LEARNING RECORD · Blackjack Ensemble course
17 July 2026
Date: 2026-07-17 Repo: tedyoung/blackjack-ensemble-blue (branch mob-session) Format: Mob/ensemble via mob.sh + Zoom Participants (from commits): Ted M. Young, Lada Kesseler, CodeItQuick, Daniel Ranner, Tom Spencer
Turn the deliberately-failing WIP test GameServiceTest.placeBetsForPlayerAccountWithInsufficientBalanceThrowsException green for the right reason, and start wiring GameService.placePlayerBets(...) to the event-sourced PlayerAccount — the money/wallet side of placing a bet.
GameService.placePlayerBets(...) — implemented (smallest step)public void placePlayerBets(List<PlayerBet> bets) {
PlayerBet firstPlayerBet = bets.getFirst();
Optional<PlayerAccount> playerAccount =
playerAccountRepository.find(firstPlayerBet.playerId());
playerAccount.ifPresent(account -> {
account.bet(firstPlayerBet.bet().amount()); // throws InsufficientBalance if too low
playerAccountRepository.save(account);
});
currentGame.placePlayerBets(bets);
}
find(...).ifPresent(...) so tests with no account simply skip (they stay green) — only a found account with too little balance throws.getFirst()) — a fake-it/smallest-step to get to green before generalising to all players.GameService.createForTest(...) — signature shielding, and one null removedcreateForTest(Shuffler) now passes new PlayerAccountRepository() instead of null.createForTest(GameMonitor, Shuffler) so tests stop calling the raw 4-arg constructor.GameServiceTest.placeBetsForPlayerAccountWithInsufficientBalanceThrowsExceptionwithNextId(74) + PlayerId.of(9) → withNextId(111) + PlayerId.of(111).assertThatIllegalStateException() → assertThatExceptionOfType(InsufficientBalance.class). We let the meaningful domain exception win instead of forcing a generic one.GameServiceTest.placeBetsReducesPlayerAccountBalance — un-@Disabled and implementedvar repo = PlayerAccountRepository.withNextId(7);
var account = PlayerAccount.register("enough money");
account.deposit(30);
repo.save(account); // account seeded via the aggregate
...
gameService.placePlayerBets(List.of(new PlayerBet(PlayerId.of(7), Bet.of(13))));
assertThat(repo.find(PlayerId.of(7))).get()
.extracting(PlayerAccount::balance).isEqualTo(30 - 13); // 17
Replaced the fail("Start here") placeholder with a real behaviour assertion.
MultiPlayerGameMonitorTest — moved onto the shieldnew GameService(spy, DUMMY_GAME_REPOSITORY, new StubShuffler(), null) → GameService.createForTest(gameMonitorSpy, new StubShuffler()).PlayerAccount is an event-sourced aggregate:
register / deposit / bet don't mutate fields — they enqueue events (MoneyDeposited, MoneyBet, PlayerWonGame).apply(event) folds each event into balance (+= amount, -= amount, …).PlayerAccountRepository.save() appends the fresh events; find() replays them (reconstitute) to rebuild the account.So "placing a bet" = look up the account, append a MoneyBet event (which bet() refuses if the balance is too low), and save — then a later find() sees the reduced balance.
null when changing a signature. Replace it with a real cheap object (new PlayerAccountRepository()) or a no-op dummy (game -> {}), or shield it.createForTest(...) creation method so a constructor change is one edit, not many. (The tests still on the raw constructor were exactly the ones that broke earlier — proof the shield works.)getFirst() before looping all bets.InsufficientBalance where it expected IllegalStateException. The "unexpected" exception was the more correct one — so we asserted it, rather than dumbing the code down to a generic exception.register → deposit → save.null is a hidden landmine. It's harmless until the first code path uses the dependency, then it's an NPE at every construction site. Shield or supply a real/dummy value instead.In one sentence: route tests through a single creation method or builder instead of calling a production constructor/method directly, so that when its signature changes you fix it in one place rather than across every test.
The live example is createForTest:
public static GameService createForTest(Shuffler shuffler) {
return new GameService(game -> {}, game -> {}, shuffler, new PlayerAccountRepository());
}
GameService.createForTest(new StubShuffler()) and never touch the raw 4-arg constructor. So when the constructor grew a PlayerAccountRepository parameter, only createForTest changed — the ~13 tests through it kept compiling.new GameService(dummyGameMonitor, repositorySpy, new StubShuffler(), null) directly were exactly the ones that broke and had to be edited by hand. Shielded tests survived; unshielded ones didn't.game -> {}) for the monitor the test doesn't care about — keeping tests about behaviour, not construction plumbing.Goal to aim for: when the ensemble adds a parameter, one edit to the creation method, zero edits to the tests using it.
null vs a real-or-dummy valueNever introduce null when you change a signature. A null argument is a hidden landmine — harmless until the first code path actually uses the dependency, then it's an NPE at every call site. Supply the cheapest honest value instead:
new PlayerAccountRepository().GameMonitor game -> {}.Both are honest and safe; null is neither.
A step is safe when tests are green before and after it. A step is small when a red bar names its own culprit, because you only changed one thing. Together: never be more than one git revert away from a working system.
The cautionary tale was ours — implementing placePlayerBets in one leap turned ~20 tests red at once (a mix of NPEs and "no account" errors) and we had to reverse-engineer which change caused which failure. The same destination in small steps stays green the whole way.
Named moves to reach for:
createForTest overloads are exactly this.PlayerAccountRepository into a port.For large refactors, the Mikado Method plans it: attempt the goal naively, and when it breaks don't fix forward — note the prerequisites, revert to green, do those first (recursively), and execute the resulting dependency graph leaves-first. To drill the habit, TCR (test && commit || revert) is the most brutal teacher — you can't stay red, so steps stay tiny.
Parallel change and signature shielding are two instances of duplicate → migrate → converge. You stand up the new form beside the old (that coexistence is the duplication), migrate callers one by one staying green, then delete the old form so the duplication disappears.
This is not the "bad" DRY-violating duplication — it's intentional, temporary, green-keeping. Duplication is a smell at rest but a technique in motion; the distinction is whether it's on its way out. The one discipline that makes it safe: you must take the contract step — a shield or overload you introduce and then leave stops being a scaffold and becomes real debt.
PlayerAccount combines two DDD ideas:
balance directly; they call bet() / deposit().deposited 10, bet 5), not the current state, and derive state by replaying them. Events are the source of truth; state is a fold over them.Commands don't mutate state — they emit events:
public void bet(int amount) {
if (balance < amount) throw new InsufficientBalance(...);
enqueue(new MoneyBet(amount)); // record the fact
}
apply(event) is the only place state changes — it folds each event into balance (MoneyDeposited → += amount, MoneyBet → -= amount, PlayerWonGame → += payout).reconstitute(playerId, events) rebuilds an aggregate by replaying its whole event list.save() appends the fresh events, find() reads and replays them.One-liner: an event-sourced aggregate treats state as a left-fold over an append-only list of events — bet()/deposit() append facts, apply() folds them into the balance, and reconstitute() replays the whole list to rebuild the object. This is also why we seed test state through the aggregate (register → deposit → save) — you set up state by supplying past events, not by poking fields.
placePlayerBets only handles the first bet — generalise to loop all bets (multi-player).GameService: the commented-out if (playerAccountRepository != null) and the new createForTest(GameMonitor, Shuffler) factory that still passes null internally (contract step — remove the stepping-stone null).execute() game-over path still has // repository.save(...) comments and a PlayerWonGame event waiting to be used).