LESSON 0004 · The application layer · Blackjack Ensemble course

Lesson 0004 — Prep for tonight's ensemble (5pm)

Paying the Winners: wiring game-over payouts into the account

Last session you took money out of a PlayerAccount when a bet was placed. Tonight the ensemble does the mirror image: put money back in when the game is over. This lesson walks you through that exact change — test-first, starting from red — so you arrive already having driven it once.

The win By the end you'll have written a failing GameServiceTest, watched it fail for the right reason, and made it pass by wiring the payout loop in GameService.execute() — the precise seam Ensemble 152 picks up. You'll walk in knowing the API, the payout maths, and the smallest green step.

Why this one is different from 0004's predecessor In Lesson 0002 the test went green the instant you wrote it — good for reading code, useless for driving it. This lesson is the opposite: the behaviour does not exist yet. The test must fail first. That red bar is the whole point — it's the failing spec that the production code has to satisfy.

1 · Where we are — money out, money in

Trace one round through GameService:

StepWhat happens to the accountStatus
placePlayerBets(...)account.bet(amount) → balance − bet, saveddone (last session)
game plays out…
execute(...) sees isGameOver()should pay winners: balance + payoutnot wired — tonight

The unfinished half is sitting in the code as pseudocode. Open application/GameService.java and find execute(...):

private void execute(GameCommand command) {
    Game game = currentGame();
    command.execute(game);
    if (game.isGameOver()) {
        gameMonitor.gameCompleted(game);
        gameRepository.saveOutcome(game);
        // loop through all playerIds in the Game:
            // retrieved PlayerAccount from Repository
            // playerAccount.win(payout, playerOutcome)
            // repository.save(playerAccount)
    }
}

Turning those four comments into working code — test-first — is tonight's session. The good news: every collaborator it needs already exists.

2 · The seam — three objects already built for you

You don't have to invent an API; you have to connect one. Three pieces are waiting:

// 1. The Game already computes each player's result:
List<PlayerResult> results = game.playerResults();

// 2. A PlayerResult knows who, what outcome, and how much:
result.playerId();   // PlayerId — who to look up
result.outcome();    // PlayerOutcome — WON? LOST? PUSH?
result.payout();     // int — outcome.payoff(bet), already computed

// 3. The PlayerAccount already accepts a win or a loss:
account.win(payout, outcome);  // enqueues PlayerWonGame → balance += payout
account.lose(outcome);         // enqueues PlayerLostGame → balance unchanged
The key idea The domain (PlayerResult, PlayerAccount.win/lose) is finished. The gap is purely in the application layerGameService orchestrating a lookup-and-save for each result. That's why this is a small, safe step: no new domain rules, just wiring.

3 · The payout maths (know this cold before 5pm)

PlayerOutcome carries a multiplier applied to the bet. It's the total returned, not the profit — and remember the bet was already subtracted when it was placed:

Outcomepayoff ×Returned on a $10 betNet vs. start
PLAYER_BEATS_DEALER / DEALER_BUSTED2$20+$10
BLACKJACK2.5$25+$15
PLAYER_PUSHES_DEALER1$10$0
PLAYER_BUSTED / PLAYER_LOSES0$0−$10

Account starts at 50. Player bets 11 and beats the dealer. What's the final balance?

reveal

61

Place bet: 50 − 11 = 39. Win pays 2× → payout 22, so win(22) → 39 + 22 = 61. Net +11: you got your stake back plus an equal amount. This is the exact number your failing test will assert.

Same account at 50, bets 11, then busts. Final balance, and which method fires?

reveal

39 · lose()

Place bet: 50 − 11 = 39. Payout is 0, so you call lose(outcome) — it records a PlayerLostGame event but does not touch the balance (the money already left when the bet was placed). Balance stays 39.

4 · RED — write the failing test first (this is the tracer bullet)

Task · in application/GameServiceTest.java · expect RED

Add the import import com.jitterted.ebp.blackjack.domain.StubDeck;, then add this test. It sets up a real account, plays a game the player wins, and asserts the account was paid:

@Test
void whenPlayerWinsGameOverPaysPayoutIntoPlayerAccount() {
    PlayerAccountRepository accounts = PlayerAccountRepository.withNextId(9);
    PlayerAccount account = PlayerAccount.register("winner");
    account.deposit(50);
    accounts.save(account);
    GameService gameService =
            GameService.createForTest(new StubShuffler(), accounts);

    // player: QUEEN+TEN = 20, dealer: EIGHT+JACK = 18 → PLAYER_BEATS_DEALER
    Deck deck = new StubDeck(Rank.QUEEN, Rank.EIGHT, Rank.TEN, Rank.JACK);
    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.playerStands();          // game is now over, player won

    Optional<PlayerAccount> updated = accounts.find(PlayerId.of(9));
    assertThat(updated).get().extracting(PlayerAccount::balance)
            .isEqualTo(50 - 11 + 22);    // = 61
}

Predict, then run just this test (⌃⇧R). What number will the failure report as “but was”?

reveal the red bar

expected: 61  but was: 39

39, because the bet was deducted but execute() never pays the winner — the payout loop is still four comments. This is the correct red: the test fails precisely because the behaviour you're about to build is missing. (Verified — this is the real output.)

5 · GREEN — the smallest wiring that pays a winner

Now make it pass with the minimum. Add import ...domain.PlayerResult; and replace the four comments in execute():

for (PlayerResult result : game.playerResults()) {
    playerAccountRepository.find(result.playerId())
            .ifPresent(account -> {
                account.win(result.payout(), result.outcome());
                playerAccountRepository.save(account);
            });
}

Run the test → green. One winning path, wired. Notice what you did not do: no lose(), no push handling, no multi-player generalising. You only wrote enough to satisfy the one test — the TDD discipline of small, safe steps. (Verified: this passes, and the other 244 tests stay green.)

6 · The next reds — how tonight will actually unfold

Each of these is the next failing test that forces the next slice of code. This is the interleaving you'll do live — jot your prediction next to each:

a. Test: player busts, account had 50, bet 11 → assert balance still 39. Why does the win-only code above fail it?

reveal

Payout is 0, but the code still calls win(0, PLAYER_BUSTED) — which enqueues a PlayerWonGame event. Wrong event recorded (even though balance happens to be unchanged). The red forces the branch: if (result.payout() > 0) account.win(...) else account.lose(...). That's the triangulating step.

b. What's still asymmetric between placePlayerBets and this new payout loop?

reveal

placePlayerBets still only handles bets.getFirst() — a deliberate fake from last session. The payout side already loops all playerResults(). A multi-player test (two accounts, two outcomes) is the red that finally makes placePlayerBets loop too — and lets you delete the commented-out // if (playerAccountRepository != null) scaffold (the contract step).

So the likely arc tonight: win path (you've done it) → lose/push branchmulti-player → clean up the getFirst() fake and the leftover comments. You'll be one step ahead the whole way.

Primary source

The behaviour behind the events — why win()/lose() enqueue rather than mutate — is in your own last session's learning record (event-sourced aggregate). For the money-in/money-out modelling, Ted Young's ensemble is the source: tedyoung.me. On driving from a failing test in small safe steps, Kent Beck, Test-Driven Development: By Example — the “red → green → refactor” and triangulation chapters.

Unsure how playerResults() figures out the outcome, or want to rehearse the multi-player red before 5pm? Ask me — I'll set up a second account and we'll predict two balances together.