Prompt and Context
Several functions in a Kotlin service need the same UserService, logger, or transaction context. The team wants less repeated parameter plumbing without hiding dependencies in globals, and it must migrate gradually from context receivers to Kotlin 2.2 context parameters. Explain the semantics, call-site behavior, ambiguity handling, and boundaries. The target is a Kotlin/JVM engineer; no DI framework is assumed.
What the Interviewer Evaluates
A strong answer says that a context parameter is an implicitly available, compile-time dependency—not a runtime container. Resolution happens at the call site by finding a matching type in the current scope; multiple compatible values at the same level are ambiguous. The candidate distinguishes context receivers from context parameters, acknowledges the migration and feature limits, and explains why ordinary parameters are often clearer at public boundaries.
Clarifications to Ask First
- Does the dependency cross a public module or library API? If yes, explicit parameters are easier to discover, test, and call from another language.
- Is the dependency stable inside a local DSL or transaction operation? If yes, a context parameter can remove repetitive plumbing.
- Can two implementations of the same type be in scope? If yes, design named context arguments or semantic wrapper types.
- Does the project already use context receivers? If yes, confirm compiler flags and migration scope; the two modes cannot be mixed in one module.
30-Second Answer Framework
“A context parameter declares a dependency on the function signature while the call-site scope supplies it. The compiler resolves it by type; two matches at the same level are an error. It fits a local DSL, transaction, or logging context and avoids repeated plumbing. For public APIs, cross-language calls, or frequently replaced dependencies, I keep explicit parameters. During migration I enable the matching compiler option and inspect receiver calls and class-level cases because the two features are not interchangeable.”
Step-by-Step Deep Dive
Declare the dependency, then provide it in a scope:
interface UserService {
fun findUser(id: Int): String
}
context(users: UserService)
fun greeting(id: Int): String = "Hello ${users.findUser(id)}"
fun main() {
val service = object : UserService {
override fun findUser(id: Int) = "user-$id"
}
context(service) {
println(greeting(7))
}
}The signature still declares the dependency, so the caller can see it; context(service) supplies the value at the call site. Resolution matches types in the current scope, not variable names. If serviceA and serviceB of the same compatible type are present at the same level, compilation fails instead of silently choosing one. Use an explicit context argument or semantic wrapper types when both meanings are valid.
The boundary with ordinary parameters is about information density. If a local DSL has many functions sharing one immutable context, a context parameter removes repeated plumbing. If one function uses the dependency once, an ordinary parameter is clearer. Public library APIs must also consider Java calls, IDE discoverability, and generated documentation; the farther an implicit source travels, the higher the maintenance cost.
Migration from context receivers is more than a keyword replacement. JetBrains notes that context parameters require names and that class-level context receivers have no direct counterpart. A module cannot enable both modes. Switch compiler options per module, then inspect call sites, callable references, and class-level receivers. The official documentation also lists restrictions: constructors cannot declare context parameters, and context properties cannot have backing fields or initializers.
An anonymous parameter avoids an unused local name but still participates in resolution:
context(_: UserService)
fun logGreeting(id: Int) {
println(greeting(id))
}The decision rule is: “Use context for local, stable shared state; use ordinary parameters or explicit DI for cross-boundary dependencies, explicit replacement, or multiple implementations.” Do not turn context parameters into a service locator. Tests still provide dependencies through a call-site scope, and an outer object still owns their lifetime.
High-Quality Sample Answer
A context parameter is a compile-time declaration of an implicit dependency. The function includes UserService in its signature, while the caller supplies an instance inside context(service); the compiler searches the current scope by type and reports ambiguity when two values match. I would use it for a local DSL, transaction, or logging chain that shares stable context. For public APIs, Java interop, or dependencies that are replaced often, I would keep explicit parameters or DI. During a context-receiver migration, I would switch compiler options per module, inspect names, callable references, and class-level receivers, and account for restrictions such as constructors and backing-field properties.
Common Mistakes
- Mistake → calling context parameters a runtime DI container → Why it fails: the compiler resolves them at the call site → Fix: explain scope, type matching, and compile-time ambiguity.
- Mistake → assuming same-type values are selected by variable name → Why it fails: same-level matches are ambiguous → Fix: use an explicit context argument or a semantic wrapper type.
- Mistake → replacing
context receiverwithcontext parametermechanically → Why it fails: naming, class-level cases, and callable references differ → Fix: migrate per module and inspect each call site. - Mistake → making every dependency implicit → Why it fails: public-boundary discoverability drops → Fix: keep context local and stable, and use ordinary parameters at boundaries.
Follow-up Questions and Responses
Follow-up 1: How do you replace a context dependency in a test?
Create a fake or stub implementing the same interface, then run the subject inside context(fake) { ... }. If a test switches implementations frequently, explicit parameters are usually clearer and make the dependency visible in the test report.
Follow-up 2: What if two same-type services must coexist?
Introduce semantic wrapper types such as PrimaryUserService and AuditUserService, or pass an explicit context argument at the call site. Do not rely on declaration order because resolution does not use order as priority.
Follow-up 3: Why can’t a constructor declare a context parameter?
The current Kotlin documentation explicitly restricts constructors from declaring context parameters. For object-level dependencies, use an ordinary constructor parameter or explicit factory; forcing a global context would hide lifetime and thread boundaries.