Skip to Content
Course content

200: Context Receivers (Experimental Feature)

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been spending the morning refactoring a piece of a project where I'm handling complex data transformations. I keep running into the same annoying problem: my functions need a "dependency" (like a logger or a configuration object), but they also need to operate on a specific object. In Kotlin, we usually solve this with extension functions, but extension functions have a strict limit—they only give you one receiver.

The Extension Function Wall

Let's look at what I'm trying to do. I have a User profile, and I want to validate it. To do that, I need a ValidationRules object to tell me what's legal, and a Logger to record any failures. My first instinct was to make it an extension function on User:

class User(val name: String, val age: Int)
class ValidationRules { fun isValidName(name: String) = name.isNotEmpty() }
class Logger { fun log(msg: String) = println("LOG: $msg") }

fun User.validate(rules: ValidationRules, logger: Logger) {
    if (!rules.isValidName(this.name)) {
        logger.log("Invalid name for user $name")
    }
}

This works, sure. But as my project grows, I find myself passing rules and logger into every single function in this layer. It's "parameter drilling." My function signatures are becoming 70% plumbing and 30% actual logic. I wanted to make it an extension of ValidationRules instead, but then I can't easily make it an extension of User. I can't do fun ValidationRules.User.validate(). Kotlin just doesn't allow multiple extension receivers.

Fighting the Boilerplate

I tried to wrap things in a class to hold the state, but that just adds another layer of nesting and makes the functions harder to move around. I started wondering: why can't I just tell the compiler, "This function requires these three things to be present in the environment to run," without forcing them to be explicit arguments every single time?

That's where I stumbled into Context Receivers. Now, keep in mind, this is an experimental feature, so you'll need to enable it in your Gradle settings (-Xcontext-receivers), but the power it gives you is exactly what I'm looking for.

Breaking the One-Receiver Limit

Instead of passing the dependencies as arguments, I can define them as contexts. Watch what happens when I rewrite that validate function:

context(ValidationRules, Logger)
fun User.validate() {
    // I have access to 'this' (the User) 
    // AND I have access to ValidationRules and Logger as if they were 'this'
    if (!isValidName(this.name)) { 
        log("Invalid name for user $name")
    }
}

This is wild. Inside the function, I can call isValidName() (from ValidationRules) and log() (from Logger) directly. I don't need to reference a variable name because the compiler knows that for this function to even be callable, those two types must be available in the surrounding scope.

But here is the catch: if you try to call user.validate() in a standard main function now, the code won't compile. The compiler will complain that ValidationRules and Logger are missing. It's essentially enforcing a requirement at the type level.

Actually Making It Run

To make this work, I have to provide the context. The most common way to do this is using with() blocks, nesting them to provide the required environments. I tried it like this:

fun main() {
    val user = User("Alice", 30)
    val rules = ValidationRules()
    val logger = Logger()

    with(rules) {
        with(logger) {
            user.validate() // Now it compiles!
        }
    }
}

I'll be honest: nesting with blocks feels a bit clunky. But imagine this in a real-world architecture. You could have a base service class that provides these contexts, or a higher-level coordinator that wraps a whole block of business logic in the necessary contexts. Suddenly, your internal domain functions are clean, focused on the logic, and free of the "plumbing" arguments that usually clutter up the code.

It turns the dependency from something you pass into something you require. It's a subtle shift, but it makes the intent of the function much clearer: "I am a User validation logic, and I can only exist in a world where rules and logging are available."




📋 Practical Task

Implementing a Transaction-Aware Order Service

You are building a checkout system. You have a Database class and a Transaction class. The Order class should have a saveToDb() method, but this method must only be callable when both a Database connection and an active Transaction are present in the context.

Requirements:

  • Create a Database class with a method connect().
  • Create a Transaction class with a method commit().
  • Create an Order class.
  • Write an extension function for Order called saveToDb() that uses context receivers to require both Database and Transaction.
  • Inside saveToDb(), call connect() and commit() to simulate the saving process.
  • In your main function, instantiate the objects and use with() blocks to provide the necessary context so that saveToDb() can be executed.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.