Skip to main content

Behavioral Patterns

Behavioral patterns define how objects interact and distribute responsibility. They answer questions like "who handles this request?", "how do I swap algorithms?", "how do I save and restore state?", "how do I traverse a collection?". This chapter covers all ten GoF behavioral patterns with the Android APIs that use them.

1. Chain of Responsibility — pass the request down the line

Intent: Pass a request along a chain of handlers. Each handler decides to process or pass it on.

OkHttp Interceptor chain — the canonical example

val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor()) // 1. adds Authorization header
.addInterceptor(LoggingInterceptor()) // 2. logs the request
.addInterceptor(CacheInterceptor()) // 3. returns cached response if fresh
.addInterceptor(RetryInterceptor()) // 4. retries on 5xx
.build()

Each interceptor can:

  • Pass through unchanged (chain.proceed(chain.request()))
  • Modify the request before passing
  • Short-circuit with its own response
  • Modify the response before returning
class CacheInterceptor @Inject constructor(
private val cache: ResponseCache
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.method == "GET") {
cache.get(request)?.let { cached ->
if (!cached.isStale) return cached.toResponse() // short-circuit
}
}
val response = chain.proceed(request)
cache.store(request, response)
return response
}
}

Compose pointer input — another chain

Modifier
.pointerInput(Unit) { detectTapGestures(onTap = { /* ... */ }) }
.pointerInput(Unit) { detectDragGestures { _, drag -> /* ... */ } }

Pointer events flow through each handler; the first to consume it stops the chain.

Custom handler chain

interface Handler<T> {
fun handle(request: T, next: (T) -> Unit)
}

class ChainOfHandlers<T>(private val handlers: List<Handler<T>>) {
fun dispatch(request: T) {
val iterator = handlers.iterator()
fun proceed(req: T) {
if (iterator.hasNext()) iterator.next().handle(req) { proceed(it) }
}
proceed(request)
}
}

// Example — moderation pipeline
class ProfanityHandler : Handler<String> {
override fun handle(request: String, next: (String) -> Unit) {
if (containsProfanity(request)) return // stop here
next(request)
}
}
class LengthHandler : Handler<String> {
override fun handle(request: String, next: (String) -> Unit) {
if (request.length > 2_000) return
next(request)
}
}
class PostHandler(private val postRepo: PostRepository) : Handler<String> {
override fun handle(request: String, next: (String) -> Unit) {
postRepo.submit(request); next(request)
}
}

2. Command — encapsulate a request as an object

Intent: Turn a request into a standalone object with all its parameters, enabling queuing, logging, undo, and delayed execution.

Use cases are commands

class PlaceOrderUseCase @Inject constructor(
private val cart: CartRepository,
private val payment: PaymentGateway,
private val orders: OrderRepository
) {
suspend operator fun invoke(paymentMethod: PaymentMethod): Outcome<OrderId, OrderError> {
val items = cart.current()
val receipt = payment.charge(items.total, paymentMethod)
orders.save(Order.from(items, receipt))
cart.clear()
return Outcome.Ok(OrderId(receipt.orderId))
}
}

Every use case is a command — invocable, testable, queueable, and composable.

WorkManager requests — delayed commands

val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setInitialDelay(10, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setInputData(workDataOf("userId" to userId))
.build()

WorkManager.getInstance(context).enqueue(syncRequest)

OneTimeWorkRequest is a command object — parameters captured, enqueued, executed later (possibly after process death).

Undo with command + memento

interface Command {
fun execute()
fun undo()
}

class DeleteMessageCommand(
private val messageId: MessageId,
private val dao: MessageDao
) : Command {
private var deletedMessage: MessageEntity? = null

override fun execute() {
deletedMessage = dao.getById(messageId.raw)
dao.delete(messageId.raw)
}

override fun undo() {
deletedMessage?.let(dao::insert)
}
}

class CommandHistory {
private val history = ArrayDeque<Command>()
private val redoStack = ArrayDeque<Command>()

fun execute(command: Command) {
command.execute()
history.addLast(command)
redoStack.clear()
}

fun undo() {
history.removeLastOrNull()?.let {
it.undo()
redoStack.addLast(it)
}
}

fun redo() {
redoStack.removeLastOrNull()?.let {
it.execute()
history.addLast(it)
}
}
}

Intent as command

android.content.Intent is literally a command — "do this action with these parameters" captured as a serializable object that can cross process boundaries.


3. Iterator — traverse a collection without exposing its shape

Intent: Provide a way to access elements of an aggregate object sequentially without exposing its underlying representation.

Kotlin Iterator<T> and Sequence<T>

// Iterator — external iteration
val list = listOf(1, 2, 3, 4, 5)
val iter = list.iterator()
while (iter.hasNext()) { println(iter.next()) }

// Sequence — lazy internal iteration
val seq = sequence {
yield(1)
yield(2)
yieldAll(listOf(3, 4, 5))
}
seq.forEach(::println)

Flow — async iterator

Flow<T> is a suspending iterator — each element is produced asynchronously:

fun messages(conversationId: String): Flow<Message> = flow {
var cursor: String? = null
do {
val page = api.page(conversationId, cursor)
page.messages.forEach { emit(it) } // emit one at a time
cursor = page.nextCursor
} while (cursor != null)
}

// Consumer
messages("c1").collect { message ->
println(message)
}

Paging 3 PagingSource — paginated iterator

class MessagePagingSource(
private val api: MessageApi,
private val conversationId: String
) : PagingSource<String, Message>() {

override suspend fun load(params: LoadParams<String>): LoadResult<String, Message> = try {
val page = api.page(conversationId, cursor = params.key, limit = params.loadSize)
LoadResult.Page(
data = page.messages,
prevKey = page.prevCursor,
nextKey = page.nextCursor
)
} catch (e: IOException) {
LoadResult.Error(e)
}

override fun getRefreshKey(state: PagingState<String, Message>): String? =
state.anchorPosition?.let { state.closestPageToPosition(it)?.prevKey }
}

Paging handles the iteration protocol (load next page, load previous, handle errors). You provide the "load N items from cursor" logic.


4. Mediator — centralize complex communication

Intent: Define an object that encapsulates how a set of objects interact, preventing explicit references between them.

The problem

Without a mediator, N components talking to each other produces N² edges:

TextField ←→ Button
↕ ╲ ╱ ↕
↕ ╳ ↕ ← tangled
↕ ╱ ╲ ↕
Checkbox ←→ ErrorView

With a mediator (ViewModel):

TextField Button Checkbox ErrorView
↘ ↓ ↓ ↙
ViewModel (mediator)

ViewModel as mediator

@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepo: AuthRepository
) : ViewModel() {

private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()

fun onIntent(intent: LoginIntent) {
when (intent) {
is LoginIntent.EmailChanged -> _state.update {
it.copy(email = intent.value, emailError = null, canSubmit = validate(intent.value, it.password))
}
is LoginIntent.PasswordChanged -> _state.update {
it.copy(password = intent.value, passwordError = null, canSubmit = validate(it.email, intent.value))
}
is LoginIntent.Submit -> submit()
}
}
/* ... */
}

The TextField emits intents; the ViewModel updates state; the ErrorView and Button observe state. Components don't know about each other.

The NavController mediates between screens. Each screen says "I want to go to X"; the controller handles back stack, transition, argument delivery. Screens don't directly instantiate one another.


5. Memento — save/restore without violating encapsulation

Intent: Capture an object's internal state so it can be restored later, without exposing its internals.

SavedStateHandle — Android's memento for ViewModels

@HiltViewModel
class EditorViewModel @Inject constructor(
private val savedState: SavedStateHandle
) : ViewModel() {

// These StateFlows persist across process death via savedState
val draftTitle: StateFlow<String> = savedState.getStateFlow("title", "")
val draftBody: StateFlow<String> = savedState.getStateFlow("body", "")

fun updateTitle(title: String) { savedState["title"] = title }
fun updateBody(body: String) { savedState["body"] = body }
}

SavedStateHandle is literally a memento — the ViewModel stores its state into it; the platform restores it after process death.

Bundle — activity state memento

class PlayerActivity : AppCompatActivity() {
private var currentPosition: Long = 0

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putLong("position", currentPosition) // capture memento
}

override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
currentPosition = savedInstanceState.getLong("position", 0L) // restore memento
}
}

rememberSaveable — Compose's memento

@Composable
fun DraftEditor() {
var title by rememberSaveable { mutableStateOf("") }
var body by rememberSaveable { mutableStateOf("") }
/* ... */
}

rememberSaveable packages the state into a Bundle (memento) and restores it when the composable re-enters composition.

Custom Saver — memento protocol

data class DraftSnapshot(val title: String, val body: String, val tags: List<String>)

val DraftSnapshotSaver: Saver<DraftSnapshot, List<Any>> = Saver(
save = { snap -> listOf(snap.title, snap.body, snap.tags) }, // capture
restore = { list -> // restore
DraftSnapshot(
title = list[0] as String,
body = list[1] as String,
tags = list[2] as List<String>
)
}
)

6. Observer — publish/subscribe

Intent: Define a one-to-many dependency so when one object changes, all its dependents are notified.

StateFlow / SharedFlow — the modern form

class CartRepository {
private val _items = MutableStateFlow<List<CartItem>>(emptyList())
val items: StateFlow<List<CartItem>> = _items.asStateFlow() // subject

fun add(item: CartItem) {
_items.update { it + item } // notify observers
}
}

// Observers
@Composable
fun CartBadge(cartRepo: CartRepository) {
val items by cartRepo.items.collectAsStateWithLifecycle()
BadgedBox(badge = { Badge { Text("${items.size}") } }) { /* ... */ }
}

@Composable
fun CartTotal(cartRepo: CartRepository) {
val items by cartRepo.items.collectAsStateWithLifecycle()
Text("Total: ${items.sumOf { it.priceCents }}")
}

Both composables observe the same subject independently.

Compose State<T> — observer for recomposition

Every State<T> read inside a composable registers that composable as an observer. When the state writes, the observer recomposes. This is the observer pattern baked into the Compose runtime.

LiveData (legacy) — observer with lifecycle awareness

class UserViewModel : ViewModel() {
private val _user = MutableLiveData<User>()
val user: LiveData<User> = _user
}

// In an Activity
viewModel.user.observe(this) { user ->
nameView.text = user.name
}

Modern code uses StateFlow. collectAsStateWithLifecycle provides the same lifecycle-aware collection.


7. State — encode behavior in state objects

Intent: Allow an object to alter its behavior when its internal state changes. The object appears to change its class.

Sealed state machines

sealed interface Connection {
data object Disconnected : Connection
data object Connecting : Connection
data class Connected(val socket: WebSocket) : Connection
data class Reconnecting(val attempt: Int, val nextTryMs: Long) : Connection
}

class ChatClient {
private var state: Connection = Connection.Disconnected

suspend fun send(message: String) = when (val s = state) {
Connection.Disconnected -> throw IllegalStateException("Not connected")
Connection.Connecting -> throw IllegalStateException("Still connecting")
is Connection.Connected -> s.socket.send(message)
is Connection.Reconnecting -> queue(message)
}
}

Behavior varies entirely by the state class. Adding a new state requires the compiler to warn about every when — impossible to forget a case.

UI state machines — see MVI chapter

The checkout example from MVI is a pure State pattern. Each phase (Ready, Submitting, Failed, Confirmed) changes both what the UI shows and what intents it accepts.


8. Strategy — interchangeable algorithms

Intent: Define a family of algorithms, encapsulate each, and make them interchangeable.

Sorting strategies

sealed interface ProductSort {
fun sort(products: List<Product>): List<Product>

data object PriceAscending : ProductSort {
override fun sort(p: List<Product>) = p.sortedBy { it.price.cents }
}
data object PriceDescending : ProductSort {
override fun sort(p: List<Product>) = p.sortedByDescending { it.price.cents }
}
data object NewestFirst : ProductSort {
override fun sort(p: List<Product>) = p.sortedByDescending { it.createdAt }
}
data object BestRated : ProductSort {
override fun sort(p: List<Product>) = p.sortedByDescending { it.rating }
}
}

@HiltViewModel
class CatalogViewModel @Inject constructor(
repo: ProductRepository
) : ViewModel() {
private val _sort = MutableStateFlow<ProductSort>(ProductSort.NewestFirst)

val products: StateFlow<List<Product>> = combine(repo.observeAll(), _sort) { products, sort ->
sort.sort(products)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

fun onSortChanged(sort: ProductSort) { _sort.value = sort }
}

Compose AnimationSpec — strategy

animateDpAsState(
targetValue = size,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), // strategy
label = "size"
)
// Swap spring() for tween(300), keyframes { ... }, or snap()

Each AnimationSpec implementation is a strategy for how to interpolate.

Function types as strategies

Kotlin often replaces strategy classes with function types:

// Before — strategy interface + classes
interface PriceFormatter { fun format(cents: Long): String }

// After — function type
typealias PriceFormatter = (Long) -> String

val usdFormatter: PriceFormatter = { cents -> "$${cents / 100}.${cents % 100}" }
val eurFormatter: PriceFormatter = { cents -> "€${cents / 100},${cents % 100}" }

class ProductRow(private val format: PriceFormatter) { /* ... */ }

9. Template Method — fixed skeleton with varied steps

Intent: Define the skeleton of an algorithm in a method, deferring some steps to subclasses.

CoroutineWorker — template method

abstract class CoroutineWorker(/* ... */) {
// Template method — framework calls this and orchestrates lifecycle
suspend fun startWork(): Result {
try {
return doWork() // the "vary" step
} catch (t: Throwable) {
return Result.failure()
} finally {
// cleanup
}
}

// Subclass implements this
abstract suspend fun doWork(): Result
}

// Your worker fills in the template
class SyncWorker(...) : CoroutineWorker(...) {
override suspend fun doWork(): Result {
return try {
sync()
Result.success()
} catch (e: IOException) {
Result.retry()
}
}
}

AppCompatActivity / ComponentActivity — template method

Lifecycle methods (onCreate, onStart, onResume, etc.) are template method steps. The framework drives the flow; you override specific steps.

RecyclerView.Adapter — template method + factory method

onCreateViewHolder, onBindViewHolder, getItemCount — steps you fill in. The framework orchestrates layout, recycling, and scrolling.

The Kotlin alternative — higher-order functions

When the "skeleton" is small, prefer higher-order functions over class inheritance:

suspend fun <T> withRetry(
attempts: Int = 3,
initialDelay: Duration = 1.seconds,
block: suspend (attempt: Int) -> T
): T {
var lastError: Throwable? = null
repeat(attempts) { i ->
try {
return block(i + 1)
} catch (e: IOException) {
lastError = e
delay(initialDelay * (1L shl i)) // exponential backoff
}
}
throw lastError!!
}

// Usage — caller fills in the "what," helper handles the "how"
val user = withRetry { attempt ->
println("attempt $attempt")
api.getUser(id)
}

This is often cleaner than a BaseRetryingApiCall abstract class.


10. Visitor — operation on a structure without changing it

Intent: Represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements.

The classic visitor

sealed interface Expr {
data class Num(val value: Double) : Expr
data class Add(val left: Expr, val right: Expr) : Expr
data class Mul(val left: Expr, val right: Expr) : Expr
}

// Visitor — a new operation on the Expr tree
interface ExprVisitor<R> {
fun visit(num: Expr.Num): R
fun visit(add: Expr.Add): R
fun visit(mul: Expr.Mul): R
}

fun <R> Expr.accept(visitor: ExprVisitor<R>): R = when (this) {
is Expr.Num -> visitor.visit(this)
is Expr.Add -> visitor.visit(this)
is Expr.Mul -> visitor.visit(this)
}

// Concrete visitors
class Evaluator : ExprVisitor<Double> {
override fun visit(num: Expr.Num) = num.value
override fun visit(add: Expr.Add) = add.left.accept(this) + add.right.accept(this)
override fun visit(mul: Expr.Mul) = mul.left.accept(this) * mul.right.accept(this)
}

class Printer : ExprVisitor<String> {
override fun visit(num: Expr.Num) = num.value.toString()
override fun visit(add: Expr.Add) = "(${add.left.accept(this)} + ${add.right.accept(this)})"
override fun visit(mul: Expr.Mul) = "(${mul.left.accept(this)} * ${mul.right.accept(this)})"
}

val expr = Expr.Add(Expr.Num(2.0), Expr.Mul(Expr.Num(3.0), Expr.Num(4.0)))
println(expr.accept(Evaluator())) // 14.0
println(expr.accept(Printer())) // (2.0 + (3.0 * 4.0))

Sealed when — the idiomatic Kotlin alternative

In Kotlin, exhaustive when on a sealed type is the visitor:

fun Expr.evaluate(): Double = when (this) {
is Expr.Num -> value
is Expr.Add -> left.evaluate() + right.evaluate()
is Expr.Mul -> left.evaluate() * right.evaluate()
}

fun Expr.pretty(): String = when (this) {
is Expr.Num -> value.toString()
is Expr.Add -> "(${left.pretty()} + ${right.pretty()})"
is Expr.Mul -> "(${left.pretty()} * ${right.pretty()})"
}

Adding a new operation (e.g., simplify()) is a new extension function. Adding a new node type (e.g., Div) forces every when to handle it — the compiler guarantees you update every visitor.

This is strictly better than the classic visitor in Kotlin.

When classic Visitor still wins

  • Across module boundaries — when you can't edit the sealed type because it's in another module
  • Plugin architectures — third parties define new visitors without modifying the core

For most Android code, prefer sealed + exhaustive when.


Behavioral family at a glance

PatternKotlin-friendly formAndroid examples
Chain of ResponsibilityOkHttp interceptors; pointer inputInterceptor chain, pointerInput
CommandUse cases; WorkRequestIntent, use cases, WorkManager
IteratorIterator / Sequence / Flow / PagingSourceList iterators, Paging 3
MediatorViewModel; NavControllerMVVM / MVI, Navigation component
MementoSavedStateHandle, rememberSaveable, SaverViewModel state, Bundle, activity state
ObserverStateFlow / SharedFlow; Compose StateUI state observation, LiveData
StateSealed state machinesConnection states, MVI phases
StrategySealed objects; function typesAnimationSpec, Sort orders
Template MethodAbstract base classes; HOF with lambdasCoroutineWorker, Activity lifecycle
VisitorSealed + exhaustive whenAny sealed type + when

Key takeaways

Practice exercises

  1. 01

    Write an interceptor chain

    Add a custom OkHttp interceptor that logs a correlation ID header. Test that it composes cleanly with Auth and Cache interceptors.

  2. 02

    Build a command + memento undo stack

    Implement a DeleteMessageCommand with execute/undo. Wire a CommandHistory that supports undo and redo in a Compose UI.

  3. 03

    State machine with sealed + when

    Model a file download (Idle, Downloading, Paused, Done, Failed) as a sealed interface. Write a @Composable that exhaustively renders each state.

  4. 04

    Strategy via function types

    Find a place in your code that accepts a Formatter interface with one method. Replace with a function type (Long) -> String.

  5. 05

    Visitor via extension functions

    Define a sealed Expr AST. Implement evaluate() and pretty() as extension functions. Add a new Div case and watch the compiler list every when you need to update.

Continue reading