You can mix OOP and functional programming in Kotlin by using objects/classes to model state, identity, boundaries, and domain concepts, while using functions/lambdas to model behavior, transformation, policies, and workflows.
Kotlin is especially good at this because it supports:
- Classes, interfaces, inheritance, encapsulation
- Data classes and sealed classes
- Lambdas and higher-order functions
- Immutability with
val - Extension functions
- Scope functions like
let,run,also,apply - Collection pipelines like
map,filter,fold
1. Use OOP for domain models, FP for transformations
A common Kotlin style is to model entities with classes, then transform them using pure functions.
data class User(
val id: Long,
val name: String,
val age: Int,
val isActive: Boolean
)
fun User.canReceivePromotion(): Boolean =
isActive && age >= 18
fun promoteEligibleUsers(users: List<User>): List<User> =
users.filter { it.canReceivePromotion() }
Here:
Useris an OOP-style domain object.canReceivePromotionis behavior attached through an extension function.filterexpresses functional transformation over a collection.
This avoids writing procedural loops like:
val result = mutableListOf<User>()
for (user in users) {
if (user.isActive && user.age >= 18) {
result.add(user)
}
}
Prefer:
val result = users.filter { it.isActive && it.age >= 18 }
2. Prefer immutable objects
Functional programming favors immutability. In Kotlin, use val and data class.copy().
data class Order(
val id: Long,
val status: OrderStatus,
val total: Double
)
enum class OrderStatus {
Draft,
Paid,
Shipped,
Cancelled
}
fun markAsPaid(order: Order): Order =
order.copy(status = OrderStatus.Paid)
Instead of mutating the existing object:
class MutableOrder {
var status: OrderStatus = OrderStatus.Draft
}
Prefer creating a new value:
val paidOrder = order.copy(status = OrderStatus.Paid)
This makes code easier to test, reason about, and safely use in concurrent contexts.
3. Use interfaces plus lambdas for Strategy pattern
The classic OOP Strategy pattern uses an interface:
interface DiscountStrategy {
fun apply(total: Double): Double
}
class NoDiscount : DiscountStrategy {
override fun apply(total: Double): Double = total
}
class PercentageDiscount(
private val percent: Double
) : DiscountStrategy {
override fun apply(total: Double): Double =
total * (1 - percent)
}
In Kotlin, if the behavior is simple, you can replace the interface with a function type:
typealias DiscountStrategy = (Double) -> Double
val noDiscount: DiscountStrategy = { total -> total }
fun percentageDiscount(percent: Double): DiscountStrategy =
{ total -> total * (1 - percent) }
class CheckoutService(
private val discountStrategy: DiscountStrategy
) {
fun checkout(total: Double): Double =
discountStrategy(total)
}
Usage:
val service = CheckoutService(
discountStrategy = percentageDiscount(0.15)
)
val finalTotal = service.checkout(100.0)
println(finalTotal) // 85.0
This is a good example of mixing both styles:
CheckoutServiceis object-oriented.DiscountStrategyis functional.- The behavior is injected as a function.
Use an interface when the strategy has multiple methods or meaningful identity. Use a function type when it is just one operation.
4. Use sealed classes for type-safe state and results
Instead of nullable values, error codes, or large inheritance hierarchies, Kotlin often uses sealed classes.
sealed class PaymentResult {
data class Success(val transactionId: String) : PaymentResult()
data class Failure(val reason: String) : PaymentResult()
data object Pending : PaymentResult()
}
Then handle all possible cases with when:
fun describe(result: PaymentResult): String =
when (result) {
is PaymentResult.Success ->
"Payment completed: ${result.transactionId}"
is PaymentResult.Failure ->
"Payment failed: ${result.reason}"
PaymentResult.Pending ->
"Payment is pending"
}
This combines:
- OOP-style polymorphic modeling
- FP-style expression-based branching
- Compile-time exhaustiveness checking
This is often cleaner than:
class PaymentResult(
val success: Boolean,
val errorMessage: String?,
val transactionId: String?
)
because that structure can represent invalid states.
5. Replace some Template Method patterns with higher-order functions
Classic OOP Template Method might look like this:
abstract class ReportGenerator {
fun generate(): String {
val data = loadData()
val formatted = format(data)
return export(formatted)
}
protected abstract fun loadData(): List<String>
protected abstract fun format(data: List<String>): String
protected abstract fun export(content: String): String
}
In Kotlin, you can often use higher-order functions:
class ReportGenerator(
private val loadData: () -> List<String>,
private val format: (List<String>) -> String,
private val export: (String) -> String
) {
fun generate(): String {
val data = loadData()
val formatted = format(data)
return export(formatted)
}
}
Usage:
val generator = ReportGenerator(
loadData = { listOf("A", "B", "C") },
format = { data -> data.joinToString(separator = "\n") },
export = { content -> "Report:\n$content" }
)
println(generator.generate())
This avoids subclassing when all you need is customizable behavior.
6. Use composition over inheritance
Kotlin works well when you compose small behaviors instead of building deep inheritance trees.
interface Logger {
fun log(message: String)
}
class ConsoleLogger : Logger {
override fun log(message: String) {
println(message)
}
}
class UserService(
private val logger: Logger,
private val validateUser: (User) -> Boolean
) {
fun register(user: User) {
if (!validateUser(user)) {
logger.log("Invalid user: ${user.name}")
return
}
logger.log("Registered user: ${user.name}")
}
}
Here:
Loggeris an OOP abstraction.validateUseris a functional dependency.UserServicecomposes both.
Usage:
val service = UserService(
logger = ConsoleLogger(),
validateUser = { user -> user.age >= 18 && user.isActive }
)
This is flexible without excessive inheritance.
7. Use extension functions to add behavior without modifying classes
Extension functions are useful when you want functional-style transformations around OOP models.
data class Product(
val name: String,
val price: Double,
val category: String
)
fun Product.withTax(rate: Double): Product =
copy(price = price * (1 + rate))
fun Product.isExpensive(): Boolean =
price > 100.0
Usage:
val products = listOf(
Product("Keyboard", 80.0, "Electronics"),
Product("Monitor", 250.0, "Electronics")
)
val taxedExpensiveProducts =
products
.map { it.withTax(0.2) }
.filter { it.isExpensive() }
This keeps your domain model clean while allowing expressive pipelines.
8. Use functional pipelines inside object-oriented services
You do not need to choose between “service classes” and functional pipelines. They combine naturally.
class InvoiceService {
fun calculateTotal(items: List<InvoiceItem>): Double =
items
.filter { it.quantity > 0 }
.map { it.price * it.quantity }
.sum()
}
data class InvoiceItem(
val name: String,
val price: Double,
val quantity: Int
)
The class defines the business capability. The method implementation uses functional collection operations.
9. Use command objects or lambdas for Command pattern
Classic OOP Command pattern:
interface Command {
fun execute()
}
class PrintCommand(
private val message: String
) : Command {
override fun execute() {
println(message)
}
}
Kotlin functional version:
typealias Command = () -> Unit
val printCommand: Command = {
println("Hello from command")
}
fun runCommand(command: Command) {
command()
}
Usage:
runCommand {
println("Executing inline command")
}
Use a class-based command when the command has state, metadata, undo behavior, or lifecycle. Use a lambda when it is just executable behavior.
10. Use Repository as OOP boundary, FP for business rules
A practical architecture pattern is:
- Repositories/gateways: OOP interfaces
- Use cases/services: classes
- Business rules: pure functions
- Data transformations: functional pipelines
interface UserRepository {
fun findAll(): List<User>
fun save(user: User)
}
class ActivateUsersUseCase(
private val repository: UserRepository
) {
fun execute() {
repository
.findAll()
.filter { shouldActivate(it) }
.map { it.copy(isActive = true) }
.forEach { repository.save(it) }
}
private fun shouldActivate(user: User): Boolean =
!user.isActive && user.age >= 18
}
This gives you:
- Testable boundaries
- Clear dependency injection
- Pure business logic where possible
- OOP structure where useful
11. Map design patterns to Kotlin idioms
Many GoF-style design patterns become simpler in Kotlin.
| Classic Pattern | Kotlin-Friendly Approach |
|---|---|
| Strategy | Function type, lambda, or interface |
| Command | () -> Unit or command class |
| Factory | Top-level function, companion object, or lambda |
| Template Method | Higher-order function composition |
| Decorator | Composition, extension functions, wrapper classes |
| Observer | Function callbacks, Flow, listener interfaces |
| State | Sealed classes plus when |
| Visitor | Sealed classes plus exhaustive when |
| Builder | Named/default arguments, DSL builders |
| Adapter | Extension functions or wrapper classes |
Example factory:
sealed class Notification {
data class Email(val address: String) : Notification()
data class Sms(val phoneNumber: String) : Notification()
}
fun createNotification(type: String, target: String): Notification =
when (type) {
"email" -> Notification.Email(target)
"sms" -> Notification.Sms(target)
else -> error("Unsupported notification type: $type")
}
12. A practical mixed-style example
data class CartItem(
val name: String,
val price: Double,
val quantity: Int
)
data class Cart(
val items: List<CartItem>
)
typealias PricingRule = (CartItem) -> Double
class CartCalculator(
private val pricingRule: PricingRule
) {
fun total(cart: Cart): Double =
cart.items.sumOf { item ->
pricingRule(item) * item.quantity
}
}
fun standardPricing(item: CartItem): Double =
item.price
fun discountedPricing(discount: Double): PricingRule =
{ item -> item.price * (1 - discount) }
Usage:
val cart = Cart(
listOf(
CartItem("Book", 20.0, 2),
CartItem("Pen", 2.0, 5)
)
)
val calculator = CartCalculator(
pricingRule = discountedPricing(0.10)
)
println(calculator.total(cart)) // 45.0
What this demonstrates:
CartandCartItemare OOP/value models.PricingRuleis a functional strategy.CartCalculatoris an object-oriented service.- The total calculation uses functional collection operations.
General guidelines
Use OOP when you need:
- Domain concepts with identity
- Encapsulation
- Long-lived services
- Polymorphic boundaries
- Dependency injection
- External system boundaries like repositories, APIs, databases
Use functional programming when you need:
- Data transformations
- Stateless business rules
- Reusable policies
- Collection processing
- Validation
- Pipelines
- Behavior injection
A good Kotlin rule of thumb:
Model your domain with objects, model your behavior with functions, and keep state immutable unless mutation is clearly necessary.
That gives you Kotlin code that is expressive, testable, and maintainable.
