LESSON 0005 · The application layer · Blackjack Ensemble course

Lesson 0005 — the next red after “Paying the Winners”

Settling the Losers: test the event, not just the balance

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.

The win By the end you'll (1) see why a balance assertion goes green even for a loser, (2) write a RED test that inspects the emitted event, (3) make it green with a one-line branch, and (4) know exactly when to refactor that branch into the polymorphic settle() design you've been sketching.

Where 0004 left you Your green 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.

1 · The surprising part — the balance is already right

Trace what win(payout) does to a balance of 39 (a player who deposited 50 and bet 11) for each outcome:

Outcomepayoff ×payoutwin(payout) → balanceCorrect?
PLAYER_BEATS_DEALER22239 + 22 = 61
PLAYER_PUSHES_DEALER11139 + 11 = 50✓ (stake back)
PLAYER_BUSTED / PLAYER_LOSES0039 + 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?

reveal

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.

2 · So where's the bug? In the story, not 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.

The key idea Test at the level where the defect lives. When two code paths (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.

3 · RED — assert the emitted event (this is the real red)

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.

Task · in 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?

reveal the red bar

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.

4 · GREEN — one branch: pay winners, record losers

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 > 0win); the new bust test now records PlayerLostGame. Two tests, two branches, and you only wrote the branch a failing test demanded.

5 · The next red — a push is not a win

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?

reveal

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.

6 · REFACTOR — Replace Conditional with Polymorphism (the settle() design)

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);
Three things to keep straight
When to do this — not before Introduce 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.

7 · The TDD steps, in order

#StepWhat you do
1REDWrite whenPlayerBustsGameOverRecordsPlayerLostGame. Assert on the event (§3). Run it → it holds PlayerWonGame.
2GREENAdd the if (payout > 0) win else lose branch (§4). Run the test, then the full suite → all green.
3RED(optional, next session) Write the push test. Add a PlayerPushed event + PlayerAccount.push(...). Grow the branch to three arms.
4REFACTORUnder green, extract the sealed PlayerResult + settle() (§6). Delete the branch from GameService. Run → still green.

Primary source

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.