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
- Kotlin - Classes & Objects
- Kotlin - Sealed Classes
- Kotlin - Delegation
- Kotlin - Lambdas & Higher-Order Functions
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
// 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
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
- When would you choose a sealed class over an enum for implementing a state machine?
- How do Kotlin's default parameters reduce the need for the Builder pattern?
- What are the advantages of using higher-order functions instead of strategy interfaces?
Show answers
- When states need to carry different data or when you need more complex state transitions than simple enums allow.
- Default parameters eliminate the need for telescoping constructors and provide named arguments for clarity, reducing builder complexity.
- Higher-order functions provide better composability, reduce boilerplate, and enable functional programming patterns like partial application.
Exercises
- Implement a type-safe state machine for a traffic light using sealed classes and when expressions.
- Create a functional pipeline processor using higher-order functions instead of the Chain of Responsibility pattern.
- Design a configuration system using data classes with default parameters instead of the Builder pattern.
- Implement an event sourcing system using sealed classes for events and a reducer function for state updates.