✅ Context Parameters in a Spring Backend Link to heading
Context parameters became stable in Kotlin 2.4.0. You don’t need a compiler flag to use them anymore, which means it’s time to decide where they actually belong in a normal Spring codebase.
My short answer: use them for per-request values, not for beans.
🧭 The Problem Link to heading
Most backend requests carry a few values that almost every layer needs — the account ID, the subscription level, a request ID for logs.
You can thread them through every method signature. That works, but it clutters signatures, and every new value means a refactor across the call chain.
In Memoization with Lombok Lazy Getters in Spring I solved part of this with request-scoped suppliers. The point there was consistency: resolve the subscription once, so the permission check and the audit log see the same value within one request.
Context parameters give you another option — build that snapshot once at the edge, then let it flow implicitly through the functions that need it.
🧩 What Context Parameters Are Link to heading
A context parameter is a function parameter the caller doesn’t pass by hand. You declare it with context(name: Type) before the function. At the call site, Kotlin finds a value of that type in the surrounding context and passes it for you. Matching is by type.
You bring a value into context with the standard library’s context(value) { ... } function. Inside the block, any call that needs that type gets it automatically. Unlike with, it doesn’t make the value an implicit receiver — it only makes it available for context parameters. And on the JVM, a context parameter is just an extra leading parameter, so there’s no runtime magic involved.
🧱 1) Define One Snapshot Type Link to heading
Keep it small and immutable:
enum class SubscriptionLevel { FREE, TRIAL, PAID }
data class AccountContext(
val accountId: Long,
val subscription: SubscriptionLevel,
val requestId: String,
)
I avoid calling it RequestContext — Spring MVC already has a class with that name, and that’s one import mistake you don’t need.
The important part is that this is a snapshot: it’s resolved once and never changes during the request.
🧱 2) Bring It Into Context at the Edge Link to heading
Resolve it once in the controller, then open a context block:
@RestController
class ExportController(
private val contexts: AccountContextResolver,
private val exports: ExportService,
) {
@PostMapping("/api/exports")
fun create(@RequestBody body: ExportRequest, request: HttpServletRequest): ExportResponse {
val ctx = contexts.resolve(request)
return context(ctx) {
exports.create(body)
}
}
}
AccountContextResolver is an ordinary @Component that reads the account ID from a header and looks up the subscription level. If you already have the request-scoped suppliers from the memoization post, build the snapshot from them.
Notice what didn’t change: the controller still gets ExportService through its constructor.
🧱 3) Declare It Where It’s Used Link to heading
Services declare the context they need:
@Service
class ExportService(
private val permissions: PermissionService,
private val repository: ExportRepository,
private val audit: AuditLog,
) {
context(ctx: AccountContext)
fun create(body: ExportRequest): ExportResponse {
permissions.requirePaidOrTrial("export")
val exportId = repository.save(ctx.accountId, body.format)
audit.record("export.created", "export:$exportId")
return ExportResponse(exportId)
}
}
@Service
class PermissionService {
context(ctx: AccountContext)
fun requirePaidOrTrial(feature: String) {
if (ctx.subscription == SubscriptionLevel.FREE) {
throw UpgradeRequiredException("$feature requires a paid plan")
}
}
}
permissions.requirePaidOrTrial() and audit.record() don’t take the context as an argument. They pick up the same AccountContext that create received — the consistency guarantee from the memoization post, now expressed in the type system.
If a function only passes the context along, use _ as the name:
@Service
class ScheduledExports(private val exports: ExportService) {
context(_: AccountContext)
fun monthlyCsv(): ExportResponse = exports.create(ExportRequest("csv"))
}
The anonymous parameter still satisfies create. If you need the value after all, contextOf<AccountContext>() retrieves it.
🧱 4) A Logging and Audit Helper Link to heading
This is where context parameters feel most natural. Every audit line should carry the same identifiers:
@Component
class AuditLog {
private val log = LoggerFactory.getLogger(AuditLog::class.java)
context(ctx: AccountContext)
fun record(action: String, target: String) {
log.info(
"audit action={} target={} account={} subscription={} request={}",
action, target, ctx.accountId, ctx.subscription, ctx.requestId,
)
}
}
You can also combine a context parameter with an extension function for plain logging:
context(ctx: AccountContext)
fun Logger.infoWithAccount(message: String) {
info("{} account={} request={}", message, ctx.accountId, ctx.requestId)
}
No MDC setup, no thread-local cleanup — and the compiler tells you when a caller has no context to give.
🧱 5) A Transaction-Style Scope Link to heading
Context parameters can also act as a capability: “you may only call this inside a transaction.”
class TxScope internal constructor()
@Component
class Transactions(private val template: TransactionTemplate) {
fun inTransaction(block: context(TxScope) () -> Unit) {
template.executeWithoutResult {
context(TxScope()) { block() }
}
}
}
interface LedgerRepository {
context(_: TxScope)
fun append(accountId: Long, amountCents: Long)
}
Now ledger.append(...) only compiles inside tx.inTransaction { ... }. It’s a guardrail, not a security boundary, and @Transactional is still fine for most service methods. I’d save this for the places where calling something outside a transaction is a real bug — ledger writes, for example.
🔀 Where Spring DI Is Still the Right Tool Link to heading
Don’t replace constructor injection with context parameters.
Beans are long-lived, singleton dependencies. Constructor injection makes them explicit, Spring manages their lifecycle, and tests just pass mocks to the constructor. Moving a repository or a client into a context parameter gains nothing and hides the dependency graph.
Here’s how I’d split it:
- Constructor injection for services, repositories, clients, and config.
- Context parameters for small immutable values that belong to one request or one unit of work.
- Regular parameters for the actual input of the function.
If you find yourself writing context(repo: UserRepository), stop.
⚠️ Warnings and Tradeoffs Link to heading
- Overloads that differ only by context are a trap. Since 2.3.20, they can become ambiguous. 2.4.0 adds explicit context arguments to resolve that at the call site, but that feature is still experimental. I covered both in the 2.3.20 and 2.4.0 notes. My advice: rename instead.
- Readability for teammates. “Where did
ctxcome from?” is a fair question in review. Keep it to one or two context types in the codebase, with obvious names. - Java callers see a normal parameter. On the JVM, context parameters come first, then any extension receiver, then regular parameters. So Java calls
exportService.create(ctx, body). That’s fine — it just means context parameters don’t hide anything from Java. - Keep them off controller handler methods. Spring’s Kotlin parameter-name discovery doesn’t support context parameters yet (spring-framework#35134, still open as I write this). Open the context inside the handler, as in step 2.
- Test proxied methods. A proxy sees a method with one more argument, so
@Transactionalshould behave normally. Anything that reads method arguments is different. Expect Spring’s default@Cacheablekey to include the context value unless you set an explicitkey. Write an integration test for each annotation you rely on before you trust it. - Callable references are still experimental. Avoid
::create-style references to these functions for now.
🏁 Wrap-Up Link to heading
Context parameters are a good fit for one thing in a Spring backend: carrying a small, immutable per-request snapshot through the call chain without threading it through every signature. Resolve it once at the edge, open a context(ctx) { } block, and let services declare what they need.
Everything else stays where it was: beans still belong in constructors, overloads should stay simple enough that no one has to guess which one resolves, and anything Spring proxies deserves a test before you lean on it.
It’s a small feature, but one I’m glad to see land — used with a little restraint, it makes request context feel like part of the language instead of something you smuggle around in a thread local.
Sources
- Context parameters (Kotlin docs)
contextfunction (Kotlin stdlib API)- What’s new in Kotlin 2.4.0
- Context parameters KEEP: JVM ABI
- spring-framework#35134: KotlinReflectionParameterNameDiscoverer doesn’t support Kotlin Context Parameters
- Spring Framework:
TransactionOperations.executeWithoutResult(Javadoc) - Spring Framework: Cache abstraction, default key generation
Related Posts