Phase 1 — Core Banking (Sub-project B)
Status: [Done]
Full goals for this phase: Roadmap — Phase 1.
Completed work
- Initial dev environment (Java, Maven, Docker Postgres, Flyway) — see Environment setup below.
- Maven build succeeds (BUILD SUCCESS).
- Flyway migrations run successfully, creating the
accountsandledger_entriestables. - Entity layer:
Account,LedgerEntrymapped to the V1 migration schema — see Entity layer. LedgerService.recordTransaction()— validates Debit/Credit balance before saving, atomically in one transaction.- Unit test
LedgerServiceTest(3 test cases) — BUILD SUCCESS, Tests run: 3, Failures: 0, Errors: 0. AccountService.getBalance()— balance derived from the ledger instead of stored — see AccountService: deriving the balance from the ledger.- Unit test
AccountServiceTest(1 test case) — BUILD SUCCESS, Tests run: 4, Failures: 0, Errors: 0. AccountService.withdraw()— pessimistic locking (SELECT ... FOR UPDATE) prevents races on concurrent withdrawals — see Pessimistic locking for withdrawals.- Race condition unit test
withdraw_shouldPreventOverdraft_whenConcurrentRequests— BUILD SUCCESS, Tests run: 5, Failures: 0, Errors: 0. - (2026-09-16) DDD refactor —
Moneyvalue object,Transactionaggregate root, and ADR-008 — see DDD refactor. - (2026-09-17) Outbox Pattern + Kafka event publishing —
Transactionraises domain events, 4 independent consumers (Audit/Compliance, Fraud Detection, Notification, Reporting), and ADR-009 — see Outbox Pattern & Kafka Event Publishing. - (2026-09-17) Redis cache for
AccountService.getBalance(), invalidation via AFTER_COMMIT, and ADR-010 — see Redis cache for account balance. - (2026-09-17) Oracle dual-profile support (45/45 tests passing on both databases) and the fixed
Moneyscale — ADR-011, ADR-012 — see Oracle dual profile & Money scale fix. - (2026-09-17) REST API + Kubernetes deployment, pessimistic locking verified across 3 real Pods — ADR-013 — see Kubernetes deployment.
Environment setup
The problems met while setting up the dev environment for the first time, in chronological order.
1. Java version mismatch
- Problem: the
pom.xmlgenerated by Spring Initializr required Java 21 by default, but the dev machine only had JDK 17. - Cause: the wrong target Java version was selected when creating the project on Spring Initializr.
- Fix: lowered
java.versioninpom.xmlto17.
2. Corrupted local Maven cache
- Problem: the build failed with
zip file is emptyforobjenesis-3.3.jarin~/.m2/repository. - Cause: the artifact had been partially downloaded/corrupted in the local Maven cache in an earlier run.
- Fix: deleted the
objenesiscache folder in~/.m2/repositoryso Maven would download it again.
3. Missing DataSource configuration
- Problem: after fixing the Java version, Spring Boot failed to start with
Failed to determine a suitable driver class. - Cause:
application.propertieshad no datasource configuration, so Spring Boot could not tell which JDBC driver to use. - Fix: added
spring.datasource.url,spring.datasource.username,spring.datasource.passwordpointing to PostgreSQL.
4. Docker port conflict
- Problem: the project's Postgres container would not start / port conflict.
- Cause: the dev machine already had another Postgres container (used for work, taking the default port 5432), and Windows also had PostgreSQL 17 installed as a service — both competing for ports 5432/5433.
- Fix: moved the project's Postgres container to its own port 5434 in
docker-compose.yml.
5. Spring Boot 4.x breaking change with Flyway
- Problem:
flyway-corewas on the classpath but Flyway did not run migrations on startup. - Cause: Spring Boot 4.x moved Flyway auto-configuration into a separate module;
flyway-coreon the classpath is no longer enough to trigger it as in earlier Spring Boot versions. - Fix: replaced
flyway-corewithspring-boot-starter-flyway.
6. Password authentication failed because of an old Docker volume
- Problem: after the port change in step 4, Postgres still reported
password authentication failedeven though the password indocker-compose.ymlwas correct. - Cause: the old Docker volume had been initialized with the password from a previous run; Postgres only applies the password environment variable when the volume is first initialized, not for an existing volume.
- Fix: ran
docker compose down -vto delete the old volume and start a clean container.
7. Invalid "Asia/Saigon" timezone
- Problem: Postgres rejected the connection with
FATAL: invalid value for parameter "TimeZone": "Asia/Saigon". - Cause: the JDBC driver sends the Windows system timezone name (the legacy
Asia/Saigon) to the server, and the tzdata bundled in thepostgres:16image does not recognize that name. - Fix: downgraded the Postgres image to
postgres:15, whose tzdata recognizesAsia/Saigon.
Result
After fixing all 7 problems: the Maven build succeeded (BUILD SUCCESS), and Flyway migrations ran and created the accounts and ledger_entries tables.
Entity layer
Accountentity (packageaccount/):id(UUID),accountNumber,accountType,currency,status,createdAt— mapped to theaccountstable from migration V1.LedgerEntryentity (packageledger/):id,accountId,transactionId,entryType(enumDEBIT/CREDIT),amount(BigDecimal),createdAt— mapped to theledger_entriestable.- An
EntryTypeenum instead of a rawStringforentryType— validated at compile time, matching the CHECK constraint in the migration SQL.@Enumerated(EnumType.STRING)stores the name, not the ordinal. BigDecimalforamount, notdouble/float— avoids floating-point rounding errors in a financial system.
LedgerService: Debit/Credit balance validation
recordTransaction(List<LedgerEntry>)validates total Debit = total Credit before saving and throwsIllegalStateExceptionif they differ.@Transactionalensures the whole list ofLedgerEntryis saved atomically (all or nothing).transactionIdis generated inside the service (not accepted from outside) — so every entry in one call belongs to the same logical transaction.BigDecimal.compareTo()instead ofequals()when comparing Debit/Credit totals —equals()treats100.0and100.00as different because of scale,compareTo()compares the numeric value.
Unit tests and the bugs they found
LedgerServiceTest has 2 test cases: a balanced transaction must succeed, an unbalanced one must throw. Running them surfaced 2 errors, both caused by invalid test data rather than business logic:
- Foreign key constraint violation: the first version used random UUIDs as
accountIdwithout creating a realAccountfirst — Postgres rejected it becauseledger_entries.account_idhasREFERENCES accounts(id). This shows the database-level integrity constraint works as designed. Fixed with a helper that creates a realAccountbefore testingLedgerEntry. - Value too long for varchar(20): after fixing error 1, the helper built
accountNumberfrom a full UUID (36 characters) whileaccount_numberis limited toVARCHAR(20). Fixed by truncating the UUID to its first 8 characters for test account numbers.
Final result: Tests run: 3, Failures: 0, Errors: 0 — BUILD SUCCESS.
AccountService: deriving the balance from the ledger
- Added 2 queries to
LedgerEntryRepository:sumDebitByAccountId,sumCreditByAccountId— JPQL withCOALESCE(SUM(...), 0)to avoidNULLfor accounts with no transactions. AccountService.getBalance(accountId)= total Credit - total Debit, the correct accounting rule for customer accounts (liabilities): increases are credits, decreases are debits.AccountServiceTest: creates 2 accounts, posts one transaction throughLedgerService(100.00 CREDIT to the account, 100.00 DEBIT to the counterparty), and checks thatgetBalance()returns exactly 100.00.- Result:
Tests run: 4, Failures: 0, Errors: 0— BUILD SUCCESS. The whole Account/Ledger/Balance foundation of Phase 1 works correctly. - Wrote an ADR for the design decision: immutable ledger, balance derived rather than stored (see ADR-006).
Pessimistic locking for withdrawals
- Read first: optimistic vs. pessimistic locking in Spring Data JPA.
- Added
AccountRepository.findByIdForUpdate()with@Lock(LockModeType.PESSIMISTIC_WRITE)— generatesSELECT ... FOR UPDATE(Postgres specifically showsFOR NO KEY UPDATE). - Added
AccountService.withdraw(accountId, counterpartyAccountId, amount): locks the account, checks for sufficient funds, posts aLedgerEntrypair (DEBIT the account, CREDIT the counterparty) throughLedgerService. - Test
withdraw_shouldPreventOverdraft_whenConcurrentRequests: 2 threads withdraw 80.00 at the same time from an account holding 100.00, usingExecutorService+CountDownLatch+AtomicIntegerto count successes. Confirms exactly 1 of the 2 threads succeeds. - Result:
Tests run: 5, Failures: 0, Errors: 0— BUILD SUCCESS. Hibernate logs confirm the lock works (the 2 threads are serialized, never running in parallel on the same account). - Wrote ADR-007 recording why pessimistic rather than optimistic locking was chosen (see ADRs).
DDD refactor: Money value object and Transaction aggregate root
(2026-09-16) Refactored the domain layer towards DDD, in 3 consecutive steps on the same day.
Step 1 — Money value object
- Added
Money(packageshared/money) — immutable, wrapsBigDecimal amount+Currency;add()/subtract()throwCurrencyMismatchExceptionwhen the twoMoneyvalues have different currencies. LedgerEntry.amountandAccount.currencymoved from rawBigDecimal/StringtoMoney/Currency.LedgerService.validateBalanced()now sums throughMoney— a transaction mixing currencies across entries fails immediately (viaCurrencyMismatchException) instead of silently adding up wrong numbers.- Migration
V3__add_currency_to_ledger_entries.sqladdsledger_entries.currency, backfilled fromaccounts.currency. - Tests:
MoneyTest(85 lines), updatedAccountServiceTest,LedgerServiceTest.
Step 2 — Transaction aggregate root
- Added the
Transactionentity (packageledger/) as the aggregate root for a group ofLedgerEntry:Transaction.record(entries, createdBy)validates Debit = Credit in the constructor itself;Transaction.reverse(reversedBy)creates a symmetric reversal.Transaction.idis generated (UUID.randomUUID()) in the constructor rather than by the DB via@GeneratedValue, following the DDD principle that an aggregate is responsible for creating itself in a valid state. LedgerServicebecame thinner and no longer validates balance itself — that responsibility moved entirely intoTransaction.- Migration
V4__create_transactions_table.sqlcreates thetransactionstable, backfilled from existingledger_entries. - Bug found while debugging: after implementation,
Transactionwas saved, but the cascade toLedgerEntrydid not run — producing empty, incorrect records with no exception thrown (a "silently wrong" bug). Cause: Spring Data JPA assumes an entity already exists in the DB if its ID is non-nullwhensave()is called — sinceTransaction.idis assigned manually in the constructor, JPA treated it as an update (merge()) instead of a new insert (persist()), so thePERSISTcascade did not fire properly. - Fix:
TransactionimplementsPersistable<UUID>and defines its ownisNew()(via a@Transient boolean isNewfield reset tofalsein@PostLoad/@PostPersist) to tell JPA exactly when the entity is new, whether or not the ID is set. After the fix: 21/21 tests pass and the cascade works. - Tests:
TransactionTest(86 lines). - The full design decision (options A/B, rationale, the 2 technical issues —
MIN(uuid)on Postgres and thePersistable<UUID>pitfall) is recorded in ADR-008.
Step 3 — Test coverage for the bug found
- While building the
Transactionaggregate, a bug was found:createdBywas not set correctly on some paths — previously only caught by the DB'sNOT NULLconstraint in integration tests, with no unit-test assertion catching it earlier. - Added explicit
createdByassertions on both happy paths (Transaction.record()andTransaction.reverse()) inTransactionTest, so a similar regression fails immediately in unit tests without waiting for the DB.
Outbox Pattern & Kafka Event Publishing
(2026-09-17) Other systems need to be notified when a Transaction is posted or reversed, for 4 business purposes: Audit/Compliance, Notification, Fraud Detection, Reporting. Implemented in 3 steps.
Step 1 — Outbox Pattern
Transaction(aggregate root) raises domain events (TransactionPostedEvent,TransactionReversedEvent) inrecord()/reverse()and exposespullDomainEvents()— consistent with the DDD principle applied in ADR-008.LedgerServicepulls the events after a successfulsave(), maps them toOutboxEvent, and stores them inoutbox_eventsin the same DB transaction asTransaction/LedgerEntry— avoiding the dual-write risk (DB write succeeds but the Kafka send fails, or vice versa).OutboxEventPublisher(@Scheduled, every 5s by default) reads unpublished events, sends them to thetransaction-posted-topictopic (key =transactionIdto keep ordering), then setspublished_at.- Self-contained JSON payload:
eventId(separate fromtransactionId, used for idempotency),transactionId,createdBy,occurredAt,entries[], andeventTypeembedded in the payload (not in a Kafka header). - Migration
V5__create_outbox_events_table.sql.
Step 2 — Audit Compliance Consumer (idempotent)
AuditComplianceConsumerlistens totransaction-posted-topic(groupIdaudit-compliance-group) and writes tocompliance_records— one row perLedgerEntry(not grouped by transaction), because real banking compliance/audit needs to know which specific accounts were involved (debit/credit), not only the transaction total.- Idempotent via the
processed_eventstable (keyed byeventId) — mandatory because Kafka only guarantees at-least-once delivery and messages may be redelivered. - Migrations
V6__create_processed_events_table.sql,V7__create_compliance_records_table.sql. - Tests:
AuditComplianceConsumerIntegrationTest,TransactionToComplianceEndToEndTest(real end-to-end, Kafka not mocked).
Step 3 — 3 PoC consumers
FraudDetectionConsumer,NotificationConsumer,ReportingConsumer— each with its owngroupId(fraud-detection-group,notification-group,reporting-group) on the same topic, only logging to prove the publish/subscribe architecture works for several independent consumers — no real business logic (no actual fraud rules, notifications or reports).
Technical issues encountered
- Jackson 3, not Jackson 2. Spring Boot 4.1.1 uses
tools.jackson.*—JacksonJsonSerializermust be used instead ofJsonSerializer, plus a separatespring-boot-starter-jsondependency. - Serious trap: Spring Boot loads only the first
application.propertiesit finds and does not merge main + test — a separateapplication-test.propertiesoverrides the whole DB/Flyway configuration instead of adding to it. Fixed with inline@SpringBootTest(properties = ...). - Most important finding: raw
spring-kafkais not enough for Spring Boot 4 to auto-configure theKafkaTemplatebean —spring-boot-starter-kafkais needed. The bug stayed hidden through Step 1 becauseOutboxEventPublisherwas always disabled in tests (outbox.publisher.enabled=false), and only surfaced with a real, unmocked end-to-end test. - Testcontainers 2.x renamed the artifact
testcontainers-kafka(different prefix than 1.x) — causing a confusing "version is missing" error at first.
The full design decision (Context, Options Considered A/B, Decision, Consequences with the 4 issues above) is recorded in ADR-009.
Redis cache for account balance
(2026-09-17) AccountService.getBalance() computes SUM() over every LedgerEntry on each call — inefficient for accounts with many transactions. Added a Redis cache-aside in 4 steps.
Step 1 — Redis setup
- Added a
redisservice (redis:7-alpine) todocker-compose.yml. Port 6379 was taken by another Redis container on the machine → host port changed to6380(same reason Postgres had moved to 5434). - Added the
spring-boot-starter-data-redisdependency. Configuredspring.data.redis.host/portandapp.cache.balance.ttl-seconds(default 3600) inapplication.properties.
Step 2 — Cache-aside
AccountBalanceCache(usingStringRedisTemplate) caches only the amount as a string (BigDecimal.toPlainString()), not thecurrency, which is read fromAccountand never changes.AccountService.getBalance()reads the cache first; on a miss it computes from the DB and writes the result back (cache-aside).- No cache in
withdraw(): a separateprivate computeBalanceFromDb()computes straight from the DB for the sufficient-funds check inwithdraw()— this step runs inside the pessimistic lock, and reading the cache there could return an old balance and defeat the lock (see ADR-007).
Step 3 — AFTER_COMMIT invalidation
AccountBalanceCacheEvictionListenerlistens toTransactionPostedEvent/TransactionReversedEvent— the same domain events used for Outbox/Kafka in ADR-009 (LedgerServicepublishes them both as Outbox content and as an in-process SpringApplicationEvent), no new event class.@TransactionalEventListener(phase = AFTER_COMMIT)instead of evicting directly in the same@Transactional— if the eviction happens before commit, a concurrent read may query the DB (still seeing the old balance because the write has not committed) and write the old balance back into the cache right after the eviction, where it stays until the TTL expires. Evicting AFTER_COMMIT narrows that race window considerably.
Step 4 — Tests
AccountBalanceCacheTest(unit, 78 lines),AccountServiceCacheTest(unit, 95 lines),AccountBalanceCacheIntegrationTest(144 lines, real Redis via Testcontainers).- Testcontainers 2.x has no separate
testcontainers-redismodule (unlike Kafka'stestcontainers-kafka) →GenericContainerwith theredis:7-alpineimage is used directly. - The existing
withdraw()regression and concurrency tests still pass — confirming the cache does not affect the overdraft invariant. A dedicated integration test confirms the balance is never stale after a new transaction.
The full design decision (Context, 2 options considered, Decision, Consequences with the 2 issues above) is recorded in ADR-010.
Oracle dual profile & Money scale fix
(2026-09-17) The goal was to prove the system works with an enterprise database (Oracle is common in Vietnamese banks) without affecting the 45 tests passing on PostgreSQL. Done in 4 steps, with 1 real bug that needed its own fix.
Step 1 — Oracle setup
- Added an
oracleservice (gvenzl/oracle-free:23-slim-faststart) todocker-compose.yml, host port 1522 (container port 1521 — same reason as the other port changes, avoiding default ports on the dev machine). The Oracle container starts noticeably slower than Postgres/Kafka/Redis even with theslim-faststartimage. - Added the
ojdbc11(JDBC driver) andflyway-database-oracledependencies. application-oracle.properties(profileoracle, enabled with--spring.profiles.active=oracle) overrides the datasource/driver and pointsspring.flyway.locations=classpath:db/migration/oracle. It runs alongside the default (Postgres) profile rather than replacing it.- Wrote a new
README.mddocumenting the port mapping, how to run the app/tests per profile, and the migration layout.
Step 2 — Split migrations by vendor & rewrite 7 files for Oracle
- Split
db/migration/intodb/migration/postgresql/(the existing V1-V7, moved unchanged) anddb/migration/oracle/(V1-V7 rewritten for the Oracle dialect). - 4 main dialect differences:
- UUID →
RAW(16)for every id/foreign key column. UPDATE ... FROM(Postgres-only syntax) → a correlated subquery in V3, →MERGE INTOin V4 (backfillingreversal_of_transaction_id).JSONB→JSON(Oracle 23ai's native JSON type).- Partial index (
CREATE INDEX ... WHERE published_at IS NULL) → an equivalent function-based index (CASE WHEN published_at IS NULL THEN created_at END), since Oracle does not support partial indexes.
- UUID →
- Side finding:
columnDefinition = "jsonb"onOutboxEvent.javadid not need changing for Oracle, becauseddl-auto=validatedoes not usecolumnDefinitionfor actual validation —@JdbcTypeCode(SqlTypes.JSON)adapts to the active dialect.
Step 3 — Running the tests on both profiles, finding the Money bug
- Running the existing 45 tests on Oracle (
./mvnw test -Dspring.profiles.active=oracle) showed that OracleNUMBERdoes not keep a fixed scale on read (unlike PostgresNUMERIC, which keeps the declared scale — standard Oracle behaviour, not a database bug). - Trying to fix it with
Currency.getDefaultFractionDigits()(ISO 4217) revealed a deeper problem: under ISO 4217 VND has 0 decimal places, but the whole domain model (from the Audit Trail to Redis) had implicitly assumed 2 decimal places for VND — following ISO 4217 strictly would throw for VND values with a fractional part that already existed in the tests/data of the 4 previous features. - Decision: fix
scale = 2for every currency instead of strict ISO 4217.Money.javachanged: a dedicated constructor enforcessetScale(2, RoundingMode.UNNECESSARY), and every way of creatingMoney(of,zero,add,subtract) goes through it, so all are normalized automatically. - The full design decision (2 options considered, rationale, recorded risk for real multi-currency later) is in ADR-012.
Step 4 — Result
- 45/45 tests pass on both databases (Postgres and Oracle), with no profile-specific test changes — the database differences are handled entirely in configuration/migrations and do not leak into the tests.
- The full Oracle dual-profile decision (options considered, 5 technical issues while rewriting the migrations) is in ADR-011.
Kubernetes deployment
(2026-09-17) The goal was to prove the system can be deployed on Kubernetes, and especially to verify pessimistic locking holds when several application instances run in parallel. Done in 4 steps.
Step 1 — REST controller
- The codebase previously had no HTTP endpoint —
AccountServicewas only called in-process from tests. There is no way to verify pessimistic locking across real Pods (several separate JVMs/processes) without an HTTP entry point. - Added
AccountController:POST /accounts,GET /accounts/{id}/balance,POST /accounts/{id}/deposit,POST /accounts/{id}/withdraw. AddedAccountService.deposit()(posts a CREDIT/DEBIT pair throughLedgerService, symmetric towithdraw()but without a lock since it checks no balance invariant). - Request/response logging in
withdraw()tagged with the Pod name (read from theHOSTNAMEenvironment variable — K8s sets it to the Pod name) — the evidence for Step 4. - Added
spring-boot-starter-actuatorand enabledmanagement.health.livenessstate/readinessstatefor K8s probes.
Step 2 — Dockerfile
- Multi-stage build: the build stage uses the official Maven image with a pinned version (not
./mvnw, becausemaven-wrapper.jaris gitignored — avoiding a dependency on a file missing from a clean git clone), the runtime stage is justeclipse-temurin:17-jre-alpine. - Kafka advertised listener fix:
localhostis only correct when the app runs on the host, not in a container (inside a container,localhostis the container itself). Added a second listener (PLAINTEXT_HOST, advertised viahost.docker.internal, port 9094) alongside the original (PLAINTEXT, port 9092, still used by the test suite running directly on the host).
Step 3 — Deploying 1 replica
- Wrote
k8s/namespace.yaml,k8s/configmap.yaml(pointing tohost.docker.internalfor Postgres/Kafka/Redis, which still run outside the cluster via docker-compose),k8s/secret.yaml(DB credentials, separate from the ConfigMap),k8s/deployment.yaml,k8s/service.yaml(NodePort, reachable directly atlocalhost:30080from the host on Docker Desktop K8s, nokubectl port-forwardneeded). - Docker Desktop Kubernetes (not minikube/kind) — shares the Docker engine/image cache with
docker build,imagePullPolicy: Never. startupProbe/livenessProbe/readinessProbepoint to/actuator/health/*— thestartupProbegives the app enough time to connect to DB/Kafka/Redis before liveness starts counting, so it is not killed while starting up.
Step 4 — Scaling to 3 replicas & verifying pessimistic locking
- Scaled to
replicas: 3and fired 5 concurrentwithdrawrequests (funds for exactly 1 to succeed) through the Service. Verified with two separate layers of evidence:- Correct business result: 1 request succeeded, 4 were rejected with HTTP 409, the final balance was exact, never negative, never double-debited.
- Requests really spread across all 3 different Pods — confirmed with
kubectl logs --prefix, logs tagged with specific Pod names, including a case where one Pod received 2 requests at once and serialized them correctly. Evidence that the lock lives in the DB (independent of which Pod calls it), not that K8s happened to route everything to one place.
- Known limitation:
OutboxEventPublisherhas no distributed lock with several Pods — the 3 PoC consumers may log duplicates if several Pods pick up the same unpublishedOutboxEvent;AuditComplianceConsumeris unaffected because it is idempotent. - The full design decision (options considered, 2 technical issues, experimental verification details) is in ADR-013.
Challenges & solutions
The real technical problems of each step are recorded in the sections above. A summary in symptom → cause → fix → lesson form: Core Banking — Challenges & Lessons Learned.
Concepts learned
- Double-entry bookkeeping — a customer deposit account is a liability of the bank: increases are credits, decreases are debits; total Debit always equals total Credit.
- Check-then-act vs. lost update — optimistic locking (
@Version) only catches lost updates on the entity itself; a "check, then act" race needs a pessimistic lock. - Aggregate root & invariants — business invariants live in the aggregate; services only orchestrate.
- JPA persist vs. merge — an entity with a pre-assigned ID needs
Persistable.isNew()to be inserted correctly. - Dual write & Outbox Pattern — writing to the DB and sending a message cannot be atomic when done separately; the Outbox turns it into one DB transaction.
- At-least-once delivery & idempotent consumers — a message may arrive several times, so consumers must deduplicate.
- Cache-aside & invalidation after commit — evicting before commit opens a race that writes stale data back into the cache.
BigDecimalscale —equals()compares scale too,compareTo()only compares value; different databases preserve scale differently.