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.
Domain command handler
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.
Event Modeling is a visual analysis technique that focuses heavily on identifying meaningful events happening in a business process.
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.
Playing the game
Eventually, we might make a guess that matches the secret and win the game.
Winning the game
Or, we might run out of available attempts and lose the game.
Losing the game
There are also several scenarios where our guess might be rejected.
Error scenarios
Commands
In the event modeling workshop, we have identified two commands: JoinGame and MakeGuess.
Mastermind game commands
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.
Mastermind game events
Similarly to commands, we use a sealed interface to implement all the events.
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.
Mastermind game errors
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.
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.
Joining the game
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.
Four-phase test
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.
Either a GameError or a list of GameEvents
We created the shouldSucceedWith() and shouldFailWith() extension functions in the hope of removing some noise from the tests.
infixfun<A,B>Either<A,B>.shouldSucceedWith(expected:B)=assertEquals(expected.right(),this,"${expected.right()} is $this")infixfun<A,B>Either<A,B>.shouldFailWith(expected:A)=assertEquals(expected.left(),this,"${expected.left()} is $this")
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.
Making a guess
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.
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
@TestFactoryfun`itgivesfeedbackontheguess`()=guessExamples{(secret:Code,guess:Code,feedback:Feedback)->valgame=gameOf(GameStarted(gameId,secret,totalAttempts,availablePegs))execute(MakeGuess(gameId,guess),game)shouldSucceedWithlistOf(GuessMade(gameId,Guess(guess,feedback)))}privatefunguessExamples(block:(Triple<Code,Code,Feedback>)->Unit)=mapOf("it gives a black peg for each code peg on the correct position"toTriple(Code("Red","Green","Blue","Yellow"),Code("Red","Purple","Blue","Purple"),Feedback(IN_PROGRESS,BLACK,BLACK)),"it gives no black peg for code peg duplicated on a wrong position"toTriple(Code("Red","Green","Blue","Yellow"),Code("Red","Red","Purple","Purple"),Feedback(IN_PROGRESS,BLACK)),"it gives a white peg for code peg that is part of the code but is placed on a wrong position"toTriple(Code("Red","Green","Blue","Yellow"),Code("Purple","Red","Purple","Purple"),Feedback(IN_PROGRESS,WHITE)),"it gives no white peg for code peg duplicated on a wrong position"toTriple(Code("Red","Green","Blue","Yellow"),Code("Purple","Red","Red","Purple"),Feedback(IN_PROGRESS,WHITE)),"it gives a white peg for each code peg on a wrong position"toTriple(Code("Red","Green","Blue","Red"),Code("Purple","Red","Red","Purple"),Feedback(IN_PROGRESS,WHITE,WHITE))).dynamicTestsFor(block)fun<T:Any>Map<String,T>.dynamicTestsFor(block:(T)->Unit)=map{(message,example:T)->DynamicTest.dynamicTest(message){block(example)}}
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
privatefunGame.exactHits(guess:Code):List<Code.Peg>=this.secretPegs.zip(guess.pegs).filter{(secretColour,guessColour)->secretColour==guessColour}.unzip().secondprivatefunGame.colourHits(guess:Code):List<Code.Peg>=this.secretPegs.zip(guess.pegs).filter{(secretColour,guessColour)->secretColour!=guessColour}.unzip().let{(secret,guess)->guess.fold(secrettoemptyList<Code.Peg>()){(secretPegs,colourHits),guessPeg->secretPegs.remove(guessPeg)?.let{ittocolourHits+guessPeg}?:(secretPegstocolourHits)}.second}/**
* Removes an element from the list and returns the new list, or null if the element wasn't found.
*/privatefun<T>List<T>.remove(item:T):List<T>?=indexOf(item).let{index->if(index!=-1)filterIndexed{i,_->i!=index}elsenull}
Detailed calculations of exact 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).
invariants: rules that have to be protected at all times
"Learning Domain-Driven Design" by Vladik Khononov.
Rejecting a command
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.
An invariant is a state that must stay transactionally consistent throughout the Entity life cycle.
"Implementing Domain-Driven Design" by Vaughn Vernon
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.
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.
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.
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.
I love Kotlin for its language features, which make it easier to write business logic straightforward like immutable structures of sealed classes, when switches. It makes code so simple at high levels of abstraction. It also keeps freedom of choice programming approaches to problem solving.
I really enjoy reading this series although I'm not familiar with Kotlin:)
I just wonder if validations from "Guess is invalid" section are the same quality as verification of other game rules. They seem to me more like user input validation. I think that even tests tell us that that something might be wrong because in order to test a guess you need to provide in setup values like gameId, totalAttempts or even full secret value.
I'd consider introducing a class like Guess which describes valid guess so the Game doesn't need to bother with simple input validations. Testing Guess would then need only secretSize and availablePegs of a current game.
Of course keeping everything in one place as you did may work very well depeding of the complexity. Personally I've seen too many examples where simple code was tested on too high level with quite a big setup and that's why I'm sensitive to it:D
Quality focussed software engineer, architect, and trainer. Friends call me Kuba.
Open for work. Get in touch for consultancy, training or software development.
I chose to treat invalid guesses as business rules instead of input validation, as only a started game "knows" the length of the code (secret) or available pegs. These vary depending on how the game was started (i.e. I could start a game with 3-colour secret and 5 available pegs). If I moved it to input validation, I'd need to ask the Game about these details anyway.
Engage with a sea of insights in this enlightening article, highly esteemed within the encouraging DEV Community. Programmers of every skill level are invited to participate and enrich our shared knowledge.
A simple "thank you" can uplift someone's spirits. Express your appreciation in the comments section!
On DEV, sharing knowledge smooths our journey and strengthens our community bonds. Found this useful? A brief thank you to the author can mean a lot.
Top comments (4)
I love Kotlin for its language features, which make it easier to write business logic straightforward like immutable structures of sealed classes, when switches. It makes code so simple at high levels of abstraction. It also keeps freedom of choice programming approaches to problem solving.
I really enjoy reading this series although I'm not familiar with Kotlin:)
I just wonder if validations from "Guess is invalid" section are the same quality as verification of other game rules. They seem to me more like user input validation. I think that even tests tell us that that something might be wrong because in order to test a guess you need to provide in setup values like gameId, totalAttempts or even full secret value.
I'd consider introducing a class like Guess which describes valid guess so the Game doesn't need to bother with simple input validations. Testing Guess would then need only secretSize and availablePegs of a current game.
Of course keeping everything in one place as you did may work very well depeding of the complexity. Personally I've seen too many examples where simple code was tested on too high level with quite a big setup and that's why I'm sensitive to it:D
I chose to treat invalid guesses as business rules instead of input validation, as only a started game "knows" the length of the code (secret) or available pegs. These vary depending on how the game was started (i.e. I could start a game with 3-colour secret and 5 available pegs). If I moved it to input validation, I'd need to ask the Game about these details anyway.
This dev.to font so weird .
Some comments may only be visible to logged-in visitors. Sign in to view all comments.