04 · Design Patterns in Kotlin¶
Classic Gang-of-Four patterns exist to work around limitations of older,
more verbose OOP languages. Kotlin's language features — object,
data/sealed classes, function types, extension functions — collapse many
of them into a few lines, or make them unnecessary entirely. This module
walks through the patterns that still earn their keep in Kotlin, and how
idiomatic Kotlin expresses them.
Singleton: just use object¶
Java's Singleton pattern needs a private constructor, a static field, and
careful double-checked locking for thread safety. Kotlin's object
declaration is a thread-safe singleton, built into the language.
object ConfigRegistry {
private val values = mutableMapOf<String, String>()
fun set(key: String, value: String) { values[key] = value }
fun get(key: String) = values[key]
}
fun main() {
ConfigRegistry.set("env", "prod")
println("Singleton: env=${ConfigRegistry.get("env")}, same instance=${ConfigRegistry === ConfigRegistry}")
}
Builder: fluent setters + apply/also¶
Kotlin's named/default arguments remove most of the original need for Builder (avoiding giant constructor calls), but a fluent, chainable builder is still useful for building up a request or config object step by step.
class HttpRequestBuilder {
var url: String = ""
var method: String = "GET"
private val headers = mutableMapOf<String, String>()
fun header(name: String, value: String) = apply { headers[name] = value }
fun build(): String = "$method $url headers=$headers"
}
fun main() {
val request = HttpRequestBuilder()
.also { it.url = "https://api.example.com/tasks"; it.method = "POST" }
.header("Authorization", "Bearer token")
.header("Content-Type", "application/json")
.build()
println("Builder: $request")
}
Builder: POST https://api.example.com/tasks headers={Authorization=Bearer token, Content-Type=application/json}
apply returns the receiver (this), so header(...) can keep chaining —
that's the whole trick behind fluent builders in Kotlin: any function that
returns this (directly, or via apply) supports chaining.
Strategy: function types instead of an interface hierarchy¶
The Strategy pattern (interchangeable algorithms behind one interface) is
usually just a function type in Kotlin. A fun interface (a SAM,
single-abstract-method interface) lets callers pass either a named
strategy object or an inline lambda.
fun interface PricingStrategy {
fun price(base: Double): Double
}
val regularPricing = PricingStrategy { base -> base }
val discountPricing = PricingStrategy { base -> base * 0.9 }
fun checkout(base: Double, strategy: PricingStrategy) = strategy.price(base)
fun main() {
println("Strategy regular: ${checkout(100.0, regularPricing)}")
println("Strategy discount: ${checkout(100.0, discountPricing)}")
println("Strategy inline lambda: ${checkout(100.0) { it * 0.5 }}")
}
checkout(100.0) { it * 0.5 } works because Kotlin SAM-converts the
trailing lambda into a PricingStrategy automatically — no explicit
PricingStrategy { } wrapper needed at the call site.
Observer and State: sealed classes + listener lists¶
sealed class hierarchies make the State pattern's core promise — the
compiler knows every possible state — literal and enforced, via exhaustive
when. Observer is just a MutableList of function-type listeners; no
Observer/Observable interfaces required.
sealed class OrderState {
data object Pending : OrderState()
data object Shipped : OrderState()
data class Cancelled(val reason: String) : OrderState()
}
fun describe(state: OrderState): String = when (state) {
is OrderState.Pending -> "waiting to ship"
is OrderState.Shipped -> "on its way"
is OrderState.Cancelled -> "cancelled: ${state.reason}"
}
class OrderTracker {
private val listeners = mutableListOf<(OrderState) -> Unit>()
var state: OrderState = OrderState.Pending
set(value) {
field = value
listeners.forEach { it(value) }
}
fun onChange(listener: (OrderState) -> Unit) = listeners.add(listener)
}
fun main() {
val tracker = OrderTracker()
tracker.onChange { println("Listener A: ${describe(it)}") }
tracker.onChange { println("Listener B notified, state=$it") }
tracker.state = OrderState.Shipped
tracker.state = OrderState.Cancelled("customer request")
val states = listOf(OrderState.Pending, OrderState.Shipped, OrderState.Cancelled("out of stock"))
states.forEach { println(describe(it)) }
}
Listener A: on its way
Listener B notified, state=Shipped
Listener A: cancelled: customer request
Listener B notified, state=Cancelled(reason=customer request)
waiting to ship
on its way
cancelled: out of stock
state is a custom property setter, not a plain var — every assignment
runs through set(value), which is where the listener notification lives.
This is the whole "Observable property" idea, with zero framework code.
Kotlin-specific traps¶
whenon a sealed class is only exhaustive without anelsebranch if every subtype is covered. Adding a new subtype later and forgetting to update awhenis caught by the compiler only if there's no catch-allelse— resist the urge to addelse -> ..."just in case," since it silently swallows new states.data object(Kotlin 1.9+) vs. plainobjectfor sealed leaves.data objectgives you a sensibletoString(); a plain nestedobjectprints its mangled class name, which is a common surprise when logging.applyvs.alsovs.with/let.apply/alsoreturn the receiver (this/it) — good for builder chains and side effects.let/with/runcan return something else — good for transformations. Using the wrong one breaks a chain silently by returningUnitinstead of the object.- A
fun interfaceis not the same as a type alias for a function type.fun interface Strategy { fun run(): Int }gives you SAM conversion and a nominal type you can extend/implement normally;typealias Strategy = () -> Intis purely structural — pick based on whether you need named implementations later. - Singleton
objects are eagerly initialized on first access, not at class-load time — usually irrelevant, but it means side effects in anobject's initializer run lazily, at a time determined by whoever references it first.
How It Actually Works¶
apply/also aren't language keywords — they're ordinary inline
extension functions in the standard library (kotlin.apply,
kotlin.also), each roughly one line: inline fun <T> T.apply(block: T.()
-> Unit): T { block(); return this }. Because they're marked inline,
calling .apply { it.url = ...; it.method = ... } pastes that lambda's
bytecode directly into your main() function at the call site — there is no
extra method call, no extra Function1 allocation, and no separate stack
frame at runtime; it looks like a control-flow block because after
inlining, that's essentially all it is. apply's lambda has T as its
receiver (so it isn't needed — properties resolve unqualified against
the object being configured), while also's lambda takes T as a regular
parameter named via it — that difference is purely in the declared
lambda type (T.() -> Unit vs (T) -> Unit), not in any special-casing by
the compiler beyond ordinary lambda-with-receiver mechanics.
fun interface PricingStrategy (a SAM — single-abstract-method interface)
lets PricingStrategy { base -> base } compile a plain lambda directly into
an instance of a compiler-generated anonymous class implementing
PricingStrategy, whose one abstract method's body is the lambda — this is
SAM conversion, and it's what lets Strategy-pattern code accept either a
real named class implementing the interface or a bare lambda
interchangeably: both end up as ordinary objects implementing the same
interface, resolved by ordinary invokeinterface dispatch at the call site
that eventually calls .price(...). Without the fun modifier on the
interface, this shorthand isn't available and you'd need to write out
object : PricingStrategy { override fun price(...) = ... } by hand — the
fun interface keyword is purely a compiler permission slip enabling the
lambda-to-interface conversion, not a different runtime type.
Cheat sheet¶
| Pattern | Idiomatic Kotlin |
|---|---|
| Singleton | object Name { ... } |
| Builder | Chainable methods returning apply { }, or named/default args |
| Strategy | fun interface + lambda, or plain function type parameter |
| Observer | MutableList<(T) -> Unit> + custom property setter |
| State | sealed class/sealed interface + exhaustive when |
| Factory | A top-level or companion fun create(...): Type |
| Decorator | Extension functions, or delegation via by |
Exercise¶
Model a traffic light as a sealed class (Red, Yellow, Green) with a
next() extension function that returns the correct following state
(Red -> Green -> Yellow -> Red). Add an Observer-style TrafficLight
class that holds the current state, exposes advance() to move to the
next state and notify listeners, and register two listeners: one that
prints the state name, one that prints how many total transitions have
happened so far.