Context Dependent Declarations
Written by Ivan Dugalic - April 2022
Context receivers enable context-dependent declarations in Kotlin.
It enables composing contexts without using inheritance, which can simplify the overall design and make your API contextual.
Consider an interfaces IDecider
and EventRepository
that represents some context(s) and another interface C / Command
that we want to define action on, so that this action is available only in a context that provides an instance of an IDecider
and an instance of an EventRepository
, without having to explicitly pass them around.
context (IDecider<C, S, E>, EventRepository<C, E>)
fun <C, S, E> C.handle(): Flow<E> = fetchEvents().computeNewEvents(this).save()
context (IDecider<C, S, E>)
fun <C, S, E> Flow<E>.computeNewEvents(command: C): Flow<E> = flow {
val currentState = fold(initialState) { s, e -> evolve(s, e) }
val newEvents = decide(command, currentState)
emitAll(newEvents)
}
The logic inside the context (the implementation of the
handle
method) is completely separated from the context implementation (IDecider
,EventRepository
) and could be written in different parts of the program or even in a different module.
IDecider
represents the pure computation by handling Commands/C and making further decisions (publishing new events/state). EventRepository
defines fetching and storing operations / side effects. Refer to the previous blog post for more.
One example of usage would be to handle the dispatched command in the context of the event-sourced1 information system:
test("Event-sourced - add number command") {
with(decider) {
with(numberEventRepository) {
AddNumberCommand(Description("desc"), NumberValue(6)).handle().toList() shouldContainExactly listOf(
NumberAddedEvent(Description("desc"), NumberValue(6))
)
}
}
Notice how we have specified the contexts by using Kotlin’s scope function with
.
Second example of usage would be to handle the dispatched command in the context of the state-stored2 information system:
context (IDecider<C, S, E>, StateRepository<C, S>)
suspend fun <C, S, E> C.handle(): S = fetchState().computeNewState(this).save()
context (IDecider<C, S, E>)
suspend fun <C, S, E> S?.computeNewState(command: C): S {
val currentState = this ?: initialState
val newEvents = decide(command, currentState)
val newState = newEvents.fold(currentState) { s, e -> evolve(s, e) }
return newState
}
This second
handle
function is available only in theIDecider
andStateRepository
context, and this is a different method than the one we previously defined for the event-sourced context.
test("State-stored - add number command") {
with(decider) {
with(numberStateRepository) {
AddEvenNumberCommand(
Description("desc"),
NumberValue(4)
).handle() shouldBe NumberState(Description("desc"), NumberValue(4))
}
}
}
We are now able to handle any command/C in state-stored or event-sourced context, per need. It enables the composability of abstractions in a nice way:
- IDecider + EventRepository = event-sourced context, or
- IDecider + StateRepository = state-stored context
The Flow
Additionally, handle
functions could extend the flow of commands to support streaming and enable message-driven system patterns.
context (IDecider<C, S, E>, EventRepository<C, E>)
//using `it.handle()` function previously defined
fun <C, S, E> Flow<C>.handle(): Flow<E> = flatMapConcat { it.handle() }
context (IDecider<C, S, E>, StateRepository<C, S>)
//using `it.handle()` function previously defined
fun <C, S, E> Flow<C>.handle(): Flow<S> = map { it.handle() }
The handle
method is now acting as an operator on the Flow, and it can handle the upstream flow of commands dispatched via a message broker producing a new flow of successfully stored events
or the successfully stored states
as a result, depending on the context.
- Flows are cold streams - It does not return until collected
- Flows are sequential - each individual collection of a flow is performed sequentially unless special operators that operate on multiple flows are used
In this example we are adding numbers in a state-stored context:
test("State-stored - add numbers") {
with(decider) {
with(numberStateRepository) {
flowOf(
AddNumberCommand(Description("desc"), NumberValue(6)),
AddNumberCommand(Description("desc"), NumberValue(4))
).handle().toList() shouldContainExactly listOf(
NumberState(Description("desc"), NumberValue(6)),
NumberState(Description("desc"), NumberValue(10))
)
}
}
}
In this example we are adding numbers in an event-sourced context:
test("Event-sourced - add number") {
with(decider) {
with(numberEventRepository) {
flowOf(
AddNumberCommand(Description("desc"), NumberValue(6)),
AddNumberCommand(Description("desc"), NumberValue(4))
).handle().toList() shouldContainExactly listOf(
NumberAddedEvent(Description("desc"), NumberValue(6)),
NumberAddedEvent(Description("desc"), NumberValue(4))
)
}
}
}
Going Forward
FModel library will use this Kotlin language feature to simplify the design. You can track the progress over the pull request on github.
We are using contexts receivers
to simplify the composing and injecting of behaviors across the architecture layers. It is focusing on the information flow and how to handle that flow of messages depending on the context (for example, event-sourced or state-stored)
The usage might be much broader in the near future. Stay tuned, and happy codding!
-
event-sourced information system: fetch events via
EventRepository
, execute domain components/compute viaIDecider
, and store new events by appending to the previous events in the storage viaEventRepository
↩ -
state-stored information system: fetch state via
StateRepository
, execute domain components/compute viaIDecider
, and store new state viaStateRepository
, by overwriting the previous state in the storage ↩