LEARNING RECORD · Blackjack Ensemble course

17 July 2026

Ensemble Session — Place Player Bets against PlayerAccount

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


Goal of the session

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.


What we changed (from the git diff, not the "mob next" messages)

1. 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);
}

2. GameService.createForTest(...) — signature shielding, and one null removed

3. GameServiceTest.placeBetsForPlayerAccountWithInsufficientBalanceThrowsException

4. GameServiceTest.placeBetsReducesPlayerAccountBalance — un-@Disabled and implemented

var 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.

5. MultiPlayerGameMonitorTest — moved onto the shield


The domain behaviour behind it

PlayerAccount is an event-sourced aggregate:

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.


Best practices we followed

Key lessons

  1. 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.
  2. A failing test with an unexpected-but-correct result is a design cue, not a mechanical fix — let the more expressive domain exception drive the code.
  3. Parallel change & signature shielding both use deliberate, temporary duplication as a stepping stone: expand (duplicate) → migrate callers one-by-one (stay green) → contract (delete the old). Safe only if you finish by removing the duplication.
  4. Break large refactors into small safe steps — green before and after each step, so a red bar names its own culprit. (See the Mikado Method / TCR for drilling this.)

Deep dive: the techniques in full

Signature shielding

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());
}

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 value

Never 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:

Both are honest and safe; null is neither.

Small safe steps

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:

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.

Duplication as a stepping stone

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.

Event-sourced aggregate (the domain machinery)

PlayerAccount combines two DDD ideas:

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
}

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 (registerdepositsave) — you set up state by supplying past events, not by poking fields.


Follow-ups / not yet done