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
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.
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.
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.
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.
Starting with NotStartedGame, it responds to the GameStarted event. Any other event is ignored.
StartedGame ignores GameStarted, increments attempts on GuessMade, and transitions to WonGame or LostGame in response to GameWon and GameLost respectively.
WonGame and LostGame are terminal states so they're free to ignore any events sent their way.
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.
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.
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.
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
Finally, our terminal states do not expect any further commands and respond with errors.
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.
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.
We now execute a command by sending the message to the game instance.
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.
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.