Skip to main content

Lộ trình xây dựng ứng dụng ngân hàng

Một dự án học tập xây ứng dụng ngân hàng với React + Spring Boot, đi từ monolith đến microservices qua 3 giai đoạn. Ngân hàng là đề tài tốt để luyện những thứ khó nhất của backend: giao dịch tiền phải đúng tuyệt đối, bảo mật nhiều lớp, và nhất quán dữ liệu khi hệ thống bị tách nhỏ.

Lộ trình 3 giai đoạn​

Giai đoạn 1: Lõi monolithGiai đoạn 2: Tính năng & bảo mậtGiai đoạn 3: Microservices
Kiến trúcMonolith chia moduleMonolith chia moduleMicroservices
Chức năngĐăng ký, đăng nhập, OTP, chuyển tiền nội bộ, lịch sử giao dịchKYC đơn giản, báo cáo tài chính, quản lý tài khoảnChuyển tiền liên ngân hàng (giả lập), thông báo bất đồng bộ
Bảo mậtBăm mật khẩu, JWT, HTTPSChống CSRF/XSS, mã hóa dữ liệu (AES), CSPXác thực giữa các service
Dữ liệuGiao dịch ACIDDữ liệu giả để kiểm thửRetry, transactional outbox, saga; event sourcing (tùy chọn)
DevOpsDocker, Docker ComposeCI/CD với GitHub Actions, môi trường stagingLogging và monitoring tập trung (ELK, Prometheus)
Học đượcTích hợp React với Spring Boot, giao dịch ACID, DockerBảo mật web, CI/CD, sinh dữ liệu giảThiết kế microservices, message queue, giám sát hệ phân tán

Monolith chia module ở giai đoạn 1–2 giúp tập trung vào nghiệp vụ trước; ranh giới module rõ ràng giúp tách service ở giai đoạn 3 dễ hơn. Khi nào nên tách và cái giá phải trả: xem Kiến trúc Microservices.

Công nghệ đề xuất​

Thành phầnLựa chọnLý do
BackendSpring BootHệ sinh thái mạnh cho nghiệp vụ tài chính: Spring Security, @Transactional, validation
FrontendReactSPA tương tác nhanh; tự escape nội dung, giảm nguy cơ XSS
Cơ sở dữ liệuPostgreSQLGiao dịch ACID, khóa dòng, ràng buộc (CHECK balance >= 0, khóa ngoại) — đúng thứ sổ cái cần
ContainerDocker, Docker ComposeMôi trường giống nhau từ máy dev đến production
Message queueRabbitMQ hoặc KafkaGiao tiếp bất đồng bộ, tách service, chạy tác vụ nền như gửi thông báo
note

Bản gốc đề xuất MongoDB. MongoDB có hỗ trợ transaction nhiều document, nhưng với sổ cái — nơi cần ràng buộc chặt và truy vấn tổng hợp — cơ sở dữ liệu quan hệ là lựa chọn mặc định an toàn hơn.

Chức năng cốt lõi​

Chức năngBackend (Spring Boot)Frontend (React)
Đăng ký, đăng nhập, OTPAPI đăng ký/đăng nhập, băm mật khẩu, tạo và kiểm tra OTP, cấp JWTForm đăng ký, đăng nhập, nhập OTP; quản lý trạng thái đăng nhập
Tài khoản & lịch sửAPI thông tin tài khoản; lịch sử giao dịch có phân trang, lọcDanh sách và chi tiết tài khoản; bảng lịch sử lọc, sắp xếp được
Chuyển tiền & báo cáoAPI chuyển tiền trong một giao dịch ACID; API tổng hợp dữ liệu cho báo cáoForm chuyển tiền có bước xác nhận; báo cáo dạng biểu đồ, bảng

OTP nên có hạn ngắn (vài phút), dùng một lần, giới hạn số lần nhập sai, và chỉ lưu bản băm trên server.

Bảo mật​

LớpBiện pháp
Xác thực & phân quyềnJWT cho API không trạng thái, lưu trong cookie HttpOnly, Secure, SameSite; OAuth2 khi cần đăng nhập qua bên thứ ba
Mật khẩuKhông bao giờ lưu dạng thô; băm bằng Argon2id hoặc bcrypt (đã có salt riêng cho từng user)
Mã hóa khi truyềnTLS/HTTPS cho mọi kết nối
Mã hóa khi lưuMã hóa dữ liệu nhạy cảm (PII) bằng AES-256-GCM; khóa nằm ở secret manager/KMS, không nằm trong code hay cùng database
CSRFBắt buộc khi JWT ở trong cookie: CSRF token của Spring Security + cookie SameSite
XSSValidate và làm sạch input, dựa vào cơ chế escape của React (tránh dangerouslySetInnerHTML), bật Content Security Policy

Giao dịch và nhất quán dữ liệu​

Trong một database, chuyển tiền là tất cả hoặc không gì cả: ghi nợ A và ghi có B cùng commit, lỗi ở bất kỳ bước nào thì rollback toàn bộ.

TransferService.java
@Transactional
public Transfer transfer(UUID fromId, UUID toId, BigDecimal amount, String idempotencyKey) {
// Request lặp lại (mạng chập chờn, người dùng bấm 2 lần) trả kết quả cũ
var existing = transfers.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) return existing.get();

// Khóa 2 dòng theo thứ tự id cố định để tránh deadlock
var first = fromId.compareTo(toId) < 0 ? fromId : toId;
var second = first.equals(fromId) ? toId : fromId;
accounts.findByIdForUpdate(first); // SELECT ... FOR UPDATE
accounts.findByIdForUpdate(second);

var from = accounts.getReferenceById(fromId);
var to = accounts.getReferenceById(toId);
if (from.getBalance().compareTo(amount) < 0) throw new InsufficientFundsException();

from.debit(amount);
to.credit(amount);
return transfers.save(new Transfer(fromId, toId, amount, idempotencyKey));
}
  • Tiền dùng BigDecimal (hoặc số nguyên đơn vị nhỏ nhất), không dùng double.
  • idempotencyKey có ràng buộc UNIQUE trong database, để hai request trùng không tạo hai giao dịch.
  • Tốt hơn nữa: sổ cái kép (double-entry) — mỗi giao dịch ghi một dòng nợ và một dòng có, số dư là tổng các dòng.

DevOps và kiểm thử​

Hạng mụcCông cụMục đích
CI/CDGitHub ActionsTự động build, test, đóng gói và triển khai
MonitoringPrometheus + GrafanaCPU, bộ nhớ, độ trễ request, tỉ lệ lỗi
LoggingELK stack hoặc LokiLog tập trung để chẩn đoán sự cố
SandboxMôi trường staging riêngThử tính năng mới mà không ảnh hưởng dev hay production
Giả lập APIWireMock, Postman mock serverMô phỏng API bên thứ ba (cổng thanh toán, ngân hàng khác)
Dữ liệu giảDatafaker (Java), Faker.jsDữ liệu thực tế, phủ nhiều kịch bản kiểm thử

Khi triển khai lên VPS: xem Thiết lập máy chủ Ubuntu.