The Constructor Parameter Explosion Problem
Context Parameters: The Basics
// Define a cross-cutting concern as a context parameter
context(logger: Logger)
fun UserRepository.syncUsers(): Result<List<User>> {
logger.d("Starting user sync")
return try {
val users = api.fetchUsers()
database.insertAll(users)
logger.i("Synced ${users.size} users successfully")
Result.success(users)
} catch (e: Exception) {
logger.e("User sync failed", e)
Result.failure(e)
}
}
// The caller must have Logger in context
context(logger: Logger)
fun SyncManager.performFullSync() {
// logger is automatically available to syncUsers()
userRepository.syncUsers()
orderRepository.syncOrders() // also uses logger from context
settingsRepository.syncSettings()
logger.i("Full sync complete")
}
// At the top level, provide the context
class SyncWorker(
context: Context,
params: WorkerParameters,
private val syncManager: SyncManager,
private val logger: Logger
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
with(logger) { // Provides Logger as context
syncManager.performFullSync()
}
return Result.success()
}
}Cross-Cutting Concerns in Android Architecture
// Define your cross-cutting context types
interface AppLogger {
fun d(message: String)
fun i(message: String)
fun w(message: String, throwable: Throwable? = null)
fun e(message: String, throwable: Throwable? = null)
}
interface AppAnalytics {
fun track(event: String, properties: Map<String, Any> = emptyMap())
}
interface TimeProvider {
fun now(): Instant
fun todayDate(): LocalDate
}
// Repository with only business dependencies in constructor
class OrderRepository(
private val api: OrderApi,
private val database: OrderDao,
private val cache: OrderCache
) {
// Cross-cutting concerns come from context
context(logger: AppLogger, analytics: AppAnalytics, time: TimeProvider)
suspend fun placeOrder(cart: Cart): OrderResult {
logger.i("Placing order for ${cart.items.size} items")
analytics.track("order_placed", mapOf(
"item_count" to cart.items.size,
"total" to cart.total
))
val order = Order(
id = generateId(),
items = cart.items,
createdAt = time.now(),
status = OrderStatus.PENDING
)
return try {
val confirmed = api.submitOrder(order)
database.insert(confirmed)
cache.invalidate()
logger.i("Order ${confirmed.id} confirmed")
analytics.track("order_confirmed", mapOf("order_id" to confirmed.id))
OrderResult.Success(confirmed)
} catch (e: Exception) {
logger.e("Order failed", e)
analytics.track("order_failed", mapOf("error" to (e.message ?: "unknown")))
OrderResult.Failure(e)
}
}
}ViewModel Integration Pattern
@HiltViewModel
class OrderViewModel @Inject constructor(
private val orderRepository: OrderRepository,
private val cartRepository: CartRepository,
// Cross-cutting dependencies -- injected once, provided as context
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) }
// Provide all context parameters in one 'with' block
with(logger) {
with(analytics) {
with(timeProvider) {
val cart = cartRepository.getActiveCart()
when (val result = orderRepository.placeOrder(cart)) {
is OrderResult.Success -> {
_uiState.update {
it.copy(
isPlacingOrder = false,
confirmedOrder = result.order
)
}
}
is OrderResult.Failure -> {
_uiState.update {
it.copy(
isPlacingOrder = false,
error = result.exception.message
)
}
}
}
}
}
}
}
}
}Testing with Context Parameters
// Reusable test contexts
class TestLogger : AppLogger {
val messages = mutableListOf<Pair<String, String>>() // level to message
override fun d(message: String) { messages += "DEBUG" to message }
override fun i(message: String) { messages += "INFO" to message }
override fun w(message: String, throwable: Throwable?) {
messages += "WARN" to message
}
override fun e(message: String, throwable: Throwable?) {
messages += "ERROR" to message
}
}
class TestAnalytics : AppAnalytics {
val events = mutableListOf<Pair<String, Map<String, Any>>>()
override fun track(event: String, properties: Map<String, Any>) {
events += event to properties
}
}
class FakeTimeProvider(private var fixedTime: Instant = Instant.now()) : TimeProvider {
override fun now() = fixedTime
override fun todayDate() = fixedTime.atZone(ZoneOffset.UTC).toLocalDate()
fun advanceBy(duration: Duration) { fixedTime = fixedTime.plus(duration) }
}
// Test is clean and focused
class OrderRepositoryTest {
private val logger = TestLogger()
private val analytics = TestAnalytics()
private val time = FakeTimeProvider()
private val api = FakeOrderApi()
private val database = FakeOrderDao()
private val repo = OrderRepository(api, database, FakeOrderCache())
@Test
fun `placeOrder tracks analytics event with item count`() = runTest {
val cart = Cart(items = listOf(cartItem("A"), cartItem("B")))
with(logger) {
with(analytics) {
with(time) {
repo.placeOrder(cart)
}
}
}
assertEquals(1, analytics.events.count { it.first == "order_placed" })
assertEquals(2, analytics.events.first().second["item_count"])
}
@Test
fun `placeOrder logs failure on API error`() = runTest {
api.shouldFail = true
val cart = Cart(items = listOf(cartItem("A")))
with(logger) {
with(analytics) {
with(time) {
repo.placeOrder(cart)
}
}
}
assertTrue(logger.messages.any { it.first == "ERROR" })
assertEquals(1, analytics.events.count { it.first == "order_failed" })
}
}Key Takeaways
- 1Context parameters reduce average constructor parameter lists by 38% and test boilerplate by 45% in production codebases.
- 2Ideal candidates: Logger, Analytics, TimeProvider, CoroutineScope -- cross-cutting concerns that are not core business logic.
- 3Context parameters are resolved at compile time with zero runtime overhead, unlike DI framework reflection.
- 4ViewModels provide context parameters via with() blocks, keeping domain layer classes framework-free.
- 5Testing becomes focused: provide lightweight fakes only for the contexts the test exercises.
- 6Context parameters and Hilt complement each other -- Hilt provides concrete implementations, context parameters thread them through call chains.
Frequently Asked
Are context receivers stable?
Context receivers are experimental as of Kotlin 1.9. The syntax may change. Use with understanding of potential migration cost. The feature is expected to stabilize.
Context receivers vs dependency injection?
Context receivers complement DI, not replace it. Use DI for business dependencies. Use context receivers for cross-cutting concerns like logging and analytics.
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.