ADR-008: Transaction Aggregate Root for the Debit=Credit Invariant
Context
- Before the refactor: the Debit=Credit invariant lived in
LedgerService.validateBalanced(); no entity/aggregate represented the concept of "one accounting transaction". - Problem: by DDD principles, invariants belong in an aggregate, not in a service.
Options Considered
Option A — Keep things as they are (no Transaction entity)
- Pros: few changes, light migration.
- Cons: the "transaction" concept stays implicit forever, no place for future transaction-level business rules, queries return a bare
List<LedgerEntry>with no meaning.
Option B — Create a Transaction aggregate root holding List<LedgerEntry>
- Pros: Debit=Credit becomes a natural property of the aggregate (the constructor cannot create an unbalanced
Transaction), reversals have a natural home (reversalOfTransactionId), queries return a meaningful object. - Cons: a real aggregate boundary change — new table, backfill migration,
LedgerServicereduced to thin orchestration, risk of over-engineering when no business operation needs Transaction-level handling yet (YAGNI).
Decision
Choose Option B — because "Debit=Credit" is an invariant, and by DDD principles invariants belong in the aggregate rather than in a service (services should only orchestrate). Trading time/complexity for an architecture that follows the principle, and accepting the over-engineering risk at this stage because this is a learning portfolio that prioritizes practicing the right patterns over development speed.
Consequences
Positive:
- The invariant is guaranteed automatically in the domain layer.
LedgerServicenow only orchestrates.- Reversals have a semantically correct place (
reversalOfTransactionId).
Negative/risks:
- More system complexity while no business feature really needs Transaction as an independent object yet.
Technical issues found during implementation:
- PostgreSQL has no
MIN()on the UUID type — UUIDs have no default ordering. The backfill migration (grouping oldledger_entriesbytransaction_idto pick a representative value per group) had to cast UUID to::textbeforeMIN(), then cast the result back with::uuid. - JPA pitfall —
Persistable<UUID>.Transaction.idis assigned in the constructor (no@GeneratedValue— per DDD, an aggregate creates itself in a valid state). Because the ID is not null whensave()is called, Spring Data JPA assumes the entity already exists in the DB and callsmerge()instead ofpersist(), so thePERSISTcascade toLedgerEntrydoes not fire properly — producing empty records instead of the right data. This is a "silently wrong" bug (no clear exception), only found by carefully checking the data after saving. Fix: implementPersistable<UUID>and defineisNew()to tell JPA exactly when the entity is new, whether or not the ID is already set.
Result after the fix: 21/21 tests pass, cascade works correctly. Lesson: understanding JPA's persist vs. merge mechanics matters much more than just knowing annotation syntax.