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.
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 | not 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.
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.
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”?
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.)
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.)
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?
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?
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 branch → multi-player → clean up the getFirst() fake and the leftover comments. You'll be one step ahead the whole way.
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.