Kotlin 2.4.0 Context Parameters: No More Passing Logger Through Six Layers
Kotlin 2.4.0 introduces context parameters, a long-awaited language feature that replaces deprecated context receivers and fundamentally changes how developers handle dependency propagation. If youve ever passed a Logger, Transaction, or AuthContext through multiple layers of your application, this update directly addresses that pain point.
Context parameters in Kotlin allow you to define dependencies once and automatically propagate them across the call chain without cluttering function signatures. Unlike traditional approaches such as dependency injection or ThreadLocal-based context, this solution is fully explicit and enforced by the compiler. In this guide, well break down what context parameters are, how they work in Kotlin 2.4.0, key differences from context receivers, and real-world examples you can apply in production code today.
The feature that started as a KEEP proposal, survived the deprecation of context receivers, and spent two releases in Beta has finally landed as Stable. Here’s what it means for production code — and what’s still experimental.
TL;DR:
- Kotlin 2.4.0 introduces context parameters as a stable replacement for deprecated context receivers.
- They allow dependencies like Logger or Transaction to be defined once and automatically propagated through the call chain.
- The approach is explicit and compiler-enforced, avoiding hidden state and ThreadLocal pitfalls.
- Most of the feature is stable, but callable references and explicit context arguments are still experimental.
What Graduated to Stable — and What Didn’t
Kotlin 2.4.0-Beta2 is the release where context parameters officially cross the line. You no longer need the -Xcontext-parameters compiler flag to use them in production code. No opt-in annotation, no worrying about the API changing under you next quarter.
There’s one important footnote: two parts of the feature remain experimental. Context arguments (explicit context(expr) call sites) and callable references to functions with context parameters stay behind the experimental gate. For most application code this won’t matter — you’ll hit stable ground on day one.
contextkeyword in function declarationscontextkeyword on properties- Underscore name (
_: Type) - Multiple context parameters
- Propagation through call chains
- Context arguments at call sites
- Callable references
::fn - Context parameters in lambdas
The feature has been on a long road: introduced as context receivers in Kotlin 1.6.20 (2022), redesigned from scratch, beta-shipped in 2.2.0, and now stable in 2.4.0. Three major versions and roughly four years of design, community feedback, and compiler surgery.
The 4-Year Architectural Debt
Context parameters didnt take four years because the design was complex; they took four years because the old Kotlin compiler frontend was, frankly, a house of cards. Trying to shove named context dependencies into the pre-K2 era would have caused the compiler to just give up and melt.
Context Parameters are finally here as a cynical admission of reality: we need static dependencies, but we’re tired of our function signatures looking like a dumpster fire of “Logger, CorrelationId, Transaction, Config”. It’s a clean way to handle a dirty job, enabled only by the fact that the K2 compiler finally has enough structural integrity to handle it without collapsing.
Context Receivers vs Context Parameters: The Real Difference
If you used context receivers before (and got the deprecation warnings around 2.0.20), you already understand the why. The what changed is more subtle.
Context receivers polluted this scope
With the old syntax, the context type became an implicit receiver. All its members dropped directly into the function scope, which sounds convenient until you have two contexts that both define a log() method — and the compiler has no idea which one you mean:
// Old syntax — DEPRECATED, will error in 2.3+
context(Logger, AuditTrail)
fun processOrder(order: Order) {
log("Processing...") // Which log()? Compiler panics.
}
Context parameters are named and explicit
Context parameters require a name. That name becomes your explicit handle inside the function body. No implicit receivers, no mystery method resolution — and the instance propagates automatically through any nested call that declares the same context:
// Stable — no compiler flag needed from 2.4.0
interface Logger {
fun log(message: String)
}
Why Kotlin DSL, Compose Compiler Plugin, and Version Catalog Break at Once You migrate to Kotlin 2.0. You switch to libs.versions.toml. You convert build.gradle to build.gradle.kts. Now the build is on fire and you're staring...
context(logger: Logger)
fun processOrder(order: Order) {
logger.log(“Processing order ${order.id}”)
validateStock(order) // logger flows down automatically
chargePayment(order)
}
| Property | Context Receivers (deprecated) | Context Parameters (stable) |
|---|---|---|
| Name required? | No — anonymous implicit this | Yes — or _ to ignore |
| Member access | Direct: log("...") |
Via name: logger.log("...") |
| Multiple contexts | Ambiguous when names clash | Clear — each has its own handle |
| Compiler flag | -Xcontext-receivers |
None required in 2.4.0 |
| Status | Deprecated, errors in 2.3+ | Stable |
Syntax Deep Dive
The underscore shorthand
If your function needs a type available in scope but never references it by name — because nested calls handle everything — use _. The compiler resolves the context by type and threads it through:
// No explicit name needed — resolves by type
context(_: Logger)
fun processOrder(order: Order) {
logMessage("Validating stock for ${order.id}")
// logMessage also declares context(_: Logger), instance flows through
}
Multiple context parameters
You can stack as many as you need. This is where context parameters genuinely outclass the old approach — every dependency is named, so there’s no guessing which object is which when you read the body three months later:
context(logger: Logger, tx: Transaction, auth: AuthContext)
fun fulfillOrder(order: Order) {
auth.requireRole(Role.FULFILMENT)
logger.log("Fulfilling ${order.id}")
tx.savePoint("pre-fulfilment")
}
Context parameters on properties
Not just functions — properties can declare context parameters too. Useful for computed properties that need a service without turning a clean val into an explicit function call:
context(repo: UserRepository)
val Order.ownerName: String
get() = repo.findById(this.userId)?.name ?: "Unknown"
You can now define domain-model extensions that “know” how to fetch related data without turning your Order class into a repository-aware god object. The repo is a declared dependency, not a captured global — and the compiler enforces it.
Production Patterns Worth Stealing
Pattern 1 — Logging without the parameter carousel
In any layered architecture, you end up threading a Logger (or CorrelationId, or TraceContext) through every function that participates in a request. Context parameters end that — declare it once at the entry point, let it flow:
context(logger: Logger)
fun handleCheckout(cart: Cart) {
logger.log("Checkout started: cart=${cart.id}")
val order = createOrder(cart) // context(logger: Logger) declared here too
chargePayment(order) // and here
sendConfirmation(order) // and here
}
// Call site — provide context with `with`
with(ProductionLogger()) {
handleCheckout(cart)
}
Kotlin Jetpack Compose Keyboard Shortcuts: Handling Hotkeys with DS Kotlin Jetpack Compose keyboard shortcuts often fail not because of broken APIs, but because of incorrect assumptions about focus and event propagation. Most developers attach onKeyEvent,...
Pattern 2 — Transaction scoping without ThreadLocal gymnastics
Thread-local transactions technically work until you’re on coroutines and the transaction context silently evaporates. Context parameters are an explicit, compiler-enforced alternative — if a function needs a transaction, the signature says so and the compiler verifies the call site provides one.
interface Transaction {
fun commit()
fun rollback()
}
context(tx: Transaction)
fun saveOrder(order: Order) {
orderTable.insert(order)
updateInventory(order.items) // tx flows here too
tx.commit()
}
Pattern 3 — Replacing extension function inflation in KMP
In Kotlin Multiplatform, teams often grow a forest of extension functions just to thread platform-specific services through commonMain. Context parameters let you declare the dependency once and propagate it structurally. Instead of HttpClient.doThing(), HttpClient.doOtherThing(), and twenty siblings, you write regular top-level functions that declare context(client: HttpClient). Signatures stay clean; the platform wires it at the entry point.
Pattern 4 — Auth context in use-case layer
Your use-case layer needs to know who’s calling, but you don’t want AuthToken polluting your domain model. Context parameters fit cleanly — declare it at the architecture boundary, let it propagate inward:
context(auth: AuthContext)
class PlaceOrderUseCase(private val repo: OrderRepository) {
fun execute(req: PlaceOrderRequest): Order {
auth.requirePermission(Permission.PLACE_ORDER)
val order = Order(userId = auth.userId)
return repo.save(order)
}
}
Context parameters don’t replace DI for construction-time dependencies like repositories or services that belong in your DI graph. Use them for call-time context: things known only when a request starts — logged user, open transaction, correlation ID, active tenant. If you find yourself putting a Retrofit instance in a context parameter, step back.
Jetpack Compose: the potential upside
The Compose team hasn’t shipped context-parameters-aware APIs yet, but the potential is real. Today, design tokens in Compose are accessed via LocalXxx.current — a CompositionLocal lookup that can’t be statically verified. A future where context(colors: ColorScheme) replaces MaterialTheme.colorScheme in component signatures would give the compiler full visibility into what a composable actually needs, turning missing-token bugs from runtime surprises into compile errors.
Context Parameters are Not CoroutineContext (Stop Dreaming)
If you harbored some naive hope that your logger would magically drift through a withContext(Dispatchers.IO) block like a ghost in the machine, its time to wake up. This isn’t ThreadLocal voodoo, and its not runtime magic.
At its core, this is just high-grade syntactic sugar. The compiler is literally rewriting your calls and dragging those arguments through the mud for you. If you start a new coroutine or a lambda and don’t explicitly pass the context in, the compiler will smack your hand with an error. Its brutal, its manual, and its honest. No hidden state, no “where the hell did my logger go” debugging sessions at 3 AM.
Migration from Context Receivers: The Practical Steps
If you shipped code with -Xcontext-receivers, the migration is mechanical. IntelliJ IDEA 2025.1+ ships a quick-fix: Replace context receivers with context parameters. You can run it project-wide via Code → Analyze Code → Run Inspection by Name.
The manual rule is simple
Every context receiver needs a name. Every call that previously worked through implicit this gets prefixed with that name. Mechanically tedious, semantically trivial:
// BEFORE — context receiver (errors in Kotlin 2.3+)
context(Logger)
fun old() = log("hello") // implicit this.log()
Kotlin 2.3.21 Fixes That Finally Make Multiplatform Performance Predictable When JetBrains drops a point-release like 2.3.21, the average developer scrolls past the changelog thinking it is just another round of "fixed rare edge case in...
// AFTER — context parameter (stable in 2.4.0)
context(logger: Logger)
fun new() = logger.log(“hello”)
Build script cleanup
In 2.4.0, remove -Xcontext-parameters from your build script entirely — the feature is stable and no flag is needed. Remove -Xcontext-receivers too; using both simultaneously causes a compiler error. For the still-experimental parts (context arguments, callable references), use @OptIn(ExperimentalContextParameters::class) at individual call sites.
Callable references: the one rough edge
If your code passed a context-receiver function as a callable reference — ::processOrder — that pattern doesn’t compile yet with context parameters. Wrap in a lambda until callable refs stabilize:
// Does NOT compile yet — callable refs still experimental
val ref = ::processOrder
// Workaround — wrap in a lambda
val ref: context(Logger) (Order) -> Unit = { o -> processOrder(o) }
What’s Still Experimental — and the Roadmap
Explicit context arguments
The context(expr) { ... } syntax at call sites requires @OptIn. Most application code doesn’t hit this; it’s relevant mainly when you have a service class holding a context-parameter-typed field and need to push it into scope for a nested block:
class OrderService(private val logger: Logger) {
fun handle(order: Order) {
context(logger) { // @OptIn(ExperimentalContextParameters::class)
processOrder(order)
}
}
}
Context parameters and coroutines: a structural note
Context parameters are not CoroutineContext elements. A new launch { } or async { } block does not automatically inherit context parameters from the parent scope — you need to pass them explicitly. The rule of thumb: coroutine-specific concerns (dispatchers, jobs, structured concurrency) belong in CoroutineContext; domain-level dependencies (logger, transaction, auth) belong in context parameters, with explicit propagation across coroutine boundaries.
The big picture
Context parameters stabilizing closes the last major open chapter of the Kotlin language redesign that started with K2. The language now has a coherent, compiler-enforced answer to the question “how do I thread cross-cutting dependencies through a call chain without global state or six-parameter signatures?” — and the answer is readable, explicit, and doesn’t require a DI framework to understand.
Start with logging and transaction scoping — lowest-risk, highest-return entry points. Once propagation through call chains clicks, the auth and tenant-context patterns follow naturally. The callable-reference gap is real but rarely hit in application code, and stabilization is on the roadmap for a near release.
Should You Refactor Your Entire Production Codebase Tomorrow?
YES — If your project is currently suffocating under a “LoggingContext” or “TracingId” that appears in the signature of every second method from the API controller down to the DAO. If your “clean architecture” is starting to look like a parameter-passing marathon, this is your exit ramp.
NO — If you are still clinging to Kotlin 1.9 because you’re terrified of the K2 migration. Or if your codebase is small enough that “passing the logger” is just a minor annoyance rather than a structural crisis. Don’t chase the hype if you’re not feeling the pain.
Written by: