Object-oriented event sourcing

Jakub Zalas - Nov 16 '23 - - Dev Community

In the last post, we’ve shown how to represent state in a domain model. We mostly kept behaviours separate from data in a true functional programming fashion.

This time, we’re going to take a little detour and refactor our solution towards an object-oriented (OOP) style. As it turns out, the finite state machine we implemented previously enables us to take advantage of polymorphic behaviour via dynamic dispatch.

Don't worry though, we're not going to go full-stateful but we'll stick to immutability.

There are two behaviours we're going to move in this exercise:

  • applyEvent() - used for transitioning state.
  • execute() - used for command execution.

Transitioning state

State transitions

State transitions

Last time, we implemented state transitions in a regular function called applyEvent(). It takes advantage of a sealed type hierarchy and transitions the current state to a new state based on an event.



fun applyEvent(
  game: Game,
  event: GameEvent
): Game = when (game) {
  is NotStartedGame -> when (event) {
    is GameStarted -> StartedGame(event.secret, 0, event.totalAttempts, event.availablePegs)
    else -> game
  }

  is StartedGame -> when (event) {
    is GameStarted -> game
    is GuessMade -> game.copy(attempts = game.attempts + 1)
    is GameWon -> WonGame(secret = game.secret, attempts = game.attempts, totalAttempts = game.totalAttempts)
    is GameLost -> LostGame(secret = game.secret, totalAttempts = game.totalAttempts)
  }

  is WonGame -> game
  is LostGame -> game
}


Enter fullscreen mode Exit fullscreen mode
State transitions (implementation)

The advantage is we have all the transition logic in one place. Each state is a data class or object that implements a sealed interface. Thanks to the sealed interface the compiler will remind us to update missing branches in case we add a new state.



sealed interface Game

data object NotStartedGame : Game

data class StartedGame(
  val secret: Code,
  val attempts: Int,
  val totalAttempts: Int,
  val availablePegs: Set<Code.Peg>
) : Game

data object WonGame : Game

data object LostGame : Game


Enter fullscreen mode Exit fullscreen mode
State objects and classes

Let's group the behaviour with data.

First, we'll need to define an applyEvent() method on the Game interface. The signature will be similar to our function's signature, except the game will no longer be a parameter.



sealed interface Game {
  fun applyEvent(event: GameEvent): Game   
}


Enter fullscreen mode Exit fullscreen mode
Moving state transitions to the Game

Now, the compiler will ask us to implement the method on each state data class/object. Our IDE will help us here. We will also need to update the tests to use the new method instead of the old function.

Next, we will need to dissect the applyEvent() function and move logic to an appropriate class/object.

Refactoring steps involve calling the original function, and inlining it, to finally simplify and remove dead branches.

Dissecting state transitions

Dissecting state transitions

Starting with NotStartedGame, it responds to the GameStarted event. Any other event is ignored.



data object NotStartedGame : Game {
  override fun applyEvent(event: GameEvent): Game = when (event) {
    is GameStarted -> StartedGame(event.secret, 0, event.totalAttempts, event.availablePegs)
    else -> this
  }
}


Enter fullscreen mode Exit fullscreen mode
Moving state transitions to NotStartedGame

StartedGame ignores GameStarted, increments attempts on GuessMade, and transitions to WonGame or LostGame in response to GameWon and GameLost respectively.



data class StartedGame(
  val secret: Code,
  val attempts: Int,
  val totalAttempts: Int,
  val availablePegs: Set<Code.Peg>
) : Game {

  override fun applyEvent(event: GameEvent): Game = when (event) {
    is GameStarted -> this
    is GuessMade -> this.copy(attempts = this.attempts + 1)
    is GameWon -> WonGame
    is GameLost -> LostGame
  }
}


Enter fullscreen mode Exit fullscreen mode
Moving state transitions to StartedGame

WonGame and LostGame are terminal states so they're free to ignore any events sent their way.



data object WonGame : Game {
  override fun applyEvent(event: GameEvent): Game = this
}

data object LostGame : Game {
  override fun applyEvent(event: GameEvent): Game = this
}


Enter fullscreen mode Exit fullscreen mode
Moving state transitions to WonGame and LostGame

What we end up with is behaviour for each state that's close to its type definition and data.

Executing commands

Moving on to command execution, the steps involved are quite similar.

First, we need to define a new method on the Game interface. Similarly to the previous case, the game argument goes away.



sealed interface Game {
    fun applyEvent(event: GameEvent): Game
    fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>>


Enter fullscreen mode Exit fullscreen mode
Moving command execution to the Game

Again, the compiler will ask us to implement missing methods and we will be able to use similar refactoring steps to move logic as before.

NotStartedGame only responds to the JoinGame command.



data object NotStartedGame : Game {
  // …

  override fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>> = when (command) {
    is JoinGame -> nonEmptyListOf(
      GameStarted(command.gameId, command.secret, command.totalAttempts, command.availablePegs)
    ).right()

    else -> GameNotStarted(command.gameId).left()
  }
}


Enter fullscreen mode Exit fullscreen mode
Moving command execution to NotStartedGame

StartedGame enables us to make guesses. In the previous solution, we were not handling the case of someone trying to start an already-started game. We'd simply start a new game again.

This is indicated by TODO() in the code below. Our solution here would be to either return an error or allow this to happen silently by returning no new events. The latter would require us to change the signature to allow for returning an empty list of events.



data class StartedGame(
  private val secret: Code,
  private val attempts: Int,
  private val totalAttempts: Int,
  private val availablePegs: Set<Code.Peg>
) : Game {
  // …

  override fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>> = when (command) {
    is MakeGuess -> validGuess(command).map { guess ->
      GuessMade(command.gameId, Guess(command.guess, feedbackOn(guess)))
    }.withOutcome()

    else -> TODO()
  }

  // …
}


Enter fullscreen mode Exit fullscreen mode
Moving command execution to StartedGame

Notice we no longer need to validate if the game is started since we're already in the StartedGame state.

Furthermore, we can lower the visibility of all the properties to be private. Nothing outside depends on them. In fact, nothing outside depends on any specific implementation of Game.

Many of our extension functions can be now promoted to private instance methods. The full class is hidden below.

Full StartedGame example



data class StartedGame(
  private val secret: Code,
  private val attempts: Int,
  private val totalAttempts: Int,
  private val availablePegs: Set<Code.Peg>
) : Game {
  private val secretLength: Int = secret.length

  private val secretPegs: List<Code.Peg> = secret.pegs

  override fun applyEvent(event: GameEvent): Game = when (event) {
    is GameStarted -> this
    is GuessMade -> this.copy(attempts = this.attempts + 1)
    is GameWon -> WonGame
    is GameLost -> LostGame
  }

  override fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>> = when (command) {
    is MakeGuess -> validGuess(command).map { guess ->
      GuessMade(command.gameId, Guess(command.guess, feedbackOn(guess)))
    }.withOutcome()

    else -> TODO()
  }

  private fun validGuess(command: MakeGuess): Either<GameError, Code> {
    if (isGuessTooShort(command.guess)) {
      return GuessTooShort(command.gameId, command.guess, secretLength).left()
    }
    if (isGuessTooLong(command.guess)) {
      return GuessTooLong(command.gameId, command.guess, secretLength).left()
    }
    if (!isGuessValid(command.guess)) {
      return InvalidPegInGuess(command.gameId, command.guess, availablePegs).left()
    }
    return command.guess.right()
  }

  private fun isGuessTooShort(guess: Code): Boolean =
    guess.length < secretLength

  private fun isGuessTooLong(guess: Code): Boolean =
    guess.length > secretLength

  private fun isGuessValid(guess: Code): Boolean =
    availablePegs.containsAll(guess.pegs)

  private fun feedbackOn(guess: Code): Feedback =
    feedbackPegsOn(guess)
      .let { (exactHits, colourHits) ->
        Feedback(outcomeFor(exactHits), exactHits + colourHits)
      }

  private fun feedbackPegsOn(guess: Code) =
    exactHits(guess).map { BLACK } to colourHits(guess).map { WHITE }

  private fun outcomeFor(exactHits: List<Feedback.Peg>) = when {
    exactHits.size == this.secretLength -> WON
    this.attempts + 1 == this.totalAttempts -> LOST
    else -> IN_PROGRESS
  }

  private fun exactHits(guess: Code): List<Code.Peg> = this.secretPegs
    .zip(guess.pegs)
    .filter { (secretColour, guessColour) -> secretColour == guessColour }
    .unzip()
    .second

  private fun colourHits(guess: Code): List<Code.Peg> = this.secretPegs
    .zip(guess.pegs)
    .filter { (secretColour, guessColour) -> secretColour != guessColour }
    .unzip()
    .let { (secret, guess) ->
      guess.fold(secret to emptyList<Code.Peg>()) { (secretPegs, colourHits), guessPeg ->
        secretPegs.remove(guessPeg)?.let { it to colourHits + guessPeg } ?: (secretPegs to colourHits)
      }.second
    }

    private fun Either<GameError, GuessMade>.withOutcome(): Either<GameError, NonEmptyList<GameEvent>> =
      map { event ->
        nonEmptyListOf<GameEvent>(event) +
          when (event.guess.feedback.outcome) {
            WON -> listOf(GameWon(event.gameId))
            LOST -> listOf(GameLost(event.gameId))
            else -> emptyList()
          }
        }
      }


Enter fullscreen mode Exit fullscreen mode
Moving command execution to StartedGame (full example)

Finally, our terminal states do not expect any further commands and respond with errors.



data object WonGame : Game {
  // …

  override fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>> =
    GameAlreadyWon(command.gameId).left()
}

data object LostGame : Game {
  // …

  override fun execute(command: GameCommand): Either<GameError, NonEmptyList<GameEvent>> =
    GameAlreadyLost(command.gameId).left()
  }
}


Enter fullscreen mode Exit fullscreen mode
Moving command execution to WonGame and LostGame

As before, we end up with behaviour for each state close to its type definition and data. We could make all the properties private since nothing outside of our public methods needs to access them. It wouldn't be terrible to keep them open though, since they're immutable.

Usage

Let's consider how usage has changed since we've moved behaviours to state classes.

Previously, we were building the state by folding past events and applying them over and over to the state transitioned by each iteration.



val game = events.fold(notStartedGame(), ::applyEvent)


Enter fullscreen mode Exit fullscreen mode
Building the state (before)

That hasn't changed, except the function we now need to call is defined on the state object itself. The dynamic dispatch mechanism will take care of delegating the call to the right implementation of Game.



val game = events.fold(notStartedGame(), Game::applyEvent)


Enter fullscreen mode Exit fullscreen mode
Building the state (after)

We now execute a command by sending the message to the game instance.



game.execute(command)


Enter fullscreen mode Exit fullscreen mode
Executing commands

To perform the refactoring we had to update tests shortly after adding a new method to the Game interface. Here's one of the test cases that was updated to use the OOP way that demonstrates the new usage.



@Test
fun `it makes a guess`() {
  val command = MakeGuess(gameId, Code("Purple", "Purple", "Purple", "Purple"))
  val game = gameOf(
    GameStarted(gameId, secret, totalAttempts, availablePegs)
  )

  game.execute(command) shouldSucceedWith listOf(
    GuessMade(
      gameId,
      Guess(
        Code("Purple", "Purple", "Purple", "Purple"),
        Feedback(IN_PROGRESS)
      )
    )
  )
}

private fun gameOf(vararg events: GameEvent): Game = 
  events.toList().fold(notStartedGame(), Game::applyEvent)


Enter fullscreen mode Exit fullscreen mode
An example test case

Summary

We experimented with refactoring our functional solution to be more object-oriented. We ended up with a domain model that's closer to what we're used to when practising OOP since all the data is now private and behaviours are implemented as instance methods. In some way, it's still functional, but it does take advantage of some object-oriented properties like encapsulation or polymorphism.

The functional solution makes it easier to add new behaviours without modifying existing types.

The object-oriented solution makes it easier to add new types without modifying existing behaviours.

Next time, we'll be leaving the realm of functional purity to take a look at how to feed events into the domain model and persist events that the model generates.

Resources

. . . . . . . . . . . . .