Giai đoạn 1 — Core Banking (Sub-project B)
Trạng thái: [Hoàn thành]
Xem mục tiêu đầy đủ của giai đoạn này tại Lộ trình — Giai đoạn 1.
Công việc đã hoàn thành
- Thiết lập môi trường dev ban đầu (Java, Maven, Docker Postgres, Flyway) — chi tiết ở mục Thiết lập môi trường bên dưới.
- Build Maven thành công (BUILD SUCCESS).
- Flyway migration chạy thành công, tạo bảng
accountsvàledger_entries. - Entity layer:
Account,LedgerEntrymap đúng schema migration V1 — chi tiết ở mục Entity layer đã hoàn thành. LedgerService.recordTransaction()— validate cân bằng Nợ/Có trước khi lưu, chạy atomic trong 1 transaction.- Unit test
LedgerServiceTest(3 test case) — BUILD SUCCESS, Tests run: 3, Failures: 0, Errors: 0. AccountService.getBalance()— tính số dư động từ ledger thay vì lưu trực tiếp — chi tiết ở mục AccountService — tính balance từ ledger bên dưới.- Unit test
AccountServiceTest(1 test case) — BUILD SUCCESS, Tests run: 4, Failures: 0, Errors: 0. AccountService.withdraw()— Pessimistic Locking (SELECT ... FOR UPDATE) ngăn race condition khi rút tiền đồng thời — chi tiết ở mục Pessimistic Locking — ngăn race condition khi rút tiền bên dưới.- Unit test race condition
withdraw_shouldPreventOverdraft_whenConcurrentRequests— BUILD SUCCESS, Tests run: 5, Failures: 0, Errors: 0. - (2026-09-16) DDD Refactor —
Moneyvalue object,Transactionaggregate root, và ADR-008 — chi tiết ở mục DDD Refactor bên dưới. - (2026-09-17) Outbox Pattern + Kafka event publishing —
Transactionphát domain event, 4 consumer độc lập (Audit/Compliance, Fraud Detection, Notification, Reporting), và ADR-009 — chi tiết ở mục Outbox Pattern & Kafka Event Publishing bên dưới. - (2026-09-17) Redis cache cho
AccountService.getBalance(), invalidation qua AFTER_COMMIT, và ADR-010 — chi tiết ở mục Redis Cache cho Account Balance bên dưới. - (2026-09-17) Oracle dual-profile support (45/45 test pass trên cả 2 database) và fix
Moneyscale cố định — ADR-011, ADR-012 — chi tiết ở mục Oracle Dual-Profile & Fix Money Scale bên dưới. - (2026-09-17) REST API + Kubernetes deployment, verify Pessimistic Locking qua 3 Pod thật — ADR-013 — chi tiết ở mục Kubernetes Deployment bên dưới.
Thiết lập môi trường
Log lại theo đúng thứ tự thời gian các vấn đề gặp phải khi setup môi trường dev lần đầu cho project.
1. Java version mismatch
- Vấn đề:
pom.xmldo Spring Initializr sinh ra mặc định yêu cầu Java 21, nhưng máy dev chỉ có sẵn JDK 17. - Nguyên nhân: chọn sai target Java version lúc khởi tạo project trên Spring Initializr.
- Xử lý: hạ
java.versiontrongpom.xmlxuống17.
2. Maven local cache hỏng
- Vấn đề: build lỗi
zip file is emptyvới fileobjenesis-3.3.jartrong~/.m2/repository. - Nguyên nhân: artifact bị tải dở/hỏng trong cache local của Maven từ lần trước.
- Xử lý: xóa thư mục cache của package
objenesistrong~/.m2/repository, để Maven tải lại từ remote repository.
3. Thiếu cấu hình DataSource
- Vấn đề: sau khi qua lỗi Java version, Spring Boot báo lỗi khởi động
Failed to determine a suitable driver class. - Nguyên nhân:
application.propertieschưa có cấu hình datasource nên Spring Boot không xác định được driver JDBC cần dùng. - Xử lý: thêm
spring.datasource.url,spring.datasource.username,spring.datasource.passwordtrỏ tới PostgreSQL.
4. Xung đột port Docker
- Vấn đề: container Postgres của project không start được / xung đột port.
- Nguyên nhân: máy dev đã có sẵn một container Postgres khác (dùng cho công việc, chiếm port 5432 mặc định), đồng thời Windows cũng có PostgreSQL 17 cài dạng Service — cả hai cùng tranh chấp port 5432/5433.
- Xử lý: đổi port container Postgres của project sang port riêng 5434 trong
docker-compose.yml.
5. Breaking change của Spring Boot 4.x với Flyway
- Vấn đề: có
flyway-coretrên classpath nhưng Flyway không tự chạy migration khi khởi động app. - Nguyên nhân: Spring Boot 4.x đã tách auto-configuration của Flyway ra một module riêng; chỉ có
flyway-coretrên classpath không còn đủ để kích hoạt tự động như các bản Spring Boot trước. - Xử lý: đổi dependency từ
flyway-coresangspring-boot-starter-flyway.
6. Password authentication failed do volume Docker cũ
- Vấn đề: sau khi đổi port ở bước 4, Postgres vẫn báo
password authentication faileddù password khai báo trongdocker-compose.ymlđã đúng. - Nguyên nhân: volume Docker cũ đã được khởi tạo (init) với password từ lần chạy trước đó; Postgres chỉ áp dụng biến môi trường password khi khởi tạo volume lần đầu, không áp dụng lại cho volume đã tồn tại.
- Xử lý: chạy
docker compose down -vđể xóa volume cũ, khởi tạo lại container sạch từ đầu.
7. Lỗi timezone "Asia/Saigon" không hợp lệ
- Vấn đề: Postgres báo lỗi khởi động kết nối
FATAL: invalid value for parameter "TimeZone": "Asia/Saigon". - Nguyên nhân: JDBC driver tự gửi tên timezone của hệ thống Windows (tên legacy
Asia/Saigon) lên server, trong khi bản tzdata đóng gói trong imagepostgres:16không nhận diện tên này. - Xử lý: hạ image Postgres xuống
postgres:15, có bản tzdata nhận diện được tênAsia/Saigon.
Kết quả
Sau khi xử lý đủ 7 vấn đề trên: build Maven thành công (BUILD SUCCESS), Flyway migration chạy và tạo thành công bảng accounts, ledger_entries.
Entity layer đã hoàn thành
Accountentity (packageaccount/):id(UUID),accountNumber,accountType,currency,status,createdAt— map đúng bảngaccountstrong migration V1.LedgerEntryentity (packageledger/):id,accountId,transactionId,entryType(enumDEBIT/CREDIT),amount(BigDecimal),createdAt— map đúng bảngledger_entries.- Dùng enum
EntryTypethay vìStringthô choentryType— validate ở compile-time, khớp với CHECK constraint trong migration SQL.@Enumerated(EnumType.STRING)đảm bảo lưu tên chữ, không lưu index số. - Dùng
BigDecimalchoamount, không dùngdouble/float— tránh sai số làm tròn số thực trong hệ thống tài chính.
LedgerService — logic validate cân bằng Nợ/Có
- Method
recordTransaction(List<LedgerEntry>)— validate tổng Debit = tổng Credit trước khi lưu, throwIllegalStateExceptionnếu lệch. @Transactionalđảm bảo toàn bộ danh sáchLedgerEntryđược lưu atomic (tất cả hoặc không gì cả).transactionIdđược sinh trong service (không nhận từ ngoài) — đảm bảo mọi entry trong 1 lần gọi thuộc cùng 1 giao dịch logic.- Dùng
BigDecimal.compareTo()thay vìequals()khi so sánh tổng Nợ/Có —equals()coi100.0và100.00là khác nhau do khác scale,compareTo()so sánh đúng giá trị số học.
Unit test và các lỗi phát hiện qua test
Viết LedgerServiceTest với 2 test case: giao dịch cân bằng phải thành công, giao dịch lệch phải throw exception. Trong quá trình chạy test phát hiện 2 lỗi, cả hai đều do dữ liệu test chưa hợp lệ, không phải lỗi logic nghiệp vụ:
- Foreign key constraint violation: test ban đầu dùng UUID ngẫu nhiên làm
accountIdmà không tạoAccountthật trước — Postgres từ chối vìledger_entries.account_idcó ràng buộcREFERENCES accounts(id). Đây là bằng chứng cho thấy ràng buộc toàn vẹn dữ liệu ở tầng database hoạt động đúng như thiết kế. Sửa bằng cách thêm helper method tạoAccountthật trong test trước khi testLedgerEntry. - Value too long for varchar(20): sau khi sửa lỗi 1, helper tạo
accountNumberbằng cách nối UUID đầy đủ (36 ký tự) trong khi cộtaccount_numbergiới hạnVARCHAR(20). Sửa bằng cách rút gọn UUID xuống 8 ký tự đầu khi tạoaccountNumbercho test.
Kết quả cuối: Tests run: 3, Failures: 0, Errors: 0 — BUILD SUCCESS.
AccountService — tính balance từ ledger
- Thêm 2 query vào
LedgerEntryRepository:sumDebitByAccountId,sumCreditByAccountId— dùng JPQL vớiCOALESCE(SUM(...), 0)để tránhNULLkhi tài khoản chưa có giao dịch nào. AccountService.getBalance(accountId)= Tổng Credit - Tổng Debit, đúng quy tắc kế toán cho tài khoản khách hàng (nhóm Nợ phải trả): tăng ghi Có, giảm ghi Nợ.- Viết
AccountServiceTest: tạo 2 account, ghi 1 giao dịch quaLedgerService(100.00 CREDIT cho account, 100.00 DEBIT cho counterparty), xác nhậngetBalance()trả về đúng 100.00. - Kết quả:
Tests run: 4, Failures: 0, Errors: 0— BUILD SUCCESS. Toàn bộ nền tảng Account/Ledger/Balance của Giai đoạn 1 đã hoạt động đúng. - Đã viết ADR ghi lại quyết định thiết kế: ledger bất biến, balance tính động thay vì lưu trực tiếp (xem ADR-006).
Pessimistic Locking — ngăn race condition khi rút tiền
- Đọc trước: Optimistic vs Pessimistic Locking trong Spring Data JPA.
- Thêm
AccountRepository.findByIdForUpdate()dùng@Lock(LockModeType.PESSIMISTIC_WRITE)— sinh SQLSELECT ... FOR UPDATE(Postgres cụ thể hiển thịFOR NO KEY UPDATE). - Thêm
AccountService.withdraw(accountId, counterpartyAccountId, amount): khóa account, kiểm tra đủ số dư, ghi cặpLedgerEntry(DEBIT cho account, CREDIT cho counterparty) quaLedgerService. - Viết test
withdraw_shouldPreventOverdraft_whenConcurrentRequests: giả lập 2 thread cùng rút 80.00 từ tài khoản có 100.00, dùngExecutorService+CountDownLatch+AtomicIntegerđể đếm số giao dịch thành công. Xác nhận đúng 1 trong 2 thread thành công. - Kết quả:
Tests run: 5, Failures: 0, Errors: 0— BUILD SUCCESS. Log Hibernate xác nhận cơ chế khóa hoạt động đúng (2 thread được tuần tự hóa, không chạy song song trên cùng account). - Đã viết ADR-007 ghi lại lý do chọn Pessimistic thay vì Optimistic Locking cho bài toán này (xem docs/adr/).
DDD Refactor — Money value object và Transaction aggregate root
(2026-09-16) Refactor tầng domain theo hướng DDD, qua 3 bước liên tiếp trong ngày.
Bước 1 — Money value object
- Thêm
Money(packageshared/money) — immutable, bọcBigDecimal amount+Currency;add()/subtract()throwCurrencyMismatchExceptionnếu 2Moneykhác loại tiền tệ. LedgerEntry.amountvàAccount.currencychuyển từBigDecimal/Stringthô sang dùngMoney/Currency.LedgerService.validateBalanced()giờ cộng tổng quaMoney— một giao dịch trộn lẫn nhiều loại tiền tệ giữa các entry sẽ fail ngay (doCurrencyMismatchException) thay vì cộng sai lặng lẽ.- Migration
V3__add_currency_to_ledger_entries.sqlthêm cộtledger_entries.currency, backfill từaccounts.currency. - Test:
MoneyTest(85 dòng), cập nhậtAccountServiceTest,LedgerServiceTest.
Bước 2 — Transaction aggregate root
- Thêm entity
Transaction(packageledger/) làm Aggregate Root cho nhómLedgerEntry:Transaction.record(entries, createdBy)validate cân bằng Nợ=Có ngay trong hàm dựng;Transaction.reverse(reversedBy)tạo giao dịch hoàn tác đối xứng.Transaction.idtự sinh (UUID.randomUUID()) ngay trong constructor thay vì để DB sinh qua@GeneratedValue, đúng nguyên tắc DDD — Aggregate tự chịu trách nhiệm tạo ra chính nó ở trạng thái hợp lệ. LedgerServiceđược rút gọn, không còn tự validate cân bằng — trách nhiệm đó chuyển hẳn vàoTransaction.- Migration
V4__create_transactions_table.sqltạo bảngtransactions, backfill từ dữ liệuledger_entriescũ. - Bug phát hiện khi debug: sau khi implement,
Transactionlưu được vào DB nhưng cascade sangLedgerEntrykhông chạy — sinh ra bản ghi rỗng, không đúng dữ liệu, không có exception nào được throw (lỗi "âm thầm sai"). Nguyên nhân: Spring Data JPA mặc định coi một entity là "đã tồn tại trong DB" nếu ID của nó khácnullkhisave()được gọi — vìTransaction.idđược gán thủ công trong constructor, JPA hiểu nhầm đây là update (gọimerge()) thay vì insert mới (persist()), khiến cascadePERSISTkhông kích hoạt đúng. - Fix:
TransactionimplementPersistable<UUID>, tự định nghĩaisNew()(qua trường@Transient boolean isNew, reset vềfalseở@PostLoad/@PostPersist) để báo cho JPA biết chính xác đây là entity mới, bất kể ID đã có giá trị hay chưa. Sau fix: 21/21 test pass, cascade hoạt động đúng. - Test:
TransactionTest(86 dòng). - Quyết định thiết kế đầy đủ (phương án cân nhắc A/B, lý do chọn, 2 vấn đề kỹ thuật gặp phải — lỗi
MIN(uuid)trên Postgres vàPersistable<UUID>pitfall) đã ghi lại tại ADR-008.
Bước 3 — Bổ sung test coverage cho lỗi phát hiện được
- Trong lúc build
Transactionaggregate, phát hiện bug:createdBykhông được set đúng trên một số đường đi — lỗi này trước đó chỉ bị chặn bởi ràng buộcNOT NULLcủa DB ở tầng integration test, không có assertion nào ở tầng unit test bắt được sớm hơn. - Thêm assertion tường minh cho
createdBytrên cả 2 happy path (Transaction.record()vàTransaction.reverse()) trongTransactionTest, để một regression tương tự sau này fail ngay ở unit test, không cần chờ tới DB.
Outbox Pattern & Kafka Event Publishing
(2026-09-17) Cần thông báo cho các hệ thống khác khi có Transaction mới hoặc bị hoàn tác, phục vụ 4 mục đích nghiệp vụ: Audit/Compliance, Notification, Fraud Detection, Reporting. Triển khai qua 3 bước.
Bước 1 — Outbox Pattern
Transaction(aggregate root) tự raise domain event (TransactionPostedEvent,TransactionReversedEvent) trongrecord()/reverse(), exposepullDomainEvents()— nhất quán với nguyên tắc DDD đã áp dụng ở ADR-008.LedgerServicepull events sau khisave()thành công, map sangOutboxEvent, lưu vào bảngoutbox_eventstrong cùng transaction DB vớiTransaction/LedgerEntry— tránh rủi ro Dual Write (ghi DB thành công nhưng gửi Kafka thất bại, hoặc ngược lại).OutboxEventPublisher(@Scheduled, mặc định 5s/lần) đọc các event chưa publish, gửi lên topictransaction-posted-topic(key =transactionIdđể giữ thứ tự), rồi đánh dấupublished_at.- Payload JSON self-contained:
eventId(riêng biệt vớitransactionId, dùng cho idempotency),transactionId,createdBy,occurredAt,entries[],eventTypenhúng trực tiếp vào payload (không dùng Kafka header). - Migration
V5__create_outbox_events_table.sql.
Bước 2 — Audit Compliance Consumer (idempotent)
AuditComplianceConsumerlắng nghetransaction-posted-topic(groupIdaudit-compliance-group), ghi vào bảngcompliance_records— 1 dòng cho mỗiLedgerEntry(không gộp theo transaction), vì compliance/audit ngân hàng thật cần biết account cụ thể nào liên quan (debit/credit), không chỉ tổng giá trị giao dịch.- Idempotent qua bảng
processed_events(khóa theoeventId) — bắt buộc vì Kafka chỉ đảm bảo at-least-once delivery, message có thể bị gửi lại. - Migration
V6__create_processed_events_table.sql,V7__create_compliance_records_table.sql. - Test:
AuditComplianceConsumerIntegrationTest,TransactionToComplianceEndToEndTest(end-to-end thật, không mock Kafka).
Bước 3 — 3 PoC Consumer
FraudDetectionConsumer,NotificationConsumer,ReportingConsumer— mỗi consumer 1groupIdriêng (fraud-detection-group,notification-group,reporting-group) trên cùng topic, chỉ log để chứng minh kiến trúc publish/subscribe hoạt động đúng cho nhiều consumer độc lập — không có logic nghiệp vụ thật (không có rule phát hiện gian lận/gửi thông báo/tạo báo cáo thật).
Vấn đề kỹ thuật gặp phải
- Jackson 3, không phải Jackson 2. Spring Boot 4.1.1 dùng
tools.jackson.*— phải dùngJacksonJsonSerializerthay vìJsonSerializer, thêm dependencyspring-boot-starter-jsonriêng. - Bẫy nghiêm trọng: Spring Boot chỉ nạp 1 file
application.propertiesđầu tiên tìm thấy, không merge main + test — nếu tạo fileapplication-test.propertiesriêng, nó ghi đè toàn bộ cấu hình DB/Flyway thay vì bổ sung. Fix bằng@SpringBootTest(properties = ...)inline trong annotation. - Phát hiện quan trọng nhất: raw
spring-kafkakhông đủ để Spring Boot 4 tự động cấu hình beanKafkaTemplate— cần thêmspring-boot-starter-kafka. Lỗi bị che giấu suốt Bước 1 vìOutboxEventPublisherluôn bị tắt trong test (outbox.publisher.enabled=false), chỉ lộ ra khi viết end-to-end test thật không mock. - Testcontainers 2.x đổi tên artifact
testcontainers-kafka(khác tiền tố so với 1.x) — gây lỗi "version is missing" khó hiểu lúc đầu.
Quyết định thiết kế đầy đủ (Context, Options Considered A/B, Decision, Consequences kèm 4 vấn đề kỹ thuật trên) đã ghi lại tại ADR-009.
Redis Cache cho Account Balance
(2026-09-17) AccountService.getBalance() tính động qua SUM() toàn bộ LedgerEntry mỗi lần gọi — không hiệu quả khi account có nhiều giao dịch. Thêm Redis cache-aside, qua 4 bước.
Bước 1 — Setup Redis
- Thêm service
redis(redis:7-alpine) vàodocker-compose.yml. Port 6379 bị 1 container Redis khác trên máy chiếm dụng → đổi host port sang6380(tương tự lý do trước đó Postgres phải đổi sang 5434). - Thêm dependency
spring-boot-starter-data-redis. Cấu hìnhspring.data.redis.host/portvàapp.cache.balance.ttl-seconds(mặc định 3600) trongapplication.properties.
Bước 2 — Cache-aside
AccountBalanceCache(dùngStringRedisTemplate) — chỉ cache số tiền dạng chuỗi (BigDecimal.toPlainString()), không cachecurrencyvì đọc thẳng từAccount, không đổi theo thời gian.AccountService.getBalance()đọc cache trước, miss thì tính từ DB rồi ghi lại cache (cache-aside).- Không dùng cache trong
withdraw(): tách riêng methodprivate computeBalanceFromDb()tính thẳng từ DB, dùng nội bộ cho bước kiểm tra đủ số dư trongwithdraw()— vì bước này nằm trong pessimistic lock, đọc cache ở đây có thể trả về balance cũ và làm mất tác dụng của Pessimistic Locking (xem ADR-007).
Bước 3 — AFTER_COMMIT invalidation
AccountBalanceCacheEvictionListenerlắng ngheTransactionPostedEvent/TransactionReversedEvent— cùng domain event đã dùng cho Outbox/Kafka ở ADR-009 (LedgerServicepublish event này vừa làm nội dung Outbox, vừa làm SpringApplicationEventnội bộ), không tạo event class mới.- Dùng
@TransactionalEventListener(phase = AFTER_COMMIT)thay vì evict trực tiếp trong cùng@Transactional— nếu evict trước khi commit, một request đọc đồng thời có thể query DB (vẫn thấy balance cũ vì transaction ghi chưa commit) rồi ghi đè lại balance cũ vào cache ngay sau evict, kẹt lại đến hết TTL. Evict ở AFTER_COMMIT thu hẹp đáng kể cửa sổ race này.
Bước 4 — Test
AccountBalanceCacheTest(unit, 78 dòng),AccountServiceCacheTest(unit, 95 dòng),AccountBalanceCacheIntegrationTest(144 dòng, Redis thật qua Testcontainers).- Testcontainers 2.x không có module
testcontainers-redisriêng (khác Kafka cótestcontainers-kafkariêng) → dùngGenericContainervới imageredis:7-alpinetrực tiếp. - Test regression + concurrency test cũ của
withdraw()vẫn pass — xác nhận cache không ảnh hưởng invariant chống overdraft. Integration test riêng xác nhận balance không stale sau giao dịch mới.
Quyết định thiết kế đầy đủ (Context, 2 Options Considered, Decision, Consequences kèm 2 vấn đề kỹ thuật trên) đã ghi lại tại ADR-010.
Oracle Dual-Profile & Fix Money Scale
(2026-09-17) Cần chứng minh khả năng làm việc với hệ quản trị CSDL doanh nghiệp (Oracle phổ biến trong ngân hàng Việt Nam) mà không ảnh hưởng đến 45 test đang pass trên PostgreSQL. Triển khai qua 4 bước, phát sinh thêm 1 bug thật cần fix riêng.
Bước 1 — Setup Oracle
- Thêm service
oracle(gvenzl/oracle-free:23-slim-faststart) vàodocker-compose.yml, host port 1522 (container port 1521 — cùng lý do đổi port như Postgres/Redis trước đó, tránh xung đột port mặc định trên máy dev). Container Oracle khởi động chậm hơn đáng kể so với Postgres/Kafka/Redis dù đã dùng bảnslim-faststart. - Thêm dependency
ojdbc11(driver JDBC),flyway-database-oracle. application-oracle.properties(profileoracle, kích hoạt qua--spring.profiles.active=oracle) — override datasource/driver, trỏspring.flyway.locations=classpath:db/migration/oracle. Chạy song song với profile mặc định (Postgres), không thay thế.- Viết
README.mdmới, ghi lại port mapping, cách chạy app/test theo từng profile, cấu trúc migration.
Bước 2 — Tách migration theo vendor & viết lại 7 file cho Oracle
- Tách
db/migration/thànhdb/migration/postgresql/(V1-V7 cũ, chuyển nguyên vẹn) vàdb/migration/oracle/(V1-V7 viết lại theo dialect Oracle). - 4 điểm khác biệt dialect chính:
- UUID →
RAW(16)cho mọi cột id/khóa ngoại. UPDATE ... FROM(cú pháp riêng Postgres) → correlated subquery ở V3, →MERGE INTOở V4 (backfillreversal_of_transaction_id).JSONB→JSON(kiểu JSON native của Oracle 23ai).- Partial index (
CREATE INDEX ... WHERE published_at IS NULL) → function-based index tương đương (CASE WHEN published_at IS NULL THEN created_at END), vì Oracle không hỗ trợ partial index.
- UUID →
- Phát hiện phụ:
columnDefinition = "jsonb"khai báo trênOutboxEvent.javakhông cần sửa cho Oracle, vìddl-auto=validatekhông dùngcolumnDefinitionđể validate thực tế —@JdbcTypeCode(SqlTypes.JSON)tự thích ứng theo dialect đang chạy.
Bước 3 — Chạy test trên cả 2 profile, phát hiện bug Money
- Chạy 45 test hiện có trên Oracle (
./mvnw test -Dspring.profiles.active=oracle) — phát hiện OracleNUMBERkhông giữ scale cố định lúc đọc (khác PostgresNUMERICgiữ nguyên scale khai báo, hành vi chuẩn của Oracle, không phải bug CSDL). - Thử fix bằng
Currency.getDefaultFractionDigits()(chuẩn ISO 4217) thì phát hiện vấn đề sâu hơn: VND theo ISO 4217 có 0 chữ số thập phân, nhưng toàn bộ domain model project (từ Audit Trail đến Redis) đã ngầm định VND có 2 chữ số thập phân xuyên suốt — dùng đúng chuẩn ISO 4217 sẽ throw exception cho các giá trị VND có phần thập phân đã tồn tại trong test/dữ liệu từ 4 tính năng trước. - Quyết định: cố định
scale = 2cho mọi currency thay vì theo ISO 4217 tuyệt đối. SửaMoney.java: constructor riêng enforcesetScale(2, RoundingMode.UNNECESSARY), mọi đường tạoMoney(of,zero,add,subtract) đều đi qua constructor này nên đều được chuẩn hóa tự động. - Quyết định thiết kế đầy đủ (2 Options Considered, lý do chọn, rủi ro đã ghi nhận cho multi-currency thật sau này) đã ghi lại tại ADR-012.
Bước 4 — Kết quả
- 45/45 test pass trên cả 2 database (Postgres và Oracle), không cần sửa test theo profile — khác biệt CSDL xử lý hoàn toàn ở tầng cấu hình/migration, không rò lên tầng test.
- Quyết định thiết kế Oracle dual-profile đầy đủ (Options Considered, 5 vấn đề kỹ thuật khi viết lại migration) đã ghi lại tại ADR-011.
Kubernetes Deployment
(2026-09-17) Cần chứng minh khả năng triển khai trên Kubernetes, đặc biệt verify Pessimistic Locking hoạt động đúng khi nhiều instance ứng dụng chạy song song. Triển khai qua 4 bước.
Bước 1 — REST Controller
- Trước đó codebase không có endpoint HTTP nào —
AccountServicechỉ được gọi trực tiếp trong cùng JVM qua test. Không có cách nào verify Pessimistic Locking qua nhiều Pod thật (nhiều JVM/process riêng biệt) nếu không có cổng vào HTTP. - Thêm
AccountController:POST /accounts,GET /accounts/{id}/balance,POST /accounts/{id}/deposit,POST /accounts/{id}/withdraw. ThêmAccountService.deposit()(ghi cặp CREDIT/DEBIT quaLedgerService, đối xứng vớiwithdraw()nhưng không cần lock vì không kiểm tra invariant số dư). - Log request/response trong
withdraw()gắn tên Pod (đọc biến môi trườngHOSTNAME— K8s tự set bằng tên Pod) — chuẩn bị bằng chứng cho bước verify ở Bước 4. - Thêm
spring-boot-starter-actuator, bậtmanagement.health.livenessstate/readinessstatecho probe K8s.
Bước 2 — Dockerfile
- Multi-stage build: stage build dùng image Maven chính thức pin version (không dùng
./mvnwvìmaven-wrapper.jarbị gitignore, tránh phụ thuộc file không có sẵn khi build từ git clone sạch), stage runtime chỉeclipse-temurin:17-jre-alpine. - Fix Kafka advertised listener:
localhostchỉ đúng khi app chạy trên host, sai khi chạy trong container (bên trong container,localhostlà chính nó). Thêm listener thứ 2 (PLAINTEXT_HOST, advertise quahost.docker.internal, port 9094) chạy song song với listener cũ (PLAINTEXT, port 9092, vẫn dùng cho test suite chạy trực tiếp trên host).
Bước 3 — Deploy 1 replica
- Viết
k8s/namespace.yaml,k8s/configmap.yaml(trỏhost.docker.internalcho Postgres/Kafka/Redis vẫn chạy ngoài cluster qua docker-compose),k8s/secret.yaml(DB credentials, tách khỏi ConfigMap),k8s/deployment.yaml,k8s/service.yaml(NodePort, truy cập thẳng qualocalhost:30080từ máy host trên Docker Desktop K8s, không cầnkubectl port-forward). - Dùng Docker Desktop Kubernetes (không phải minikube/kind) — dùng chung Docker engine/image cache với
docker build,imagePullPolicy: Never. startupProbe/livenessProbe/readinessProbetrỏ/actuator/health/*—startupProbecho app đủ thời gian kết nối DB/Kafka/Redis trước khi liveness bắt đầu tính, tránh bị kill oan lúc đang khởi động.
Bước 4 — Scale 3 replicas & verify Pessimistic Locking
- Scale
replicas: 3, bắn 5 requestwithdrawđồng thời (đủ tiền cho đúng 1 request thành công) qua Service. Verify 2 lớp bằng chứng tách biệt:- Kết quả nghiệp vụ đúng: 1 request thành công, 4 bị từ chối HTTP 409, balance cuối cùng chính xác, không âm, không trừ lặp.
- Request thực sự phân tán qua cả 3 Pod khác nhau — xác nhận qua
kubectl logs --prefix, log gắn tên Pod cụ thể, bao gồm trường hợp 1 Pod nhận 2 request cùng lúc và tự serialize đúng. Bằng chứng cho thấy cơ chế lock nằm ở DB (độc lập với Pod gọi vào), không phải do K8s tình cờ route hết về 1 chỗ.
- Giới hạn đã biết:
OutboxEventPublisherkhông có lock phân tán khi nhiều Pod — 3 consumer PoC có thể log trùng nếu nhiều Pod cùng đọc trúng 1OutboxEventchưa publish;AuditComplianceConsumerkhông bị ảnh hưởng vì đã idempotent. - Quyết định thiết kế đầy đủ (Options Considered, 2 vấn đề kỹ thuật, chi tiết verify thực nghiệm) đã ghi lại tại ADR-013.
Khó khăn & giải pháp
Các vấn đề kỹ thuật thực tế ở từng bước đã ghi ngay trong từng mục phía trên. Bản tổng hợp theo dạng triệu chứng → nguyên nhân → xử lý → bài học: Core Banking — Thử thách & Bài học rút ra.
Khái niệm học được
- Double-entry bookkeeping — tài khoản tiền gửi khách hàng là Nợ phải trả của ngân hàng: tăng ghi Có, giảm ghi Nợ; tổng Nợ luôn bằng tổng Có.
- Check-then-act vs lost update — Optimistic Locking (
@Version) chỉ bắt được lost update trên chính entity; race condition kiểu "kiểm tra rồi hành động" cần Pessimistic Lock. - Aggregate root & invariant — invariant nghiệp vụ đặt trong aggregate, service chỉ orchestrate.
- JPA persist vs merge — entity có ID gán sẵn cần
Persistable.isNew()để được insert đúng. - Dual Write & Outbox Pattern — ghi DB và gửi message không thể nguyên tử nếu làm riêng; Outbox biến nó thành một transaction DB.
- At-least-once delivery & idempotent consumer — message có thể đến nhiều lần, consumer phải tự loại trùng.
- Cache-aside & invalidation sau commit — evict trước commit mở ra cửa sổ race ghi lại dữ liệu cũ vào cache.
- Scale của
BigDecimal—equals()so sánh cả scale,compareTo()chỉ so giá trị; mỗi DB giữ scale khác nhau.