Monolith, modular monolith, microservices: chọn ranh giới trước khi tách process Trung cấp
Thời gian đọc ước tính: 9 phút
Vấn đề
Phần tiêu đề “Vấn đề”Ứng dụng bán hàng có Orders, Payments, Notifications. Sau vài tháng, mỗi lần sửa thanh toán lại phải deploy cả ứng dụng. Đội đề xuất tách ba microservice “cho code gọn”.
Nhưng Payments vẫn cần đọc đơn, cập nhật trạng thái đơn và gửi thông báo. Tách process mà giữ nguyên lời gọi chéo và database chung thì hệ thống vẫn dính nhau, thêm timeout, retry và phối hợp deploy.
Câu hỏi đầu tiên không phải “mấy service” mà là ranh giới trách nhiệm và dữ liệu nằm ở đâu. Có hai trục độc lập: mức tách biệt trong code và mức tách biệt khi deploy.
Ba kiểu kiến trúc trong một bảng
Phần tiêu đề “Ba kiểu kiến trúc trong một bảng”| Monolith thường | Modular monolith | Microservices | |
|---|---|---|---|
| Đơn vị deploy | Một | Một | Nhiều, độc lập |
| Ranh giới code | Không ép, import tự do | Module có API công khai, truy cập chéo bị cấm | Ranh giới là network |
| Dữ liệu | Một schema, ai cũng đọc ghi | Mỗi module sở hữu bảng của mình (cùng DB) | Mỗi service sở hữu store của mình |
| Gọi nhau | Hàm trực tiếp | Hàm qua API module | HTTP, gRPC hoặc message |
| Transaction chéo | Dễ (một DB) | Dễ nếu cùng DB, nhưng nên hạn chế | Phải dùng workflow, outbox, bù trừ |
| Lỗi một phần | Không | Không có lỗi mạng giữa module | Luôn phải tính |
| Ai ép ranh giới | Không ai | Tooling và review | Hạ tầng (network, deploy riêng) |
| Chi phí vận hành | Thấp | Thấp | Cao: observability phân tán, auth giữa service, versioning API |
| Hợp khi | Prototype, đội rất nhỏ, domain chưa rõ | Đa số sản phẩm đang lớn; domain đang học | Nhiều đội độc lập, ranh giới ổn định, nhu cầu scale/isolation cụ thể |
Bốn khái niệm cần phân biệt: monolith (deploy một khối), modular monolith (một khối nhưng có ranh giới thật), microservices (nhiều khối deploy độc lập), và bounded context (ranh giới của một model nghiệp vụ, xem bài riêng).
Bounded context là ứng viên để chia module hoặc service, không phải thuật toán tự sinh service cho mỗi danh từ.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Đang tải diagram…
Luồng “trả đơn 42”:
- Payments hỏi Orders qua public API: đơn này có thể trả tiền không?
- Nếu hợp lệ, Payments tạo giao dịch với provider.
- Có kết quả đáng tin cậy, Payments yêu cầu Orders chuyển trạng thái. Orders tự kiểm tra transition.
- Payments phát event hoặc outbox cho Notifications.
Sơ đồ không biến việc cập nhật hai module thành một transaction tự động: cần thiết kế transaction và failure window cụ thể. Khi cần phát event cùng commit, xem transactional outbox.
Từ modular monolith sang microservices, mỗi đường nối trong process thành lời gọi qua mạng. getPayableOrder() từng chỉ thành công hoặc throw; sau khi tách nó có thêm:
- Timeout và retry.
- Version contract.
- Trạng thái “request đã xử lý nhưng response mất” (tám ngộ nhận về hệ phân tán).
Không thể giữ nguyên code, thay lời gọi hàm bằng HTTP và giữ cùng bảo đảm.
Tín hiệu đo được để quyết định tách
Phần tiêu đề “Tín hiệu đo được để quyết định tách”Đừng quyết định bằng cảm giác “service này đang to”. Theo dõi các tín hiệu sau, đặt ngưỡng theo đội của bạn:
| Tín hiệu | Cách đo | Nói lên điều gì |
|---|---|---|
| Co-change: commit nào thường sửa nhiều module cùng lúc | git log theo thư mục, đếm cặp module hay đổi chung |
Ranh giới sai; tách process lúc này chỉ chuyển coupling lên mạng |
| Import chéo vào internals | Báo cáo từ dependency-cruiser hoặc lint rule | Module chưa thật sự đóng |
| Bảng bị nhiều module ghi | Rà soát query, hoặc phân quyền DB theo module | Dữ liệu chưa có chủ sở hữu |
| Chờ nhau khi deploy | Tỷ lệ release bị chặn bởi thay đổi của đội khác | Có động cơ autonomy thật |
| Tải lệch có đo | Metric CPU/memory/QPS theo module | Có động cơ scale độc lập thật |
| Yêu cầu cô lập | Compliance, SLA, blast radius cho một phần | Có động cơ isolation thật |
| Chatty calls | Số lời gọi đồng bộ giữa hai module trong một request | Nếu cao, hai phần này nên ở cùng service |
Quy tắc dùng bảng: nếu hai dòng đầu đỏ, sửa ranh giới trước. Chỉ khi các tín hiệu động cơ (chờ deploy, tải lệch, cô lập) thật sự có số, mới cân nhắc tách.
Microsoft Learn cũng khuyên: khi không chắc, bắt đầu với service thô; tách một service thành hai dễ hơn nhiều so với gộp lại.
Fowler đặt điều kiện vận hành trước khi dùng microservices (Microservice Prerequisites): provisioning nhanh, monitoring cơ bản, deploy nhanh qua pipeline. Thiếu ba thứ này thì chưa nên tách.
Về đội: theo Conway’s law, kiến trúc có xu hướng phản chiếu cơ cấu đội. Hãy căn chỉnh chủ động: mỗi module hoặc service có một đội sở hữu rõ ràng.
Ép ranh giới module bằng tooling
Phần tiêu đề “Ép ranh giới module bằng tooling”Ranh giới trong code không được runtime bảo vệ. Cần kiểm tra tự động trong CI:
| Công cụ | Dùng để | Ghi chú |
|---|---|---|
| dependency-cruiser | Khai báo luật forbidden (ví dụ module A không được import internals của module B, cấm vòng phụ thuộc) và chạy depcruise trong CI |
Độc lập với monorepo tool |
Nx @nx/enforce-module-boundaries |
ESLint rule: mỗi project chỉ lộ API qua index.ts, import sâu vào project khác bị lỗi; ràng buộc theo tag |
Cần repo dùng Nx |
| Test kiến trúc tự viết | Test đọc import và khẳng định “payments không import orders/internal” | Linh hoạt nhưng bạn tự bảo trì |
Quy ước thêm: mỗi module chỉ ghi bảng của mình; truy cập bảng của module khác đi qua API hoặc view có chủ.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Chạy demo quyền sở hữu dữ liệu
Phần tiêu đề “Chạy demo quyền sở hữu dữ liệu”npx --prefix examples ts-node examples/src/modular-monolith/modules-demo.tsOutput thật:
paid 100{ id: 42, amount: 100, paid: true }OrdersModule giữ Map private và trả bản copy read-only. PaymentsModule chỉ nhận hai method công khai qua TypeScript Pick. private và Pick chỉ là contract lúc compile, không phải security boundary; vì vậy cần các công cụ ở bảng trên.
API tốt hơn là orders.recordPaymentResult(orderId, paymentId) để Orders tự kiểm tra transition, thay vì để Payments sửa cờ paid.
Khi nào chọn gì
Phần tiêu đề “Khi nào chọn gì”- Monolith thường: prototype, vài người, domain chưa rõ. Chấp nhận phải dọn ranh giới sau.
- Modular monolith: mặc định cho đa số sản phẩm đang lớn. Theo Fowler (Monolith First), đa số câu chuyện microservices thành công đều bắt đầu từ monolith; điều kiện là monolith được thiết kế có module ở cả API lẫn dữ liệu.
- Microservices: khi tín hiệu động cơ có số đo, ranh giới ổn định và đội có đủ prerequisites vận hành.
Trade-offs
Phần tiêu đề “Trade-offs”Giữ modular monolith trước
- Gọi trong process và transaction chung giảm chi phí network, retry, điều phối dữ liệu.
- Ranh giới nghiệp vụ có thể điều chỉnh khi domain chưa ổn định.
- Ít thành phần triển khai, đội nhỏ tập trung vào sản phẩm.
Điểm phải chấp nhận
- Cùng deployment làm thời gian build/release và blast radius tăng khi ứng dụng lớn.
- Ranh giới cần kỷ luật và tooling; không có network để ép độc lập.
- Scale riêng một module hoặc dùng stack khác khó hơn service đã tách.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Checklist trước khi tách một module thành service
Phần tiêu đề “Checklist trước khi tách một module thành service”- Co-change giữa module này và các module khác thấp; nếu cao, sửa ranh giới trước.
- Không còn import chéo vào internals; CI đã ép ranh giới (dependency-cruiser, Nx hoặc test kiến trúc).
- Module chỉ ghi bảng của mình; module khác truy cập qua API hoặc view có chủ.
- Có ít nhất một động cơ đo được bằng số: release bị chặn vì đội khác, tải lệch theo module, hoặc yêu cầu cô lập (compliance, SLA, blast radius).
- Số lời gọi đồng bộ với module khác trong một request thấp; chatty calls cao thì giữ chung service.
- Đội đã có prerequisites vận hành: provisioning nhanh, monitoring cơ bản, deploy nhanh qua pipeline.
- Mỗi lời gọi sắp thành lời gọi mạng đã có thiết kế cho timeout, retry, version contract và kết quả mơ hồ; cập nhật chéo cần outbox hoặc workflow.
Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra:
- Nếu Payments đọc thẳng bảng Orders, điều gì hỏng khi tách service?
- Co-change cao giữa hai module nói lên điều gì về ranh giới?
- Cần đo gì trước khi viện lý do “scale độc lập”?