Kotlin Context Parameters in Practice: Replacing Service Locators, Scoping Database Transactions, and the Zero-Boilerplate Patterns That Make Clean Architecture in KMP Actually Clean
If you’ve threaded a transaction or auth token through six layers of function parameters, you already know the pain context parameters exist to solve.
Why context receivers failed and context parameters fix it
Context receivers, experimental since Kotlin 1.6.20, brought the receiver’s members directly into scope. This caused ambiguity when multiple contexts shared member names. The compiler couldn’t reliably resolve which log() you meant when both Logger and AuditTrail were in context.
Context parameters solve this by being named and explicit. They don’t pollute the member scope. You reference them by name, and the compiler resolves them unambiguously.
// Old context receivers (deprecated)
context(Logger)
fun processOrder(order: Order) {
info("Processing ${order.id}") // Whose info()? Ambiguous.
}
// Kotlin 2.2 context parameters
context(logger: Logger)
fun processOrder(order: Order) {
logger.info("Processing ${order.id}") // Explicit. Clear. Done.
}
Pattern 1: Replacing service locators in KMP
Most teams reach for Koin or manual service locators in commonMain, then fight platform-specific initialization order. That’s the wrong instinct. Context parameters let you thread dependencies at the call-site level without a framework.
context(repo: OrderRepository, auth: AuthContext)
fun placeOrder(items: List<Item>): Result<Order> {
val user = auth.currentUser
return repo.save(Order(userId = user.id, items = items))
}
// Contexts propagate automatically through the call chain
context(repo: OrderRepository, auth: AuthContext)
fun handleCheckout(cart: Cart): Result<Order> {
validate(cart)
return placeOrder(cart.items) // No need to forward repo or auth
}
At the outermost boundary (your HTTP handler, ViewModel, or test) you provide the concrete instances once. Every function inward receives them implicitly through context propagation.
DI approach comparison
| Approach | KMP support | Boilerplate | Compile-time safety | Scoping control |
|---|---|---|---|---|
| Koin | Yes | Medium | No (runtime) | Module-level |
| Manual DI | Yes | High | Yes | Call-site |
| Context parameters | Yes | Low | Yes | Call-site |
| Hilt | Android only | Low | Yes | Component-level |
Context parameters are the only option in this table that combine compile-time safety with minimal boilerplate across all KMP targets.
Pattern 2: Scoping database transactions
Threading a transaction object through repository calls has always been ugly. ThreadLocal works on JVM but breaks in coroutines and doesn’t exist on iOS/JS targets. Context parameters handle this cleanly.
context(tx: Transaction)
fun transferFunds(from: Account, to: Account, amount: Money) {
tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from.id)
tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to.id)
}
// Scoping function introduces the context at the boundary
inline fun <T> Database.transactional(
block: context(Transaction) () -> T
): T {
val tx = beginTransaction()
try { return block(tx).also { tx.commit() } }
catch (e: Exception) { tx.rollback(); throw e }
}
// Usage — transaction scope enforced by the compiler
database.transactional {
transferFunds(checking, savings, Money(500))
}
No ThreadLocal. No coroutine context element hacks. The transaction scope is visible in the type signature and the compiler enforces it.
Pattern 3: Auth and tenant propagation
In multi-tenant SaaS backends, propagating tenant context through clean architecture layers typically means adding a parameter to every use case, repository, and service call. Context parameters collapse this entirely.
context(tenant: TenantContext, auth: AuthContext)
fun executeUseCase(): Result<Report> {
return generateReport() // Automatically forwards both contexts
}
This is where I think context parameters earn their keep. The boilerplate reduction is real, and more importantly, the intent reads clearly: this function operates within a tenant and auth scope. Period.
Migration gotchas
Overload resolution can trip you up. Two functions differing only by context parameter types can confuse the compiler. Keep context parameter lists distinct or use named invocations.
Type inference with generics gets shaky when context parameters interact with generic return types. The compiler occasionally needs explicit type arguments. This improves with each 2.2.x patch, but expect to add a few type annotations you wish you didn’t need.
Coroutine interaction is the subtlest issue. Context parameters are lexically scoped, so they remain available after suspension points within the same function body. The real risk is child coroutines: launch { } and async { } create new scopes that don’t automatically inherit context parameters from the parent. If a child coroutine calls a function requiring context parameters, you must explicitly provide them in the new scope. Use CoroutineContext for coroutine-specific concerns and context parameters for domain-level dependencies.
Where to go from here
Introduce context parameters at your use-case entry points first (the HTTP handler or ViewModel layer) and let them propagate inward. Don’t refactor everything at once.
Be deliberate about what goes in context. Auth, tenant, and transaction scoping are ideal candidates. Loggers and metrics are reasonable. Coroutine dispatchers and platform-specific services belong in CoroutineContext or platform DI. I’d resist the temptation to put everything in context just because you can.
If you’re migrating from context receivers, the process is mechanical: add names to every context declaration, replace member-style calls with named references, and let the compiler guide you. Budget one sprint for a medium-sized KMP module. In our experience, the reduction in DI ceremony paid for itself quickly.