✅ 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 ctx come 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 @Transactional should behave normally. Anything that reads method arguments is different. Expect Spring’s default @Cacheable key to include the context value unless you set an explicit key. 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


Related Posts