The problem we'll be working with is the Mastermind game. If you're unfamiliar with the game, you can learn about it from Wikipedia or this GitHub repository.
As before, we're still only focusing on the domain model.
We're interested in the behaviour and types of the execute() function that we discussed in the previous post. Here's how the function signature will look like for our game:
Note: The solution presented here has been developed with TDD. However, our goal now is to show the final solution rather than how we have arrived at it with refactoring. This is an important note, as I wouldn't like to introduce confusion that what we do here is TDD. TDD is better shown in videos than in the written word.
Furthermore, since we've started the series with the model, it might appear as if we're working inside-out. In practice, it's usually the other way around. We work outside-in, starting at the boundaries of the system.
Event model
Let's start with the output of an event modeling workshop to visualise what needs to be done.
Diagrams below reveal the commands (blue), events (orange), and error cases (red) the Mastermind game might need. We can also see the views (green), but we're not concerned with them on this occasion.
Playing the game means we join a new game and make guesses.
Eventually, we might make a guess that matches the secret and win the game.
Or, we might run out of available attempts and lose the game.
There are also several scenarios where our guess might be rejected.
Commands
In the event modeling workshop, we have identified two commands: JoinGame and MakeGuess.
Commands are implemented as data classes with immutable properties. What's common between the two commands is a game identifier.
All the commands that are handled by the same aggregate implement the same sealed interface. Later, we'll use the same pattern with events and errors.
Note: Isn't it curious how we continue to use the term "aggregate" while there's no single entity that represents the aggregate? I like to think that, conceptually, the aggregate is still there. It's represented by commands, events, and the behaviour of the execute() function.
The key benefit of sealed types comes into play with the when expression. It lets us rely on exhaustive matching:
Let's notice there's no else branch in the above example. Since our type is sealed we can be sure we covered all the possible cases. The compiler will tell us otherwise.
If we ever add a new command implementation, the compiler will point out all the places we matched on the command that need to be updated with a new branch.
Events
In the workshop, we have also identified four events: GameStarted, GuessMade, GameWon, and GameLost. GameStarted will only be published in the beginning, while GameWon and GameLost are our terminal events.
Similarly to commands, we use a sealed interface to implement all the events.
Errors
To be honest, not all of these error cases were identified in the modeling workshop. Many were discovered in the implementation phase and added to the diagram later.
Yet again, sealed interfaces come in handy. This time the hierarchy is a bit more nested. This way we'll be able to perform a more fine-grained matching later on when it comes to handling errors and converting them to responses.
Other domain types
Notice how we tend to avoid primitive types in our commands, events, and errors.
Rich types help us to express our domain better and prevent us from making simple mistakes (i.e. is this string a game ID or a player ID?).
Behaviours
With the right workshop, we can discover a lot of types and behaviours upfront. It's nearly impossible to think of everything upfront though. We fear not, as when it comes to the implementation, the tests we write tend to guide us and fill in the gaps.
Joining the game
Joining the game should result in the GameStarted event.
Test cases for domain model behaviours could not be simpler. We need to arrange fixtures, execute the tested behaviour, and verify the outcome. The good old Arrange/Act/Assert, a.k.a Given/When/Then. Meszaros calls it a four-phase test, but in our case, the fourth phase will be implicitly done by the test framework and there's nothing left to tear down.
Since we planned for our domain model to be purely functional, it's easy to stick to this structure to keep the tests short and readable.
In the test case below, the test fixture (test context) is defined as properties of the test class. The test case creates the command, executes it, and verifies we got the expected events in response. This is how all our tests for the domain model will look like.
The actual result of execute() is of type Either<GameError, NonEmptyList<GameEvent>>, so when verifying the outcome we need to confirm whether we received the left or the right side of it. Left is by convention used for the error case, while Right is for success.
We created the shouldSucceedWith() and shouldFailWith() extension functions in the hope of removing some noise from the tests.
Since for this first case, there are no error scenarios or any complex calculations, we only need to return a list with the GameStarted event inside. Matching the command with when() gives us access to command properties for the matched type thanks to smart casts.
Notice the either {}builder above. It makes sure the value we return from its block is wrapped in an instance of Either, in this case, the Right instance. either { nonEmptyListOf(GameStarted())) } is an equivalent of nonEmptyListOf(GameStarted())).right().
Making a guess
Making a valid guess should result in the GuessMade event.
This time, we additionally need to prepare the state of the game, since MakeGuess is never executed as the first command. The game must've been started first.
The state is created based on past events. We delegated this task to the gameOf() function. Since for now, we treat the list of events as the state, we only need to return the list. Later on, we'll see how to convert it to an actual state object.
Now we need to return the GuessMade event for the second when() branch.
Receiving feedback
Feedback should be given on each valid guess with the GuessMade event.
Here's one of the test cases for receiving feedback.
We'll need a few more similar test cases to develop the logic for giving feedback. They're hidden below if you're interested.
Receiving feedback test cases
Eventually, we arrived at the solution below.
We've hidden Game.exactHits() and Game.colourHits() below as they're not necessarily relevant. The point is we identify pegs on the correct and incorrect positions and convert them to BLACK and WHITE pegs respectively.
Exact hits and colour hits
What's more interesting is how we accessed the state of the game - namely the secret code and its pegs. The secret is available with the first GameStarted event. Since we have access to the whole event history for the given game, we could filter it for the information we need. We achieved it with extension functions on the Game type (which is actually a List<GameEvent> for now).
Enforcing invariants
Commands that might put our game in an inconsistent state should be rejected. For example, we can't make a guess for a game that hasn't started or has finished. Also, guesses with a code that's of a different length to the secret are incomplete. Finally, we can't make guesses with pegs that are not part of the game.
Game not started
The game should not be played if it has not been started.
Our approach to validate an argument is to pass it to a validation function that returns the validated value or an error. That's the job for startedNotFinishedGame() below. Once the game is returned from the validation function, we can be sure that it was started. We can then pass it to the next function with map(). In case of an error, map will short-circuit by returning the error, and the next function won't be called.
Guess is invalid
The guess code needs to be of the same length as the secret. Furthermore, it should only contain pegs that the game has been started with.
Here, we use the same trick with a validation function to validate the guess. Notice we have to use flatMap() instead of map() in the outer call. Otherwise an Either result would be placed inside another Either.
Winning the game
Winning the game means publishing the GameWon event in addition to the usual GuessMade.
We now need to decide if the game is won based on whether the number of guessed pegs is equal to the secret length.
Again, that requires a bit of state.
We've taken care of deciding whether the game is won. We also need to publish an additional event. To do this, we make makeGuess() return a single event. Next, we create the withOutcome() function that converts the result of makeGuess() to a list and optionally appends the GameWon event.
Once the game is won it shouldn't be played anymore.
This requires an additional validation rule.
The game is won if there has been a GameWon event published.
Losing the game
Losing the game means publishing the GameLost event in addition to the usual GuessMade.
We now need to decide if the game is lost based on whether we have run out of attempts.
The following state-accessing functions turned out to be handy for this task.
One last rule is that we cannot continue playing a lost game.
One more condition in the validation function will do the job.
The game is lost if there has been a GameLost event published.
Summary
We explained the implementation details of an event-sourced functional domain model in Kotlin. In the process, we attempted to show how straightforward testing of such a model can be and how it doesn't require any dedicated testing techniques or tools. The model itself, on the other hand, remains a rich core of business logic.