How do I mix OOP with functional programming using Kotlin’s design patterns?

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:

  • User is an OOP-style domain object.
  • canReceivePromotion is behavior attached through an extension function.
  • filter expresses 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:

  • CheckoutService is object-oriented.
  • DiscountStrategy is 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:

  • Logger is an OOP abstraction.
  • validateUser is a functional dependency.
  • UserService composes 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:

  • Cart and CartItem are OOP/value models.
  • PricingRule is a functional strategy.
  • CartCalculator is 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.