LESSON 0004 · The application layer · Blackjack Ensemble course
Lesson 0004 — Prep for tonight's ensemble (5pm)
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.
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.
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.
Trace one round through GameService:
| Step | What happens to the account | Status |
|---|---|---|
placePlayerBets(...) | account.bet(amount) → balance − bet, saved | done (last session) |
| game plays out… | — | — |
execute(...) sees isGameOver() | should pay winners: balance + payout | started, 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.
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
PlayerResult, PlayerAccount.win/lose) is finished. The gap is purely in the application layer — GameService orchestrating a lookup-and-save for each result. That's why this is a small, safe step: no new domain rules, just wiring.
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:
| Outcome | payoff × | Returned on a $10 bet | Net vs. start |
|---|---|---|---|
PLAYER_BEATS_DEALER / DEALER_BUSTED | 2 | $20 | +$10 |
BLACKJACK | 2.5 | $25 | +$15 |
PLAYER_PUSHES_DEALER | 1 | $10 | $0 |
PLAYER_BUSTED / PLAYER_LOSES | 0 | $0 | −$10 |
Account starts at 50. Player bets 11 and beats the dealer. What's the final balance?
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?
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.
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;.)
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”?
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.)
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.
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.)
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?
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?
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?
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.
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.
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.