LESSON 0005 · The application layer · Blackjack Ensemble course
Lesson 0005 — the next red after “Paying the Winners”
In 0004 you paid a winner. But your green code calls win() for everyone — including a player who busted. This lesson drives the fix test-first, and teaches the subtle bit that trips people up: the account balance is already correct, so it cannot drive the change. You have to test the event the account records.
settle() design you've been sketching.
execute() loop pays every result the same way — with win():
for (PlayerResult result : game.playerResults()) {
playerAccountRepository.find(result.playerId())
.ifPresent(account -> {
account.win(result.payout(), result.outcome()); // ← called even for a bust!
playerAccountRepository.save(account);
});
}
A busted player has payout() == 0, so this calls win(0, PLAYER_BUSTED). That's the defect this lesson hunts.
Trace what win(payout) does to a balance of 39 (a player who deposited 50 and bet 11) for each outcome:
| Outcome | payoff × | payout | win(payout) → balance | Correct? |
|---|---|---|---|---|
PLAYER_BEATS_DEALER | 2 | 22 | 39 + 22 = 61 | ✓ |
PLAYER_PUSHES_DEALER | 1 | 11 | 39 + 11 = 50 | ✓ (stake back) |
PLAYER_BUSTED / PLAYER_LOSES | 0 | 0 | 39 + 0 = 39 | ✓ (unchanged) |
Look hard at that last column: every balance is already correct. win(0) adds nothing, and a push's win(bet) just hands the stake back. So a test that asserts on balance will pass no matter what — it cannot force you to write new code.
Player deposits 50, bets 11, then busts. What's the final balance — and does the win()-for-everyone code already produce it?
39 · yes, already green
50 − 11 = 39. The buggy code calls win(0, PLAYER_BUSTED), which adds 0 → still 39. So assertThat(balance).isEqualTo(39) passes. A balance test gives you a false “all good”. Whatever's wrong here, it isn't the money.
Every command on a PlayerAccount records an event — that's the source of truth (the balance is just a projection of those events). Calling win(0, PLAYER_BUSTED) records this:
PlayerWonGame(0, PLAYER_BUSTED) // ← the log says this player WON. They busted.
The balance happens to land right, but the history is a lie. Anything that reads events — an audit trail, a “why did I lose?” screen, a stats projection — now believes a busted player won a $0 game. That's the real defect, and it lives in the event stream.
win(0) vs lose()) produce the same balance, a balance assertion can't tell them apart. Drop down to the events — that's where they differ.
A PlayerAccount exposes its not-yet-persisted events via freshEvents() — that's exactly how PlayerAccountTest.loseEmitsPlayerLostGame checks the domain. At the service level we do the same, one step out: capture the account handed to save() and inspect its fresh events. We use a Mockito mock of the repository so save() is a no-op — a real save() would clear the fresh events before we could read them.
application/GameServiceTest.java · expect RED
Add these imports:
import com.jitterted.ebp.blackjack.domain.MoneyDeposited;
import com.jitterted.ebp.blackjack.domain.PlayerLostGame;
import com.jitterted.ebp.blackjack.domain.PlayerOutcome;
import com.jitterted.ebp.blackjack.domain.PlayerRegistered;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
Then add the test. A player with a real account busts, and we assert the account was told it lost:
@Test
void whenPlayerBustsGameOverRecordsPlayerLostGame() {
PlayerAccountRepository accounts = mock(PlayerAccountRepository.class);
PlayerAccount account = PlayerAccount.reconstitute(
PlayerId.of(9),
List.of(new PlayerRegistered("loser"), new MoneyDeposited(50)));
when(accounts.find(PlayerId.of(9))).thenReturn(Optional.of(account));
GameService gameService = GameService.createForTest(new StubShuffler(), accounts);
// player: TEN+TEN = 20, then hits FIVE = 25 → PLAYER_BUSTED
StubDeck deck = new StubDeck(Rank.TEN, Rank.NINE, Rank.TEN, Rank.SEVEN, Rank.FIVE);
gameService.createGame(List.of(PlayerId.of(9)), new Shoe(List.of(deck)));
gameService.placePlayerBets(List.of(new PlayerBet(PlayerId.of(9), Bet.of(11))));
gameService.initialDeal();
gameService.playerHits(); // 25 → bust, game over
ArgumentCaptor<PlayerAccount> captor = ArgumentCaptor.forClass(PlayerAccount.class);
verify(accounts, atLeastOnce()).save(captor.capture());
assertThat(captor.getValue().freshEvents())
.contains(new PlayerLostGame(PlayerOutcome.PLAYER_BUSTED));
}
(Why atLeastOnce(): save() is called twice — once in placePlayerBets, once at settlement — with the same account object, so captor.getValue() (the last capture) is the one we want.)
Predict, then run just this test (⌃⇧R). What event will the captured account actually hold?
Expecting … to contain: PlayerLostGame[…] but could not find: … had PlayerWonGame(0, PLAYER_BUSTED)
The win-only code recorded PlayerWonGame(0, PLAYER_BUSTED), so the contains(PlayerLostGame…) fails. This is the correct red — and notice a balance assertion here would have been green. The event is what exposes the bug.
Make it pass with the smallest change. Split the loop body on whether there's a payout:
for (PlayerResult result : game.playerResults()) {
playerAccountRepository.find(result.playerId())
.ifPresent(account -> {
if (result.payout() > 0) {
account.win(result.payout(), result.outcome());
} else {
account.lose(result.outcome()); // ← records PlayerLostGame
}
playerAccountRepository.save(account);
});
}
Run → green. The 0004 winner test still passes (payout > 0 → win); the new bust test now records PlayerLostGame. Two tests, two branches, and you only wrote the branch a failing test demanded.
Before you celebrate, look at the middle row of that first table again. A push has payout() == 11 > 0, so your new branch sends it to win() — recording PlayerWonGame(11, PLAYER_PUSHES_DEALER). The balance is right, but the story's wrong again: a tie is being logged as a win.
You add a test: player pushes, assert the account records a PlayerPushed event. Why does the two-arm branch fail it — and what does fixing it do to the if?
A push takes the payout > 0 arm and records PlayerWonGame, not PlayerPushed → red. To fix it you'd add a PlayerPushed event and a PlayerAccount.push(...) command, then grow the branch to three arms: payout > bet → win, == bet → push, else → lose. Three arms keyed on outcome, sitting in the application layer. That is the smell that earns the refactor below.
This is the object-oriented move you asked about. Under green, push the growing conditional down into result types that each know how to settle themselves. Make PlayerResult a sealed interface:
public sealed interface PlayerResult permits WinResult, PushResult, LoseResult {
PlayerId playerId();
void settle(PlayerAccount account);
}
and the service loses its branch entirely — it just tells the result to settle:
for (PlayerResult result : game.playerResults()) {
playerAccountRepository.find(result.playerId())
.ifPresent(account -> {
result.settle(account); // Win/Push/Lose decides how
playerAccountRepository.save(account);
});
}
Each type carries its own settlement:
WinResult.settle(a) → a.win(payout, outcome);
PushResult.settle(a) → a.push(bet, outcome);
LoseResult.settle(a) → a.lose(outcome);
result.settle(account) dispatches on the result type only — PlayerAccount is one concrete type. It's the same instinct that powers Smalltalk double dispatch, but one bounce here. It becomes true double dispatch only if the account side also varies (e.g. a HouseAccount). Don't build that until you need it.PlayerOutcome as the payoff multiplier and the description (busted vs out-scored). Let the class (WinResult/…) carry what to do. Don't model the same taxonomy twice and let them drift.WinResult/PushResult/LoseResult (a factory). That's fine: the point of Replace-Conditional-with-Polymorphism is to move the if to a single creation site so every use site is branch-free.settle() after you have the third behaviour (the push event), or the moment the same switch (outcome) shows up in a second file (settlement + event choice + UI rendering). One conditional in one place doesn't yet earn a class hierarchy. Refactor under green, with the tests from steps 3–5 as your safety net.
| # | Step | What you do |
|---|---|---|
| 1 | RED | Write whenPlayerBustsGameOverRecordsPlayerLostGame. Assert on the event (§3). Run it → it holds PlayerWonGame. |
| 2 | GREEN | Add the if (payout > 0) win else lose branch (§4). Run the test, then the full suite → all green. |
| 3 | RED | (optional, next session) Write the push test. Add a PlayerPushed event + PlayerAccount.push(...). Grow the branch to three arms. |
| 4 | REFACTOR | Under green, extract the sealed PlayerResult + settle() (§6). Delete the branch from GameService. Run → still green. |
The refactor is Martin Fowler, Refactoring — “Replace Conditional with Polymorphism”. The red→green→refactor rhythm and driving one case at a time is Kent Beck, Test-Driven Development: By Example (triangulation). Why win()/lose() record events rather than mutate is in your event-sourced aggregate learning record. Money-in/money-out modelling from Ted Young's ensemble: tedyoung.me.
Want to rehearse the push red before you build it, or pair on turning the three-arm branch into settle() step by step? Ask me — we'll predict the events together first, then write them.