Kotlin - Design Patterns

Overview

Estimated time: 90–120 minutes

Master design patterns in Kotlin with modern approaches. Learn how Kotlin's language features enable elegant implementations of classic patterns, explore functional alternatives, and understand when to use each approach.

Learning Objectives

  • Implement common design patterns using Kotlin's language features
  • Choose between object-oriented and functional approaches to pattern implementation
  • Use sealed classes, data classes, and delegation for pattern-based solutions
  • Apply Kotlin-specific patterns like scope functions and extension functions

Prerequisites

Creational Patterns

Singleton Pattern

Kotlin provides multiple ways to implement the Singleton pattern, from traditional approaches to modern idioms.

Object Declaration (Recommended)


// Thread-safe singleton using object declaration
object DatabaseManager {
    private val connection = createConnection()
    
    fun query(sql: String): List<String> {
        return connection.executeQuery(sql)
    }
    
    fun close() {
        connection.close()
    }
    
    private fun createConnection(): Connection {
        return Connection("jdbc:postgresql://localhost/mydb")
    }
}

// Usage
fun main() {
    val results = DatabaseManager.query("SELECT * FROM users")
    println("Found ${results.size} users")
    DatabaseManager.close()
}

class Connection(private val url: String) {
    fun executeQuery(sql: String): List<String> = listOf("user1", "user2")
    fun close() = println("Connection closed")
}

Class-Based Singleton (When Parameters Needed)


class ConfigManager private constructor(private val environment: String) {
    companion object {
        @Volatile
        private var INSTANCE: ConfigManager? = null
        
        fun getInstance(environment: String = "production"): ConfigManager {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: ConfigManager(environment).also { INSTANCE = it }
            }
        }
    }
    
    fun getProperty(key: String): String {
        return when (environment) {
            "development" -> "dev-$key"
            "production" -> "prod-$key"
            else -> "default-$key"
        }
    }
}

// Usage
fun main() {
    val config = ConfigManager.getInstance("development")
    println(config.getProperty("database.url"))
}

Factory Pattern

Use sealed classes and when expressions for type-safe factory implementations.


// Product hierarchy using sealed classes
sealed class Vehicle {
    abstract val type: String
    abstract fun start(): String
}

data class Car(override val type: String = "Car") : Vehicle() {
    override fun start() = "Car engine started"
}

data class Motorcycle(override val type: String = "Motorcycle") : Vehicle() {
    override fun start() = "Motorcycle engine started"
}

data class Truck(override val type: String = "Truck") : Vehicle() {
    override fun start() = "Truck engine started"
}

// Factory using sealed class for type safety
enum class VehicleType { CAR, MOTORCYCLE, TRUCK }

object VehicleFactory {
    fun createVehicle(type: VehicleType): Vehicle = when (type) {
        VehicleType.CAR -> Car()
        VehicleType.MOTORCYCLE -> Motorcycle()
        VehicleType.TRUCK -> Truck()
    }
}

// Usage
fun main() {
    val vehicles = listOf(
        VehicleFactory.createVehicle(VehicleType.CAR),
        VehicleFactory.createVehicle(VehicleType.MOTORCYCLE),
        VehicleFactory.createVehicle(VehicleType.TRUCK)
    )
    
    vehicles.forEach { vehicle ->
        println("${vehicle.type}: ${vehicle.start()}")
    }
}

Builder Pattern

Kotlin's default parameters and named arguments often eliminate the need for traditional builders, but here's how to implement when needed.


// Traditional approach with default parameters (preferred)
data class HttpRequest(
    val url: String,
    val method: String = "GET",
    val headers: Map<String, String> = emptyMap(),
    val body: String? = null,
    val timeout: Long = 5000,
    val retries: Int = 3
)

// Usage with named parameters
fun main() {
    val request = HttpRequest(
        url = "https://api.example.com/users",
        method = "POST",
        headers = mapOf("Content-Type" to "application/json"),
        body = """{"name": "John", "email": "[email protected]"}""",
        timeout = 10000
    )
    
    println("Request: $request")
}

// Builder pattern for complex construction logic
class EmailBuilder {
    private var to: List<String> = emptyList()
    private var cc: List<String> = emptyList()
    private var subject: String = ""
    private var body: String = ""
    private var attachments: List<String> = emptyList()
    
    fun to(recipients: List<String>) = apply { to = recipients }
    fun cc(recipients: List<String>) = apply { cc = recipients }
    fun subject(subject: String) = apply { this.subject = subject }
    fun body(body: String) = apply { this.body = body }
    fun attachments(files: List<String>) = apply { attachments = files }
    
    fun build(): Email {
        require(to.isNotEmpty()) { "Email must have at least one recipient" }
        require(subject.isNotBlank()) { "Email must have a subject" }
        
        return Email(to, cc, subject, body, attachments)
    }
}

data class Email(
    val to: List<String>,
    val cc: List<String>,
    val subject: String,
    val body: String,
    val attachments: List<String>
)

// Usage
fun createComplexEmail() {
    val email = EmailBuilder()
        .to(listOf("[email protected]", "[email protected]"))
        .cc(listOf("[email protected]"))
        .subject("Monthly Report")
        .body("Please find the monthly report attached.")
        .attachments(listOf("report.pdf", "data.xlsx"))
        .build()
    
    println("Email created: $email")
}

Structural Patterns

Adapter Pattern

Use extension functions and delegation for clean adapter implementations.


// Legacy system interface
interface LegacyPrinter {
    fun printOldFormat(text: String)
}

class LegacyPrinterImpl : LegacyPrinter {
    override fun printOldFormat(text: String) {
        println("LEGACY: $text")
    }
}

// Modern interface
interface ModernPrinter {
    fun print(document: Document)
}

data class Document(val title: String, val content: String)

// Adapter using delegation
class PrinterAdapter(private val legacyPrinter: LegacyPrinter) : ModernPrinter {
    override fun print(document: Document) {
        val formattedText = "${document.title}: ${document.content}"
        legacyPrinter.printOldFormat(formattedText)
    }
}

// Extension function approach
fun LegacyPrinter.asModernPrinter(): ModernPrinter = object : ModernPrinter {
    override fun print(document: Document) {
        printOldFormat("${document.title}: ${document.content}")
    }
}

// Usage
fun main() {
    val legacyPrinter = LegacyPrinterImpl()
    
    // Using adapter class
    val adapter1 = PrinterAdapter(legacyPrinter)
    adapter1.print(Document("Report", "This is the content"))
    
    // Using extension function
    val adapter2 = legacyPrinter.asModernPrinter()
    adapter2.print(Document("Invoice", "Invoice details here"))
}

Decorator Pattern

Use delegation and composition for flexible decoration.


// Base component
interface Coffee {
    val description: String
    val cost: Double
}

// Concrete component
class SimpleCoffee : Coffee {
    override val description = "Simple coffee"
    override val cost = 2.0
}

// Base decorator
abstract class CoffeeDecorator(private val coffee: Coffee) : Coffee {
    override val description get() = coffee.description
    override val cost get() = coffee.cost
}

// Concrete decorators
class MilkDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
    override val description get() = super.description + ", milk"
    override val cost get() = super.cost + 0.5
}

class SugarDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
    override val description get() = super.description + ", sugar"
    override val cost get() = super.cost + 0.2
}

class WhipDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
    override val description get() = super.description + ", whip"
    override val cost get() = super.cost + 0.7
}

// Functional approach using higher-order functions
typealias CoffeeModifier = (Coffee) -> Coffee

fun addMilk(): CoffeeModifier = { coffee ->
    object : Coffee {
        override val description = "${coffee.description}, milk"
        override val cost = coffee.cost + 0.5
    }
}

fun addSugar(): CoffeeModifier = { coffee ->
    object : Coffee {
        override val description = "${coffee.description}, sugar"
        override val cost = coffee.cost + 0.2
    }
}

// Extension function for chaining modifiers
fun Coffee.modify(vararg modifiers: CoffeeModifier): Coffee {
    return modifiers.fold(this) { coffee, modifier -> modifier(coffee) }
}

// Usage
fun main() {
    // Traditional decorator pattern
    val decoratedCoffee = WhipDecorator(
        SugarDecorator(
            MilkDecorator(SimpleCoffee())
        )
    )
    
    println("${decoratedCoffee.description} costs $${decoratedCoffee.cost}")
    
    // Functional approach
    val functionalCoffee = SimpleCoffee()
        .modify(addMilk(), addSugar())
        
    println("${functionalCoffee.description} costs $${functionalCoffee.cost}")
}

Behavioral Patterns

Observer Pattern

Use functional approaches with lambdas and higher-order functions.


// Traditional observer interface
interface Observer<T> {
    fun onChanged(data: T)
}

// Observable class with type-safe observers
class Observable<T> {
    private val observers = mutableListOf<Observer<T>>()
    
    fun addObserver(observer: Observer<T>) {
        observers.add(observer)
    }
    
    fun removeObserver(observer: Observer<T>) {
        observers.remove(observer)
    }
    
    fun notifyObservers(data: T) {
        observers.forEach { it.onChanged(data) }
    }
}

// Modern functional approach
class EventBus<T> {
    private val listeners = mutableListOf<(T) -> Unit>()
    
    fun subscribe(listener: (T) -> Unit) {
        listeners.add(listener)
    }
    
    fun unsubscribe(listener: (T) -> Unit) {
        listeners.remove(listener)
    }
    
    fun publish(event: T) {
        listeners.forEach { it(event) }
    }
}

// Specific implementation for user events
sealed class UserEvent {
    data class UserLoggedIn(val username: String) : UserEvent()
    data class UserLoggedOut(val username: String) : UserEvent()
    data class UserProfileUpdated(val username: String, val changes: Map<String, Any>) : UserEvent()
}

class UserService {
    private val eventBus = EventBus<UserEvent>()
    
    fun subscribe(listener: (UserEvent) -> Unit) = eventBus.subscribe(listener)
    
    fun login(username: String) {
        // Login logic here
        println("$username logged in")
        eventBus.publish(UserEvent.UserLoggedIn(username))
    }
    
    fun logout(username: String) {
        // Logout logic here
        println("$username logged out")
        eventBus.publish(UserEvent.UserLoggedOut(username))
    }
    
    fun updateProfile(username: String, changes: Map<String, Any>) {
        // Update logic here
        println("Profile updated for $username: $changes")
        eventBus.publish(UserEvent.UserProfileUpdated(username, changes))
    }
}

// Usage
fun main() {
    val userService = UserService()
    
    // Subscribe to events
    userService.subscribe { event ->
        when (event) {
            is UserEvent.UserLoggedIn -> {
                println("Analytics: User ${event.username} logged in")
            }
            is UserEvent.UserLoggedOut -> {
                println("Analytics: User ${event.username} logged out")
            }
            is UserEvent.UserProfileUpdated -> {
                println("Cache: Invalidating profile for ${event.username}")
            }
        }
    }
    
    // Trigger events
    userService.login("john_doe")
    userService.updateProfile("john_doe", mapOf("email" to "[email protected]"))
    userService.logout("john_doe")
}

Strategy Pattern

Use functional interfaces and lambda expressions for flexible strategy implementation.


// Traditional strategy interface
interface PaymentStrategy {
    fun pay(amount: Double): String
}

// Concrete strategies
class CreditCardStrategy(private val cardNumber: String) : PaymentStrategy {
    override fun pay(amount: Double): String {
        return "Paid $$amount using credit card ending in ${cardNumber.takeLast(4)}"
    }
}

class PayPalStrategy(private val email: String) : PaymentStrategy {
    override fun pay(amount: Double): String {
        return "Paid $$amount using PayPal account: $email"
    }
}

class CryptoStrategy(private val walletAddress: String) : PaymentStrategy {
    override fun pay(amount: Double): String {
        return "Paid $$amount using crypto wallet: ${walletAddress.take(8)}..."
    }
}

// Context class
class PaymentContext(private var strategy: PaymentStrategy) {
    fun setStrategy(strategy: PaymentStrategy) {
        this.strategy = strategy
    }
    
    fun executePayment(amount: Double): String {
        return strategy.pay(amount)
    }
}

// Functional approach using type aliases
typealias PaymentMethod = (Double) -> String

// Factory for payment methods
object PaymentMethods {
    fun creditCard(cardNumber: String): PaymentMethod = { amount ->
        "Paid $$amount using credit card ending in ${cardNumber.takeLast(4)}"
    }
    
    fun paypal(email: String): PaymentMethod = { amount ->
        "Paid $$amount using PayPal account: $email"
    }
    
    fun crypto(walletAddress: String): PaymentMethod = { amount ->
        "Paid $$amount using crypto wallet: ${walletAddress.take(8)}..."
    }
}

// Modern context using functional approach
class ModernPaymentProcessor {
    fun processPayment(amount: Double, method: PaymentMethod): String {
        return method(amount)
    }
}

// Usage
fun main() {
    // Traditional approach
    val context = PaymentContext(CreditCardStrategy("1234567890123456"))
    println(context.executePayment(100.0))
    
    context.setStrategy(PayPalStrategy("[email protected]"))
    println(context.executePayment(50.0))
    
    // Functional approach
    val processor = ModernPaymentProcessor()
    
    val creditCard = PaymentMethods.creditCard("1234567890123456")
    val paypal = PaymentMethods.paypal("[email protected]")
    val crypto = PaymentMethods.crypto("1A2B3C4D5E6F7G8H9I0J")
    
    println(processor.processPayment(100.0, creditCard))
    println(processor.processPayment(50.0, paypal))
    println(processor.processPayment(75.0, crypto))
    
    // Inline lambda
    println(processor.processPayment(25.0) { amount ->
        "Paid $$amount using bank transfer"
    })
}

Command Pattern

Use sealed classes for type-safe command implementation.


// Command interface
interface Command {
    fun execute(): String
    fun undo(): String
}

// Receiver classes
class FileSystem {
    private val files = mutableSetOf<String>()
    
    fun createFile(filename: String): String {
        files.add(filename)
        return "Created file: $filename"
    }
    
    fun deleteFile(filename: String): String {
        files.remove(filename)
        return "Deleted file: $filename"
    }
    
    fun listFiles(): String = "Files: ${files.joinToString(", ")}"
}

// Concrete commands
class CreateFileCommand(
    private val fileSystem: FileSystem,
    private val filename: String
) : Command {
    override fun execute(): String = fileSystem.createFile(filename)
    override fun undo(): String = fileSystem.deleteFile(filename)
}

class DeleteFileCommand(
    private val fileSystem: FileSystem,
    private val filename: String
) : Command {
    override fun execute(): String = fileSystem.deleteFile(filename)
    override fun undo(): String = fileSystem.createFile(filename)
}

// Modern approach using sealed classes
sealed class FileCommand {
    abstract fun execute(fileSystem: FileSystem): String
    abstract fun undo(fileSystem: FileSystem): String
    
    data class CreateFile(val filename: String) : FileCommand() {
        override fun execute(fileSystem: FileSystem) = fileSystem.createFile(filename)
        override fun undo(fileSystem: FileSystem) = fileSystem.deleteFile(filename)
    }
    
    data class DeleteFile(val filename: String) : FileCommand() {
        override fun execute(fileSystem: FileSystem) = fileSystem.deleteFile(filename)
        override fun undo(fileSystem: FileSystem) = fileSystem.createFile(filename)
    }
}

// Command invoker with undo support
class FileManager {
    private val fileSystem = FileSystem()
    private val history = mutableListOf<FileCommand>()
    
    fun execute(command: FileCommand): String {
        val result = command.execute(fileSystem)
        history.add(command)
        return result
    }
    
    fun undo(): String {
        return if (history.isNotEmpty()) {
            val lastCommand = history.removeLastOrNull()
            lastCommand?.undo(fileSystem) ?: "Nothing to undo"
        } else {
            "Nothing to undo"
        }
    }
    
    fun listFiles(): String = fileSystem.listFiles()
}

// Usage
fun main() {
    val manager = FileManager()
    
    // Execute commands
    println(manager.execute(FileCommand.CreateFile("document.txt")))
    println(manager.execute(FileCommand.CreateFile("image.png")))
    println(manager.listFiles())
    
    println(manager.execute(FileCommand.DeleteFile("document.txt")))
    println(manager.listFiles())
    
    // Undo commands
    println(manager.undo()) // Undoes delete
    println(manager.listFiles())
    
    println(manager.undo()) // Undoes create image.png
    println(manager.listFiles())
}

Kotlin-Specific Patterns

Sealed Class State Machine

Use sealed classes to model state machines with type safety.


// State representation using sealed classes
sealed class OrderState {
    object Pending : OrderState()
    object Confirmed : OrderState()
    object Shipped : OrderState()
    object Delivered : OrderState()
    object Cancelled : OrderState()
}

// Events that can trigger state transitions
sealed class OrderEvent {
    object Confirm : OrderEvent()
    object Ship : OrderEvent()
    object Deliver : OrderEvent()
    object Cancel : OrderEvent()
}

// Order with state machine logic
data class Order(
    val id: String,
    val items: List<String>,
    val state: OrderState = OrderState.Pending
) {
    fun handle(event: OrderEvent): Order {
        val newState = when (state) {
            OrderState.Pending -> when (event) {
                OrderEvent.Confirm -> OrderState.Confirmed
                OrderEvent.Cancel -> OrderState.Cancelled
                else -> throw IllegalStateException("Cannot $event from Pending state")
            }
            OrderState.Confirmed -> when (event) {
                OrderEvent.Ship -> OrderState.Shipped
                OrderEvent.Cancel -> OrderState.Cancelled
                else -> throw IllegalStateException("Cannot $event from Confirmed state")
            }
            OrderState.Shipped -> when (event) {
                OrderEvent.Deliver -> OrderState.Delivered
                else -> throw IllegalStateException("Cannot $event from Shipped state")
            }
            OrderState.Delivered -> throw IllegalStateException("Order already delivered")
            OrderState.Cancelled -> throw IllegalStateException("Order already cancelled")
        }
        
        return copy(state = newState)
    }
    
    fun canHandle(event: OrderEvent): Boolean {
        return try {
            handle(event)
            true
        } catch (e: IllegalStateException) {
            false
        }
    }
}

// Usage
fun main() {
    var order = Order("ORD-001", listOf("Laptop", "Mouse"))
    println("Initial state: ${order.state}")
    
    order = order.handle(OrderEvent.Confirm)
    println("After confirm: ${order.state}")
    
    order = order.handle(OrderEvent.Ship)
    println("After ship: ${order.state}")
    
    order = order.handle(OrderEvent.Deliver)
    println("After deliver: ${order.state}")
    
    // Check if event can be handled
    println("Can cancel delivered order: ${order.canHandle(OrderEvent.Cancel)}")
}

Scope Function Patterns

Use scope functions for common object manipulation patterns.


// Configuration pattern using apply
class DatabaseConfig {
    var host: String = "localhost"
    var port: Int = 5432
    var database: String = ""
    var username: String = ""
    var password: String = ""
    var maxConnections: Int = 10
    var timeout: Long = 5000
    
    fun connect(): String = "Connected to $host:$port/$database"
}

// Builder pattern using apply
fun createDatabaseConfig() = DatabaseConfig().apply {
    host = "prod-db.example.com"
    port = 5432
    database = "myapp"
    username = "app_user"
    password = "secure_password"
    maxConnections = 50
    timeout = 10000
}

// Validation pattern using let
fun processUser(username: String?) {
    username?.takeIf { it.isNotBlank() }
        ?.let { validUsername ->
            println("Processing user: $validUsername")
            // Additional processing...
        }
        ?: println("Invalid username provided")
}

// Resource management pattern using use
class ResourceManager : AutoCloseable {
    init {
        println("Resource acquired")
    }
    
    fun process(data: String): String {
        return "Processed: $data"
    }
    
    override fun close() {
        println("Resource released")
    }
}

fun processWithResource(data: String): String {
    return ResourceManager().use { resource ->
        resource.process(data)
    }
}

// Transformation pattern using run
data class User(val name: String, val email: String, val age: Int)

fun createUserSummary(user: User): String {
    return user.run {
        """
        User Summary:
        Name: $name
        Email: $email
        Age Category: ${when {
            age < 18 -> "Minor"
            age < 65 -> "Adult"
            else -> "Senior"
        }}
        """.trimIndent()
    }
}

// Usage
fun main() {
    // Configuration pattern
    val config = createDatabaseConfig()
    println(config.connect())
    
    // Validation pattern
    processUser("john_doe")
    processUser("")
    processUser(null)
    
    // Resource management
    val result = processWithResource("important data")
    println(result)
    
    // Transformation pattern
    val user = User("Alice Johnson", "[email protected]", 30)
    println(createUserSummary(user))
}

Anti-Patterns to Avoid

Common Mistakes

❌ Anti-Pattern: Overusing Singletons

// Don't make everything a singleton
object UserService // Hard to test, tight coupling
object EmailService // Creates dependencies
object LoggingService // Better to use dependency injection
✅ Better Approach: Dependency Injection

class UserService(private val emailService: EmailService)
class EmailService(private val logger: Logger)
// Easier to test and maintain
❌ Anti-Pattern: Deep Inheritance Hierarchies

open class Animal
open class Mammal : Animal()
open class Carnivore : Mammal()
class Cat : Carnivore() // Too deep, hard to maintain
✅ Better Approach: Composition and Interfaces

interface Eater { fun eat(food: String) }
interface Walker { fun walk() }
class Cat : Eater, Walker {
    override fun eat(food: String) = println("Cat eats $food")
    override fun walk() = println("Cat walks gracefully")
}

Best Practices

Pattern Selection Guidelines

  • Favor composition over inheritance - Use interfaces and delegation
  • Use sealed classes for state - Type-safe state machines and ADTs
  • Prefer functional approaches - Higher-order functions over interfaces when possible
  • Keep it simple - Don't force patterns where they're not needed
  • Use Kotlin idioms - Extension functions, scope functions, data classes

When to Use Each Pattern

Pattern Use When Kotlin Alternative
Singleton Need exactly one instance Object declaration
Factory Complex object creation Sealed classes + when
Builder Many optional parameters Default parameters + apply
Observer Event notification Higher-order functions
Strategy Algorithm selection Function types
Command Undo/redo functionality Sealed classes

Summary

Design patterns in Kotlin benefit from the language's modern features:

  • Sealed classes provide type-safe alternatives to traditional inheritance hierarchies
  • Data classes eliminate boilerplate in value objects
  • Extension functions enable decorator-like functionality without wrapper classes
  • Higher-order functions offer functional alternatives to behavioral patterns
  • Object declarations provide thread-safe singletons
  • Delegation enables composition-based designs

Remember that patterns are tools to solve specific problems - don't force them where simpler solutions exist. Kotlin's expressive syntax often provides more elegant alternatives to traditional pattern implementations.

Common Pitfalls

  • Over-engineering with unnecessary patterns
  • Using object-oriented patterns when functional approaches are simpler
  • Creating deep inheritance hierarchies instead of using composition
  • Ignoring Kotlin's built-in features like default parameters and extension functions

Checks for Understanding

  1. When would you choose a sealed class over an enum for implementing a state machine?
  2. How do Kotlin's default parameters reduce the need for the Builder pattern?
  3. What are the advantages of using higher-order functions instead of strategy interfaces?
Show answers
  1. When states need to carry different data or when you need more complex state transitions than simple enums allow.
  2. Default parameters eliminate the need for telescoping constructors and provide named arguments for clarity, reducing builder complexity.
  3. Higher-order functions provide better composability, reduce boilerplate, and enable functional programming patterns like partial application.

Exercises

  1. Implement a type-safe state machine for a traffic light using sealed classes and when expressions.
  2. Create a functional pipeline processor using higher-order functions instead of the Chain of Responsibility pattern.
  3. Design a configuration system using data classes with default parameters instead of the Builder pattern.
  4. Implement an event sourcing system using sealed classes for events and a reducer function for state updates.