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.

Two seams — and the lower one is already done Be precise about what's missing. The domain method PlayerAccount.win(payout, outcome) is already implemented and unit-tested — see PlayerAccountTest.winEmitsPlayerWonGame and playerWonGameIncreasesBalance. So we do not re-test win's internals here. The only gap is the application-layer wiring in GameService.execute() that calls it. That's the single behaviour this lesson drives, test-first, from red.

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 + payoutstarted, not finished

Open application/GameService.java and find execute(...). The ensemble has started the loop — but it finds the account and then does nothing with it:

if (game.isGameOver()) {
    gameMonitor.gameCompleted(game);
    gameRepository.saveOutcome(game);
    for (PlayerId playerId : game.playerIds()) {
        Optional<PlayerAccount> playerAccount =
                playerAccountRepository.find(playerId);   // ← found, but never credited
    }
    // still TODO: playerAccount.win(payout, outcome) + save(...)
}

The account is looked up and then dropped on the floor — no win, no save. Finishing that — test-first — is tonight's session. 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 — the failing test (this is the tracer bullet)

This test is already in your GameServiceTest — but marked @Disabled, so it isn't running. Delete the @Disabled to bring it back to life; it should go red. (If you're starting fresh, here it is in full — you'll also need import com.jitterted.ebp.blackjack.domain.StubDeck;.)

Task · in application/GameServiceTest.java · expect RED

A real account, a game the player wins, and an assertion that 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 the started loop in execute() finds the account and never credits it. 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.)

Saw but was: 50 instead of 39? You already half-wired it If your loop already credits the account with a first-pass line like account.win(game.currentBets().getFirst().bet().amount(), game.currentPlayerOutcome()), your red reads expected: 61 but was: 50. The maths: 50 − 11 (bet) + 11 = 50 — you paid the raw bet (11) back where win() wants the payout (22 = bet × 2 for PLAYER_BEATS_DEALER). It's the same fix as GREEN below: result.payout() asks PlayerOutcome.payoff(bet) for the right multiplier, so the winner is paid 22 and the balance lands on 61. This is not cheating — it's your own domain (PlayerResult / PlayerOutcome) doing the money maths instead of the service guessing at it.

5 · GREEN — iterate playerResults(), not playerIds()

The started loop uses game.playerIds() — but an id alone can't tell you the payout, so you'd have to re-derive each player's outcome and bet. Don't. game.playerResults() hands you a PlayerResult that already carries playerId(), outcome(), and payout(). Swap the loop, add import ...domain.PlayerResult;, and credit the account:

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

Run the test → green. That switch from playerIds() to playerResults() is the elegant move — tell, don't ask: the Game computed the result; the service just applies it. Notice what you did not do: no lose(), no push handling, no multi-player generalising — only enough to satisfy the one test (small, safe steps). (Verified: this passes; full suite 245 green.)

6 · The loss path — the subtle red that teaches where a decision belongs

Obvious next test: a player busts, so nothing is paid. You might reach for a balance assertion — but here's the trap:

a. Player busts (account 50, bet 11). You assert balance == 39. Does that test force you to add a lose() branch?

reveal

No — it passes without one

With the win-only code, a busted player hits win(0, PLAYER_BUSTED). Payout is 0, so balance += 0 → still 39. The balance test is green with no branch at all. Balance can't distinguish a win(0) from a lose() — the difference is the event recorded (PlayerWonGame vs PlayerLostGame). (Verified — this really passes.)

b. So what's the elegant way to drive win-vs-lose? Where should that decision live?

reveal

Since only the event distinguishes them, test at the seam where the event is observable — the domain. Give PlayerAccount one command that decides, e.g. settle(PlayerResult), and unit-test it against freshEvents() (win → PlayerWonGame, loss → PlayerLostGame) — exactly like the existing winEmitsPlayerWonGame test. Then the service loses its if entirely:

for (PlayerResult result : game.playerResults()) {
    playerAccountRepository.find(result.playerId())
            .ifPresent(account -> {
                account.settle(result);          // domain decides win vs lose
                playerAccountRepository.save(account);
            });
}

The pragmatic alternative — an if (payout > 0) win else lose in the service — works, but its only honest test has to inspect events after save(), which is awkward. That awkwardness is the design telling you the decision wants to be in the aggregate. Float settle to the mob tonight.

c. 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 (balance-driven) → win/lose decision (event-driven, ideally in the domain) → multi-player → clean up the getFirst() fake and the leftover comments.

The one thing to remember win() is already built and tested in the domain — don't re-test it through GameService. Drive the wiring with one balance-based integration test; drive the win-vs-lose decision with an event-based domain test. One behaviour per seam, no duplication.

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.