I’ve been thinking about Kotlin’s context parameters since they were introduced, wondering how they could improve domain modeling. This post explores how context parameters can solve a fundamental DDD challenge: enforcing domain boundaries while keeping business logic where it belongs.
Note: Context parameters are experimental but stable enough for production. They were renamed from context receivers in Kotlin 2.2.0. Check the Kotlin docs for details.
Context Parameters: The Key Feature Link to heading
One of the main perks that Context parameters give you is that it lets you restrict where the functions can be called, creating compile-time boundaries. Here’s an example from the Kotlin documentation:
context(users: UserService)
fun outputMessage(message: String) {
users.log("Log: $message") // Can only be called when UserService is in context
}
The magic we will focus on is when you use them to limit scope; ensuring functions can only be used in specific contexts. This prevents accidental misuse and enforces architectural boundaries at compile time.
The Problem Link to heading
DDD promises rich domain models, but we often end up with anemic entities or bloated application services. The moment you need business logic that goes deeper into entities you end-up leaking business logic into services or to aggregate root whereas it should be placed in the entity itself.
Take a simple example; approving a product that can only be approved when in Draft
status:
// Anemic approach, logic in service
class ProductCommandHandler {
suspend fun handle(command: ApproveProduct) {
val product = repository.findById(command.productId) ?: throw ProductNotFoundException()
if (product.status != Draft) {
throw ProductApprovalException("Product is not in draft status")
}
product.status = Approved // Direct field manipulation
repository.save(product)
}
}
Better approach, move logic to domain:
class Product : AggregateRoot<String>(id) {
fun approve() {
if (status != Draft) throw ProductApprovalException("Product is not in draft status")
status = Approved
applyEvent(ProductApprovedEvent(id))
}
}
This looks better, but real complexity emerges when business rules go deeper into entities.
Say we want to change a ProductVariant
’s dimensions. The validation belongs to the entity, but the Product aggregate root needs to orchestrate.
The Main Problem: Entity Logic Leaking Outside Aggregate Root Link to heading
Even with proper encapsulation, developers can still access entities directly and bypass domain methods:
// BAD: Direct entity access bypasses aggregate root orchestration
class ProductCommandHandler {
suspend fun handle(command: ChangeProductVariantSizeAndDimensions) {
val product = repository.findById(command.productId) ?: throw ProductNotFoundException()
// Directly accessing the variant, bypasses domain logic!
val variant = product.variants().find { it.id == command.variantId }
variant.changeSizeAndDimensions(command.newSize, command.newDimensions)
// No event applied, aggregate root bypassed
repository.save(product)
}
}
This violates our carefully designed domain boundaries. We need compile-time enforcement to prevent this.
Context Parameters: The Solution Link to heading
Here’s where context parameters solve our problem. We can enforce that entity methods can only be called from within their aggregate root context.
We should also make all the properties of the aggregate root and the entity private. Only methods/use-cases can access them.
Context parameters create compile-time boundaries that prevent bypassing domain logic.
// Simplified aggregate root with context-restricted event application
abstract class AggregateRoot<TId>(val id: TId) {
// Recorder and router for the aggregate root
protected val router = EventRouter()
protected val recorder = EventRecorder()
// Only entities can apply events, this is the key constraint
context(_: Entity<*, *>)
val applyEvent get() = Applier { event -> applyEvent(event) }
protected suspend fun <TEvent : DomainEvent> applyEvent(event: TEvent) {
router.route(event)
recorder.record(event)
}
}
// Entity with context-restricted state changes
abstract class Entity<TId, TAggregate : AggregateRoot<*>>(val id: TId) {
// Router for the entity
protected val router = EventRouter()
context(ar: TAggregate)
protected suspend fun applyEvent(event: DomainEvent) {
router.route(event)
ar.applyEvent(event) // Only works within aggregate root context
}
}
Now our domain objects with context parameters:
class Product private constructor(/*...*/) : AggregateRoot<String>(id) {
init {
register<ProductVariantChangedEvent>(::handle) // setter for the aggregate root's state if necessary
}
suspend fun changeVariantSizeAndDimensions(variantId: String, newSize: Size, newDimensions: Dimensions) {
val variant = variants.find { it.id == variantId }
?: throw ProductVariantNotFoundException()
// This works because we're calling from within Product context
variant.changeSizeAndDimensions(newSize, newDimensions)
}
private fun handle(event: ProductVariantChangedEvent) {
// do nothing, as the entity handles the event
// but it is still routed here, too.
}
}
class ProductVariant private constructor(/*...*/) : Entity<String, Product>(id) {
init {
register<ProductVariantChangedEvent>(::handle) // setter for the entity's state
}
// This can ONLY be called from Product context!
context(ar: Product)
suspend fun changeSizeAndDimensions(newSize: Size, newDimensions: Dimensions) {
if (newSize < minSize || newSize > maxSize)
throw InvalidProductVariantSizeException()
if (newDimensions < minDimensions || newDimensions > maxDimensions)
throw InvalidProductVariantDimensionsException()
// applyEvent is also a setter for the entity's state
applyEvent(ProductVariantChangedEvent(ar.id, variantId, newSize, newDimensions))
}
private fun handle(event: ProductVariantChangedEvent) {
this.size = event.newSize
this.dimensions = event.newDimensions
}
}
Entity methods are context-restricted to their aggregate root. State changes must go through events.
Note: Event handling logic here is opinionated, you implement it differently depending on your use case.
The Result: Compile-Time Domain Boundaries Link to heading
Now developers cannot bypass domain logic:
// This will NOT compile, method is context-restricted!
val product = repository.findById(command.productId)
val variant = product.variants().find { it.id == command.variantId }
variant.changeSizeAndDimensions(command.newSize, command.newDimensions) // ❌ Compilation error

The method can only be called from within the Product
context, enforcing proper domain orchestration.
Conclusion Link to heading
Context parameters offer a good solution to long-standing challenges in DDD implementation. By enforcing domain boundaries at compile-time. While context parameters are still experimental, the pattern shows promise for teams working with complex domains where maintaining encapsulation is critical.
If you have any questions or want to discuss this approach further, feel free to reach out!
Take a look at the open source projects that I created/maintain: