Mastering the Observer Pattern in Kotlin: From GoF to Modern Coroutines
A guide to decoupling your code using classic interfaces, idiomatic delegates, and reactive StateFlow.
The Observer Pattern is a foundational design principle that promotes loose coupling. It allows a Subject to notify Observers about state changes without knowing their concrete details. This decoupling is what makes systems scalable, maintainable, and easier to test.
As Kotlin has evolved, so has our implementation of this pattern. Let’s journey from the classic “Gang of Four” (GoF) approach to modern, reactive streams.
The Classic GoF Observer (Interfaces & Lists)
The traditional implementation relies on explicit interfaces and manual list management. While “textbook accurate,” it can be verbose in Kotlin.
interface TemperatureDisplay {
fun update(temperature: Double)
}
interface WeatherStationSubject {
fun addDisplay(display: TemperatureDisplay)
fun removeDisplay(display: TemperatureDisplay)
fun notifyDisplays()
}
class SmartWeatherStation : WeatherStationSubject {
// For production concurrency, consider a thread-safe collection
// like CopyOnWriteArrayList to avoid ConcurrentModificationException
private val displays = mutableListOf<TemperatureDisplay>()
var currentTemperature: Double = 0.0
set(value) {
field = value
notifyDisplays()
}
override fun addDisplay(display: TemperatureDisplay) { displays.add(display) }
override fun removeDisplay(display: TemperatureDisplay) { displays.remove(display) }
override fun notifyDisplays() {
displays.forEach { it.update(currentTemperature) }
}
}The Nuance: This version is manually managed. In real-world apps, you must be diligent: forgetting to call removeDisplay leads to memory leaks, as the Subject holds a hard reference to the Observer, preventing it from being garbage collected.
Idiomatic Kotlin (Lambdas + Delegates.observable)
We can eliminate “interface boilerplate” by using Kotlin’s function types and property delegates.
import kotlin.properties.Delegates
typealias TemperatureListener = (Double) -> Unit
class ModernWeatherStation {
private val listeners = mutableListOf<TemperatureListener>()
// Standard library delegate to trigger logic on property changes
var currentTemperature: Double by Delegates.observable(0.0) { _, _, newTemp ->
listeners.forEach { it(newTemp) }
}
fun addListener(listener: TemperatureListener) { listeners.add(listener) }
}Pro-Tip: While cleaner, listeners (lambdas) must be managed carefully. Unlike interfaces, lambdas can implicitly capture outer scope variables (like a Fragment or Activity instance). This creates an “anonymous” leak that is often harder to track than a traditional class-based leak.
The Reactive Revolution: StateFlow & Coroutines
In modern development — especially Android — we use StateFlow. It is a state-holder observable flow that emits the current state and subsequent updates to its collectors.
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class ReactiveWeatherStation {
// Encapsulation: Private mutable state, public immutable Flow
private val _weatherState = MutableStateFlow(0.0)
val weatherState: StateFlow<Double> = _weatherState.asStateFlow()
fun updateTemperature(temp: Double) {
_weatherState.value = temp
}
}
fun main() = runBlocking {
val station = ReactiveWeatherStation()
launch {
// 'collect' is a suspending function that acts as our Observer
station.weatherState.collect { temp -> println("Display: $temp°C") }
}
station.updateTemperature(25.0)
}Technical Clarification on Backpressure & Lifecycle:
- Conflation: StateFlow uses conflation. It ensures collectors always receive the latest state. This is ideal for UI updates. However, if every intermediate value matters (e.g., analytics or logging), use a cold
Flowor a bufferedSharedFlowinstead. - Lifecycle: StateFlow itself is not lifecycle-aware; lifecycle awareness comes from collecting it inside a structured, cancellable scope (like
viewModelScopeorlifecycleScope).
The Global Event Bus (SharedFlow)
Sometimes you need to broadcast “one-time events” (like a Logout signal) rather than a persistent state. For this, SharedFlow is the superior choice.
object GlobalEventBus {
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 1)
val events = _events.asSharedFlow()
// 'tryEmit' is preferred for global buses to avoid suspending the caller
// if a collector is currently busy.
fun postEvent(message: String) {
_events.tryEmit(message)
}
}Architectural Insight: SharedFlow is preferred over Channels for event buses because it supports broadcast semantics (multiple observers reacting to the same event). Use them sparingly to avoid spaghetti architecture where data flow becomes untraceable.
Comparison Summary
Frequently Asked Questions
Is the classic GoF pattern still relevant?
Yes, for legacy Java interop or rigid enterprise architectures. For modern Kotlin, language-level features offer more concise and safer alternatives.
Why use asStateFlow() instead of a public MutableStateFlow?
This is a core principle of encapsulation. It ensures that only the internal logic of the Subject can modify the state, while Observers are restricted to a read-only stream.
How does StateFlow handle performance?
Flows are designed for structured, cancellable asynchronous state propagation. While they have slightly more overhead than a simple list iteration, they provide safety and concurrency management that manual implementations lack.
Engaging the Community
The Observer pattern has evolved from a manual “handshake” to a reactive “stream.”
- For the UI devs: Are you still using
LiveData, or have you fully migrated toStateFlowfor state management? - For the Architects: At what point does a simple Listener list become too complex, necessitating a full Coroutine-based stream?
If you find my content useful, feel free to support my work here. Every contribution is greatly appreciated! đ


Comments
Post a Comment