Context receivers are gone. What replaced them?
| Release | Status |
|---|---|
| Kotlin 2.2.0 | Context parameters arrive as a preview, marked Experimental (What's new in 2.2.0) |
| Kotlin 2.4.0, released July 14, 2026 | "Kotlin 2.4.0 promotes context parameters, explicit backing fields, and annotation use-site targets features to Stable" (What's new in 2.4.0) |
-Xexplicit-context-arguments compiler option, and callable references.
The change that matters when you read old samples is this one: "The main difference is that context parameters are not introduced as receivers in the body of a function." A context receiver made its members available unqualified. A context parameter has a name, and you use the name.
This article also used to cite a study of eight codebases with two precise reduction figures. There was no source for it, and it has been removed.Context Parameters: The Basics
context keyword followed by a list of parameters, with each parameter declared as name: Type".
Three rules decide how they behave:
- Resolution is by type, at the call site. "Kotlin resolves context parameters at the call site by searching for matching context values in the current scope. Kotlin matches them by their type." Two values of the same type at the same scope level is a compile error, not a silent choice.
- A caller's context flows down. A function that declares context(logger: AppLogger) can call another that needs an AppLogger without passing anything.
- An implicit receiver counts. The design document says the value is "resolved from two sources: in-scope context parameters, and implicit receivers" (KEEP). That is why with(logger) { ... } still works as a provider. The context(logger) { ... } function does the same job and says what it means.
There is no runtime machinery behind this. Per the same document, "In the JVM a function with context parameters is represented as a function with additional parameters."
If you do not need the name, use _. An anonymous context parameter still satisfies the functions you call, and contextOf<T>() retrieves the value when you need it.interface AppLogger {
fun i(message: String)
fun e(message: String, throwable: Throwable? = null)
}
// Declares a dependency on whatever AppLogger is in context
context(logger: AppLogger)
fun syncUsers(repository: UserRepository) {
logger.i("Starting user sync")
repository.sync()
}
// The caller declares the same context, so nothing is passed by hand
context(logger: AppLogger)
fun performFullSync(users: UserRepository, orders: OrderRepository) {
syncUsers(users)
syncOrders(orders)
logger.i("Full sync complete")
}
// Anonymous: satisfies callees, not accessible by name
context(_: AppLogger)
fun logWelcome() {
contextOf<AppLogger>().i("Welcome")
}
// At the edge, provide the value
fun runSync(logger: AppLogger, users: UserRepository, orders: OrderRepository) {
context(logger) {
performFullSync(users, orders)
}
}Where they fit in an Android codebase
IOException, not Exception. The function is suspending, and a broad catch would swallow cancellation, which the coroutines guide covers.interface AppAnalytics {
fun track(event: String, properties: Map<String, Any> = emptyMap())
}
interface TimeProvider {
fun now(): Instant
}
// The constructor holds what the class owns
class OrderRepository(
private val api: OrderApi,
private val database: OrderDao
) {
// The cross-cutting values arrive with the call
context(logger: AppLogger, analytics: AppAnalytics, time: TimeProvider)
suspend fun placeOrder(cart: Cart): OrderResult {
logger.i("Placing order")
val order = Order(items = cart.items, createdAt = time.now())
return try {
val confirmed = api.submitOrder(order)
database.insert(confirmed)
analytics.track("order_confirmed", mapOf("order_id" to confirmed.id))
OrderResult.Success(confirmed)
} catch (e: IOException) {
logger.e("Order failed", e)
analytics.track("order_failed")
OrderResult.Failure(e)
}
}
}ViewModel Integration Pattern
with blocks to do that. It compiles, for the reason given above, and it reads badly. The documentation's own examples pass several values to one context(...) call, and that is the form to use.
The domain layer stays free of framework annotations either way. A repository declares which contexts a function needs and has no opinion on where they come from. Which state the ViewModel then exposes is a separate decision, covered in MVVM vs MVI.@HiltViewModel
class OrderViewModel @Inject constructor(
private val orderRepository: OrderRepository,
private val cartRepository: CartRepository,
// Injected once, provided as context below
private val logger: AppLogger,
private val analytics: AppAnalytics,
private val timeProvider: TimeProvider
) : ViewModel() {
private val _uiState = MutableStateFlow(OrderUiState())
val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()
fun placeOrder() {
viewModelScope.launch {
_uiState.update { it.copy(isPlacingOrder = true) }
val cart = cartRepository.getActiveCart()
// One call provides all three context values
val result = context(logger, analytics, timeProvider) {
orderRepository.placeOrder(cart)
}
_uiState.update {
when (result) {
is OrderResult.Success ->
it.copy(isPlacingOrder = false, confirmedOrder = result.order)
is OrderResult.Failure ->
it.copy(isPlacingOrder = false, error = result.exception.message)
}
}
}
}
}Testing with Context Parameters
class RecordingLogger : AppLogger {
val errors = mutableListOf<String>()
override fun i(message: String) = Unit
override fun e(message: String, throwable: Throwable?) {
errors += message
}
}
class RecordingAnalytics : AppAnalytics {
val events = mutableListOf<String>()
override fun track(event: String, properties: Map<String, Any>) {
events += event
}
}
class FixedTime(private val instant: Instant) : TimeProvider {
override fun now() = instant
}
class OrderRepositoryTest {
private val logger = RecordingLogger()
private val analytics = RecordingAnalytics()
private val time = FixedTime(Instant.parse("2026-10-11T00:00:00Z"))
private val api = FakeOrderApi()
private val repo = OrderRepository(api, FakeOrderDao())
@Test
fun `placeOrder tracks the confirmation`() = runTest {
context(logger, analytics, time) {
repo.placeOrder(cartOf("A", "B"))
}
assertEquals(listOf("order_confirmed"), analytics.events)
}
@Test
fun `placeOrder logs and tracks a network failure`() = runTest {
api.failWith(IOException("offline"))
context(logger, analytics, time) {
repo.placeOrder(cartOf("A"))
}
assertEquals(listOf("Order failed"), logger.errors)
assertEquals(listOf("order_failed"), analytics.events)
}
}Key Takeaways
- 1Context receivers were replaced by context parameters, previewed in Kotlin 2.2.0 and stable since Kotlin 2.4.0.
- 2A context parameter has a name and is used by name. It is not an implicit receiver inside the function body.
- 3Resolution is by type at the call site. Two matching values at the same scope level is a compile error.
- 4Constructors cannot declare context parameters, so constructor injection and Hilt stay exactly where they are.
- 5Provide values with one context(a, b, c) call. Nested with blocks work and read worse.
- 6On the JVM a context parameter compiles to an additional function parameter. There is no runtime lookup.
Frequently Asked
Are context receivers stable?
No, and they never will be. Context receivers were an experimental prototype that context parameters replaced. Context parameters were previewed in Kotlin 2.2.0 and became stable in Kotlin 2.4.0, released on July 14, 2026. Explicit context arguments and callable references are the two parts still outside the stable set in that release.
Do context parameters replace dependency injection?
No. The Kotlin documentation states that constructors cannot declare context parameters, so a class still receives its collaborators through its constructor and Hilt still builds the graph. Context parameters work one level down: they let a function receive a logger or a clock from its caller without every signature in between naming it.
What is the difference between a context parameter and a context receiver?
A context receiver made its members available unqualified inside the function, as an implicit receiver. A context parameter is declared as name: Type and you access it through that name. The Kotlin release notes call this the main difference. Old samples that call log() directly must be rewritten as logger.log().
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.