Kotlin 2.4.0 Context Parameters: No More Passing Logger Through Six Layers

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.

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.

Stable in 2.4.0
  • context keyword in function declarations
  • context keyword on properties
  • Underscore name (_: Type)
  • Multiple context parameters
  • Propagation through call chains
Still Experimental
  • 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.

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:

kotlincontext receivers — deprecated

// 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:

kotlincontext parameters — stable in 2.4.0

// Stable — no compiler flag needed from 2.4.0
interface Logger {
fun log(message: String)
}

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:

Deep Dive
Kotlin Variables

Why Most Kotlin Developers Misuse Variables — And Pay for It at Runtime Standard Kotlin tutorials teach you val x = 5 and move on. What they skip is everything that actually matters: how Kotlin...

kotlinunderscore — type-based resolution

// 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:

kotlinmultiple contexts

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:

kotlincontext parameter on a property

context(repo: UserRepository)
val Order.ownerName: String
get() = repo.findById(this.userId)?.name ?: "Unknown"

Why this matters for clean architecture

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:

kotlinlogger propagation through a call chain

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)
}

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.

Technical Reference
Kotlin Data Mapping

Advanced Kotlin Data Mapping: Patterns, Performance, and Best Practices That Actually Hold Up in Production Every Kotlin service has a boundary — the line between the raw, unpredictable data coming from the outside world and...

kotlintransaction context

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:

kotlinauth context in use-case layer

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)
}
}

Don’t overdo it

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.

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:

kotlinbefore / after

// BEFORE — context receiver (errors in Kotlin 2.3+)
context(Logger)
fun old() = log("hello") // implicit this.log()

// AFTER — context parameter (stable in 2.4.0)
context(logger: Logger)
fun new() = logger.log(“hello”)

Worth Reading
Rewired Kotlin 2.4.0

The isSorted Functions That Rewired Kotlin 2.4.0 stdlib Logic Before Kotlin 2.4.0, verifying sort order meant either writing a manual loop, abusing zipWithNext(), or mapping to a boolean list — none of which the compiler...

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:

kotlincallable reference workaround

// 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:

kotlinexplicit context argument — @OptIn required

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.


Written by:

Source Category: Kotlin: Hidden Pitfalls