In my previous question, one of the answers mentioned that I shouldn't be changing behavior based on the Event class
The whole point of classing is that you can get result without having to decide based on instance.
I don't disagree with this sentiment, and figured it was more in the acceptable realm due to Kotlin's documentation on the usage pattern with sealed classes.
What I'm really looking for is a way to coordinate an interaction between two different types of classes.
I haven't used the Visitor pattern much before, but from what I've read this may be an okay use-case.
A Brazier is an Event-Sourced DDD Aggregate. Its state is reconstituted from Events. Extinguished Braziers can be lit, Lit Braziers can be extinguished. The Brazier encapsulates a finite-state transition, between lit, and extinguished. No change happens if we try to light a Lit Brazier, just as no change happens if we try to extinguish an Extinguished Brazier.
When a command is executed, a List of Events is returned, rather than changing the State of the Brazier. The Events are then eventually persisted in an Event Store, and during reconstitution, the new State is initialized.
There are two commands a Brazier supports, light
and extinguish
. There are two Events that result from these commands, Lit
and Extinguished
.
There are two sections of code I'm going to present.
The first demonstrates the code using a type-check with when
to decide what the new State should be, based on the Event that is being processed.
The second demonstrates the code using a (possibly naive) Visitor pattern to decide what the new State should be, based on the Event that is being processed. Here, the Event is the Visitor, calling the correct method on the State, depending on the concrete type of the State.
The primary difference between the two is the interface of the Event hierarchy, and the afterApplying
method in the State classes.
This is a pretty small example of where and how I'd like to use this; in other places where I've ended up in this position, the Events are much more involved, and more State types may be used (6 Events and 5 States in one case).
I don't have any plans to allow the State or Event hierarchies to be extended outside of the core I write, so we can assume that there will be a fairly fixed number of Events and States (less than 10 undoubtedly).
BrazierWithWhen
// === Point of Interest ===
sealed class BrazierEvent
class LitEvent : BrazierEvent()
class ExtinguishedEvent : BrazierEvent()
sealed class BrazierState {
abstract fun light(): List<BrazierEvent>
abstract fun extinguish(): List<BrazierEvent>
abstract fun lit(event: LitEvent): BrazierState
abstract fun extinguished(event: ExtinguishedEvent): BrazierState
abstract fun afterApplying(event: BrazierEvent): BrazierState
}
class LitState : BrazierState() {
override fun light(): List<BrazierEvent> =
emptyList()
override fun extinguish(): List<BrazierEvent> =
listOf(ExtinguishedEvent())
override fun lit(event: LitEvent): BrazierState =
this
override fun extinguished(event: ExtinguishedEvent): BrazierState =
ExtinguishedState()
// === Point of Interest ===
override fun afterApplying(event: BrazierEvent): BrazierState =
when (event) {
is LitEvent -> lit(event)
is ExtinguishedEvent -> extinguished(event)
}
}
class ExtinguishedState : BrazierState() {
override fun light(): List<BrazierEvent> =
listOf(LitEvent())
override fun extinguish(): List<BrazierEvent> =
emptyList()
override fun lit(event: LitEvent): BrazierState =
LitState()
override fun extinguished(event: ExtinguishedEvent): BrazierState =
this
// === Point of Interest ===
override fun afterApplying(event: BrazierEvent): BrazierState =
when (event) {
is LitEvent -> lit(event)
is ExtinguishedEvent -> extinguished(event)
}
}
class Brazier(
private val state: BrazierState
) {
companion object {
fun fromEvents(events: List<BrazierEvent> = emptyList()): Brazier =
events.fold(
Brazier(ExtinguishedState()),
Brazier::afterApplying)
}
fun light(): List<BrazierEvent> =
state.light()
fun extinguish(): List<BrazierEvent> =
state.extinguish()
private fun afterApplying(event: BrazierEvent): Brazier =
Brazier(state.afterApplying(event))
}
BrazierWithVisitor
// === Point of Interest ===
sealed class BrazierEvent {
abstract fun visit(state: LitState): BrazierState
abstract fun visit(state: ExtinguishedState): BrazierState
}
// === Point of Interest ===
class LitEvent : BrazierEvent() {
override fun visit(state: LitState): BrazierState =
state.lit(this)
override fun visit(state: ExtinguishedState): BrazierState =
state.lit(this)
}
// === Point of Interest ===
class ExtinguishedEvent : BrazierEvent() {
override fun visit(state: LitState): BrazierState =
state.extinguished(this)
override fun visit(state: ExtinguishedState): BrazierState =
state.extinguished(this)
}
sealed class BrazierState {
abstract fun light(): List<BrazierEvent>
abstract fun extinguish(): List<BrazierEvent>
abstract fun lit(event: LitEvent): BrazierState
abstract fun extinguished(event: ExtinguishedEvent): BrazierState
abstract fun afterApplying(event: BrazierEvent): BrazierState
}
class LitState : BrazierState() {
override fun light(): List<BrazierEvent> =
emptyList()
override fun extinguish(): List<BrazierEvent> =
listOf(ExtinguishedEvent())
override fun lit(event: LitEvent): BrazierState =
this
override fun extinguished(event: ExtinguishedEvent): BrazierState =
ExtinguishedState()
// === Point of Interest ===
override fun afterApplying(event: BrazierEvent): BrazierState =
event.visit(this)
}
class ExtinguishedState : BrazierState() {
override fun light(): List<BrazierEvent> =
listOf(LitEvent())
override fun extinguish(): List<BrazierEvent> =
emptyList()
override fun lit(event: LitEvent): BrazierState =
LitState()
override fun extinguished(event: ExtinguishedEvent): BrazierState =
this
// === Point of Interest ===
override fun afterApplying(event: BrazierEvent): BrazierState =
event.visit(this)
}
class Brazier(
private val state: BrazierState
) {
companion object {
fun fromEvents(events: List<BrazierEvent> = emptyList()): Brazier =
events.fold(
Brazier(ExtinguishedState()),
Brazier::afterApplying)
}
fun light(): List<BrazierEvent> =
state.light()
fun extinguish(): List<BrazierEvent> =
state.extinguish()
private fun afterApplying(event: BrazierEvent): Brazier =
Brazier(state.afterApplying(event))
}
BrazierTests
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class BrazierTests {
@Test
fun `light extinguished brazier`() {
val brazier = Brazier.fromEvents()
val events = brazier.light()
assertTrue(events.single() is LitEvent)
}
@Test
fun `extinguish lit brazier`() {
val brazier = Brazier.fromEvents(listOf(
LitEvent()
))
val events = brazier.extinguish()
assertTrue(events.single() is ExtinguishedEvent)
}
@Test
fun `light lit brazier`() {
val brazier = Brazier.fromEvents(listOf(
LitEvent()
))
val events = brazier.light()
assertTrue(events.isEmpty())
}
@Test
fun `extinguish extinguished brazier`() {
val brazier = Brazier.fromEvents()
val events = brazier.extinguish()
assertTrue(events.isEmpty())
}
}
Both implementations perform well enough (1ms for each test), but what I'm most interested in is if this is the best way to avoid type-checks for something like this.
Writing out all of the possible combinations, there are only a couple interesting Event-State transitions (Lit -> Extinguished, Extinguished -> Lit), and some others aren't terribly interesting (Lit -> Lit, Extinguished -> Extinguished). With a couple of commands and Events, it's not so bad, but with 5 and 6, the interface is going to get more verbose with the Visitor pattern (1 method per command, 1 method per Event, per State), whereas we can use when+else
to only describe the interesting cases, and ignore the uninteresting ones.