Bỏ qua để đến nội dung
Knowledge Hub

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

Ứ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.

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ừ.

Đang tải diagram…

Luồng “trả đơn 42”:

  1. Payments hỏi Orders qua public API: đơn này có thể trả tiền không?
  2. Nếu hợp lệ, Payments tạo giao dịch với provider.
  3. 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.
  4. 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:

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.

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ủ.

Terminal window
npx --prefix examples ts-node examples/src/modular-monolith/modules-demo.ts

Output 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.

  • 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.

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.

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”
  1. 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.
  2. 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).
  3. Module chỉ ghi bảng của mình; module khác truy cập qua API hoặc view có chủ.
  4. 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).
  5. 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.
  6. Đội đã có prerequisites vận hành: provisioning nhanh, monitoring cơ bản, deploy nhanh qua pipeline.
  7. 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ự kiểm tra:

  1. Nếu Payments đọc thẳng bảng Orders, điều gì hỏng khi tách service?
  2. Co-change cao giữa hai module nói lên điều gì về ranh giới?
  3. Cần đo gì trước khi viện lý do “scale độc lập”?