Technical Challenges & Lessons Learned
Collected from real problems met while building Phase 1. Each item: symptom → root cause → fix → lesson.
1. A "silently wrong" bug: JPA calls merge() instead of persist()
- Symptom:
Transactionwas saved, but its childLedgerEntryrows were not saved correctly. No exception. - Root cause:
Transaction.idis assigned in the constructor (in the DDD spirit — an aggregate creates itself in a valid state). Spring Data JPA sees a non-nullID, assumes the entity already exists and callsmerge(), so thePERSISTcascade does not fire. - Fix:
TransactionimplementsPersistable<UUID>and managesisNew()with a@Transientflag reset in@PostLoad/@PostPersist. - Lesson: understanding persist vs. merge matters more than knowing annotation syntax. The most dangerous bugs are the ones that throw nothing, so tests must check the actual stored data. Details: ADR-008.
2. A second database exposed a domain-model bug
- Symptom: tests passed on Postgres but failed on Oracle when comparing amounts.
- Root cause: Oracle
NUMBERdoes not preserve a fixed scale on read (PostgresNUMERICdoes).Moneynever normalized its scale, so it had only been correct by luck. - Fix: tried following ISO 4217 (VND = 0 decimal places), but that would break existing data; chose a fixed
scale = 2in theMoneyconstructor for every currency. - Lesson: portability across databases is a useful test in itself. The bug was not in Oracle but in a hidden assumption in the code. Details: ADR-012.
3. A cache can defeat a lock
- Risk: if
withdraw()read the balance from Redis, the pessimistic lock onAccountwould be meaningless — a stale cached balance could allow an overdraft. - Fix: separate
getBalance()(cached) fromcomputeBalanceFromDb()(used only inwithdraw()); evict the cacheAFTER_COMMITrather than before commit. - Lesson: before adding an optimization layer, identify which path carries the invariant and keep that path out of the optimization. Details: ADR-010.
4. A bug hidden by the test configuration itself
- Symptom: the
KafkaTemplatebean was never created, yet every test stayed green for a while. - Root cause:
spring-kafkaalone is not enough; Spring Boot 4 needsspring-boot-starter-kafka. The problem was hidden becauseOutboxEventPublisherwas always disabled in tests (outbox.publisher.enabled=false). - Fix: add the starter; write an end-to-end test against real Kafka via Testcontainers, no mocks.
- Lesson: every feature flag disabled in tests is a blind spot. At least one end-to-end test must run with the production configuration. Details: ADR-009.
5. application-test.properties overrides instead of merging
- Root cause: a same-named test configuration file replaced the whole main DB/Flyway configuration instead of merging with it.
- Fix: declare test properties inline with
@SpringBootTest(properties = ...).
6. Proving a distributed lock needs two layers of evidence
- Challenge: "1 success, 4 rejected" alone is not enough — Kubernetes might just have routed every request to the same Pod.
- Fix: log the Pod name (
HOSTNAME) inAccountControllerand usekubectl logs --prefixto confirm requests were spread across all 3 Pods. - Lesson: an experiment is only worth something once alternative explanations are ruled out. Details: ADR-013.
7. Networking inside containers
localhostinside a container is the container itself. Kafka needed a second listener (PLAINTEXT_HOSTviahost.docker.internal:9094) so Pods could connect, while keeping9092for tests running on the host.
8. Environment setup
Seven initial setup issues (Java version, corrupted Maven cache, Docker port clashes, Flyway on Spring Boot 4, a Postgres volume keeping an old password, the Asia/Saigon timezone...) are fully recorded in Devlog — Environment setup. Common thread: Spring Boot 4 and Testcontainers 2.x renamed many modules/artifacts, so release notes had to be read instead of relying on older guides.
Known limitations
OutboxEventPublisherhas no distributed lock: with several Pods, the 3 PoC consumers may log duplicates (Audit/Compliance is unaffected because it is idempotent).Moneywith a fixedscale = 2must be revisited for currencies such as JPY (0) or BHD (3).- Maintaining two migration sets (Postgres/Oracle): every schema change has to be written twice.