SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:31
UP00:01
MOD · ARTICLE · KOTLINS/N · AX-KOTLINPUBLISHED
All Articles
Kotlin · Updated 11 MIN

Kotlin Context Parameters: Eliminating Boilerplate in Android Architecture Layers

Context parameters replaced context receivers and became stable in Kotlin 2.4. How to use them for Logger, Analytics and Clock without bloating every signature.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Context receivers are gone. What replaced them?

The address of this article still says context receivers. The feature it names no longer exists in that form, so start there: "Context parameters replace an older experimental feature called context receivers" (Kotlin). If you have code written against the old prototype, it needs migrating, and JetBrains documents IDE-assisted migration in its update on context parameters. The timeline, because an earlier version of this article got it wrong twice:
ReleaseStatus
Kotlin 2.2.0Context 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)
The body of this article used to say the feature was stabilized in Kotlin 2.2, and its FAQ said it was experimental as of 1.9. Neither was right. Two parts are still outside the stable set in 2.4.0: explicit context arguments, which need the -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

The documentation defines the feature in one sentence: "Context parameters allow functions and properties to declare dependencies that are implicitly available in the surrounding context." You declare them with "the 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.
kotlin
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

The candidates are dependencies that many layers touch and no layer owns: a logger, an analytics sink, a clock. That short list is our convention, not a rule from the documentation. The documentation does set the boundary, in its list of restrictions: - "Constructors can't declare context parameters." - "Properties with context parameters can't have backing fields or initializers." - "Properties with context parameters can't use delegation." The first one settles the question people ask most. Context parameters do not replace constructor injection. A class still receives its collaborators through its constructor, and Hilt still builds the graph. What changes is the function signature: a repository method can ask for the logger and the clock without the class holding them, and without every caller threading them through by hand. Keep the context list short. Each entry is a real parameter in the compiled signature, and "Context parameters are part of the signature" for overriding as well, so adding one to an interface method changes every implementation. The sample catches IOException, not Exception. The function is suspending, and a broad catch would swallow cancellation, which the coroutines guide covers.
kotlin
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

The ViewModel is where injected objects meet the call chain. Hilt hands it the concrete logger, analytics and clock through the constructor, and the ViewModel provides them as context for the calls below it. The earlier version of this article nested three 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.
kotlin
@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

A function that takes its logger and clock from context is tested by providing a fake logger and a fixed clock at the call. No dependency graph is involved, because resolution is by type at the call site and the test is the call site. Write the fakes once and reuse them. A recording logger and a recording analytics sink let a test assert on what happened, and a clock you control removes the last source of nondeterminism from anything that stamps a time.
kotlin
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)
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Context receivers were replaced by context parameters, previewed in Kotlin 2.2.0 and stable since Kotlin 2.4.0.
  2. 2A context parameter has a name and is used by name. It is not an implicit receiver inside the function body.
  3. 3Resolution is by type at the call site. Two matching values at the same scope level is a compile error.
  4. 4Constructors cannot declare context parameters, so constructor injection and Hilt stay exactly where they are.
  5. 5Provide values with one context(a, b, c) call. Nested with blocks work and read worse.
  6. 6On the JVM a context parameter compiles to an additional function parameter. There is no runtime lookup.
MOD · FAQ3 ENTRIESANSWERED

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().

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

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.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA