Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 18 — Modular Monolith — Đường giữa

Architecture Decision-Making Bài 18/60

Mở đầu — vì sao bài này quan trọng

Ở hai bài trước, bạn đã đứng ở hai đầu của một cây cầu. Bài 16 cho bạn thấy monolith không hề "lỗi thời" như nhiều người tưởng, và trong rất nhiều bối cảnh nó là lựa chọn khôn ngoan nhất. Bài 17 lại phơi bày cái giá thực sự của microservices: độ phức tạp vận hành, latency mạng, distributed transaction, và một đội ngũ phải trưởng thành về DevOps mới gánh nổi.

Nhưng thực tế công việc của một kiến trúc sư ít khi trắng đen như vậy. Bạn sẽ liên tục gặp những hệ thống đang ở giữa: một monolith đã lớn đến mức các team giẫm chân nhau, deploy nào cũng hồi hộp, code coupling chằng chịt — nhưng khi bạn đề xuất tách microservices thì cả đội hoảng loạn vì không ai muốn ôm thêm mười cái service với hàng chục cái pipeline, message queue, và tracing distributed.

Đây chính là "vùng đất giữa" mà bài này nói tới. Modular Monolith — kiến trúc nguyên khối có mô-đun hóa rõ ràng — là câu trả lời cho một câu hỏi rất cụ thể: Làm sao có được sự tách bạch (isolation) và kỷ luật ranh giới của microservices, mà không phải trả cái giá vận hành của distributed system?

Nếu bạn nắm chắc bài này, bạn sẽ tránh được hai sai lầm đắt tiền nhất trong sự nghiệp kiến trúc: (1) để monolith mục ruỗng thành "big ball of mud" vì lười đặt ranh giới, và (2) nhảy sang microservices quá sớm khi team và bài toán chưa sẵn sàng. Modular Monolith là công cụ để bạn mua thời gian, giữ tốc độ, và giữ luôn quyền chọn tách service sau này khi thực sự cần.

Khái niệm cốt lõi

Định nghĩa: Modular Monolith là gì

Modular Monolith là một ứng dụng deploy như một khối duy nhất (một process, một artifact, thường một database), nhưng bên trong được chia thành các module có ranh giới rõ ràng, mỗi module đóng gói một domain nghiệp vụ, chỉ giao tiếp với module khác qua public interface (API nội bộ) đã được định nghĩa tường minh — không cho phép chọc thẳng vào ruột của nhau.

Điểm mấu chốt nằm ở chữ "ranh giới". Trong một monolith thông thường (không có kỷ luật), bất kỳ class nào cũng có thể gọi bất kỳ class nào khác. Module Order có thể SELECT thẳng vào bảng users, gọi trực tiếp repository của Payment, và tham chiếu vào lớp domain của Inventory. Đó là coupling ngầm, và nó là gốc rễ của "big ball of mud".

Modular Monolith đảo ngược điều này: mỗi module là một "hộp đen" đối với các module khác. Bạn dùng nó qua cửa trước, không leo qua cửa sổ.

Ba trục tách biệt cần phân biệt

Nhiều người nhầm rằng "chia thành microservices" là cách duy nhất để có isolation. Thực ra có ba trục tách biệt độc lập nhau:

  • Tách code (code isolation): Module này không được import trực tiếp lớp nội bộ của module kia. Chỉ được dùng qua interface public.
  • Tách dữ liệu (data isolation): Mỗi module "sở hữu" một tập bảng riêng (thường theo schema riêng trong cùng một database). Module khác muốn dữ liệu thì gọi API của module chủ, không JOIN thẳng vào bảng.
  • Tách triển khai (deployment isolation): Mỗi thành phần chạy trong process riêng, scale riêng, deploy riêng.
Microservices ép bạn phải làm cả ba cùng lúc. Modular Monolith cho bạn làm hai cái đầu (tách code, tách dữ liệu ở mức logic) mà không cần cái thứ ba (vẫn deploy chung một khối). Đây chính là lý do nó "rẻ" hơn nhiều: bạn có được kỷ luật ranh giới mà không phải trả giá cho network, cho distributed transaction, cho eventual consistency.

Cấu trúc điển hình của một Modular Monolith

Hãy hình dung một sàn thương mại điện tử. Thay vì tổ chức code theo tầng kỹ thuật (controllers/, services/, repositories/), bạn tổ chức theo module nghiệp vụ:

src/
  modules/
    catalog/        (sản phẩm, danh mục)
      internal/     ← chỉ dùng trong module
      api/          ← public interface cho module khác
    ordering/
      internal/
      api/
    payment/
      internal/
      api/
    shipping/
      internal/
      api/
  shared/           ← kernel dùng chung tối thiểu (Money, UserId...)

Một quy tắc kiến trúc quan trọng: module ordering chỉ được import từ catalog/api, không bao giờ từ catalog/internal. Ranh giới này có thể được ép cứng bằng công cụ — ArchUnit (Java), NetArchTest (.NET), hoặc rule của ESLint/dependency-cruiser (Node), thậm chí bằng Java Module System (JPMS) hay Bounded Context của .NET.

Giao tiếp giữa các module: in-process, không qua mạng

Trong microservices, gọi service khác là một HTTP/gRPC request qua mạng — có latency, có thể fail, cần retry. Trong Modular Monolith, gọi module khác chỉ là một method call trong cùng process: nhanh như gọi hàm bình thường, không mất gói tin, không cần circuit breaker.

Bạn vẫn có thể áp dụng phong cách event-driven nội bộ: module ordering publish sự kiện OrderPlaced lên một in-memory event bus, module shipping lắng nghe và xử lý — nhưng tất cả vẫn trong một process, trong cùng một transaction database nếu cần. Đây là điểm cực kỳ đắt giá: bạn giữ được strong consistency với một transaction ACID duy nhất, thứ mà microservices phải đánh đổi bằng Saga pattern phức tạp.

Vì sao gọi là "đường giữa"

Đặt ba lựa chọn cạnh nhau:

  • Monolith thường: dễ bắt đầu, deploy đơn giản, nhưng không có ranh giới → về lâu dài dễ mục ruỗng, team giẫm chân nhau.
  • Microservices: ranh giới cứng nhờ tách process, scale độc lập, nhưng đắt về vận hành, latency mạng, distributed transaction.
  • Modular Monolith: ranh giới rõ (như micro), deploy đơn (như mono). Bạn trả giá bằng kỷ luật kỹ thuật thay vì bằng hạ tầng.
Quan trọng hơn cả: Modular Monolith là bàn đạp. Vì ranh giới đã sạch, khi một module thực sự cần scale độc lập hoặc cần team riêng làm chủ, bạn có thể "nhấc" nó ra thành service với chi phí thấp hơn nhiều so với việc phẫu thuật một big ball of mud.

Tình huống thực tế

Ví dụ 1 — Sàn TMĐT Việt Nam: cứu một monolith đang mục ruỗng

Hãy tưởng tượng "ChợNhanh", một sàn thương mại điện tử tại TP.HCM, xây từ 2019 trên một Laravel monolith. Sau bốn năm, hệ thống có 4 team (Catalog, Order, Payment, Promotion), khoảng 220 nghìn dòng code, và một database MySQL với 180 bảng dùng chung.

Vấn đề bắt đầu lộ ra: mỗi lần team Promotion sửa logic khuyến mãi, họ vô tình JOIN vào bảng orders và làm chậm query của team Order. Mỗi release phải "gom" cả 4 team, và trung bình một tuần có 2 lần rollback vì code của team này làm vỡ chức năng của team kia. Thời gian từ khi merge đến khi deploy an toàn kéo dài 3 ngày.

CTO đề xuất tách microservices. Nhưng đội DevOps chỉ có 2 người, chưa có Kubernetes, chưa có distributed tracing. Kiến trúc sư trưởng phản biện: "Vấn đề của chúng ta không phải là scale — traffic đỉnh chỉ 800 request/giây, một server 8 core gánh dư. Vấn đề là coupling và ranh giới. Đó là bài toán code, không phải bài toán hạ tầng."

Họ chọn refactor thành Modular Monolith trong 4 tháng: chia code theo 4 module nghiệp vụ, tách schema database (catalog_, order_, payment_, promo_), cấm JOIN chéo schema, và dùng một CI rule (dựa trên deptrac) fail build nếu module này import internal của module kia. Giao tiếp chéo module chuyển sang gọi qua interface public và event nội bộ.

Kết quả: Số lần rollback giảm từ 2/tuần xuống gần 0. Vì ranh giới rõ, mỗi team tự tin sửa module của mình. Deploy vẫn là một artifact duy nhất — DevOps không phải học Kubernetes. Bài học: khi nỗi đau là coupling chứ không phải scale, Modular Monolith giải quyết đúng bệnh mà không kê thuốc quá liều.

Ví dụ 2 — Startup fintech: bàn đạp để tách service sau này

"VíSốPay", một startup ví điện tử ở Singapore phục vụ thị trường Đông Nam Á, khởi đầu với 6 kỹ sư. Ngay từ đầu họ biết một ngày nào đó module Ledger (sổ cái) sẽ phải scale và tuân thủ audit khắt khe hơn phần còn lại. Nhưng với 6 người, xây 8 microservices ngay từ ngày đầu là tự sát về vận hành.

Quyết định của họ: xây Modular Monolith với ranh giới cực kỳ nghiêm ngặt ngay từ đầu. Bốn module — Onboarding, Wallet, Ledger, Notification — mỗi cái schema riêng, giao tiếp chỉ qua interface và event. Họ đặc biệt kỷ luật với Ledger: mọi thay đổi số dư đều đi qua một public API duy nhất, không module nào được ghi thẳng vào bảng sổ cái.

18 tháng sau, khi lượng giao dịch tăng gấp 40 lần và yêu cầu tuân thủ buộc Ledger phải chạy trên hạ tầng cách ly, họ "nhấc" Ledger ra thành service riêng. Vì API và ranh giới dữ liệu đã sạch từ đầu, cuộc phẫu thuật này mất 3 tuần thay vì vài tháng — họ chỉ cần thay lời gọi in-process bằng lời gọi qua mạng, thêm retry và circuit breaker cho đúng một điểm giao tiếp đó.

Bài học: Modular Monolith không phải là "monolith cho người sợ microservices". Nó là chiến lược tiến hóa. Ranh giới sạch hôm nay là quyền chọn (option) rẻ để tách service ngày mai — khi và chỉ khi bạn thực sự cần.

Ví dụ 3 — Shopify và Amazon: khi "đường giữa" là lựa chọn của người khổng lồ

Nhiều người nghĩ Modular Monolith chỉ dành cho công ty nhỏ. Không đúng. Shopify là một trong những "majestic monolith" nổi tiếng nhất thế giới — một ứng dụng Rails khổng lồ phục vụ hàng triệu cửa hàng. Thay vì đập bỏ để làm microservices, họ đầu tư mạnh vào modularization: ép các thành phần vào những "component" có ranh giới rõ ràng, dùng công cụ tự động phát hiện khi code vi phạm ranh giới, và chỉ tách ra ngoài những phần thực sự cần.

Ngược lại, Amazon nổi tiếng với microservices — nhưng ít ai nhắc rằng một số nhóm của Amazon (đáng chú ý là nhóm Prime Video, trong một bài viết kỹ thuật năm 2023) đã gộp ngược một hệ thống giám sát từ microservices/serverless về một khối duy nhất, cắt giảm chi phí tới 90%, vì chi phí truyền dữ liệu giữa các thành phần phân tán quá lớn so với lợi ích.

Bài học: hướng đi không phải lúc nào cũng là "mono → micro". Người trưởng thành về kiến trúc chọn cấu trúc theo bài toán cụ thể, không theo mốt. Modular Monolith là điểm cân bằng mà cả startup lẫn ông lớn đều dùng khi lợi ích của distributed không vượt được cái giá của nó.

Hướng dẫn từng bước

Nếu bạn đang cân nhắc hoặc bắt đầu một Modular Monolith, đây là lộ trình thực dụng:

  • Xác định ranh giới module theo domain, không theo tầng kỹ thuật. Dùng ngôn ngữ nghiệp vụ để đặt tên: Ordering, Catalog, Billing — không phải services, helpers, utils. Nếu bạn đã học Bounded Context, đây chính là nơi áp dụng: mỗi module lý tưởng là một bounded context.
  • Tổ chức thư mục theo module, rồi mới theo tầng bên trong module. "Screaming architecture": nhìn vào cây thư mục phải đoán ngay được đây là app bán hàng, chứ không phải chỉ thấy MVC.
  • Định nghĩa public API cho mỗi module. Mỗi module có một mặt tiền rõ ràng (interface, facade). Mọi thứ khác là internal và không được lộ ra ngoài.
  • Tách dữ liệu ở mức logic. Cho mỗi module một schema riêng (hoặc ít nhất một namespace bảng rõ ràng). Cấm tuyệt đối JOIN chéo module. Cần dữ liệu module khác thì gọi API của nó.
  • Ép ranh giới bằng công cụ, đừng dựa vào ý chí con người. Cài ArchUnit / NetArchTest / deptrac / dependency-cruiser vào CI. Build phải đỏ khi có ai import internal của module khác. Ranh giới không được tự động hóa sẽ bị bào mòn trong 6 tháng.
  • Giao tiếp bằng in-process event khi phù hợp. Với luồng bất đồng bộ về mặt logic (ví dụ: đặt hàng xong thì gửi thông báo), dùng in-memory event bus. Điều này giúp bạn "tập dượt" tư duy event-driven và làm việc tách service sau này dễ hơn.
  • Giữ một transaction cho các thao tác cần strong consistency. Đây là lợi thế lớn nhất so với microservices — đừng vứt bỏ nó. Chỉ chuyển sang eventual consistency khi bạn thực sự tách service.
  • Theo dõi "tín hiệu tách service". Chỉ tách một module ra thành service khi có lý do rõ ràng: nó cần scale rất khác phần còn lại, cần công nghệ khác, cần team độc lập hoàn toàn, hoặc có yêu cầu tuân thủ/cách ly riêng. Không tách vì "cho oai".

Lỗi thường gặp & mẹo

Lỗi 1 — Tưởng chia package là đã có Modular Monolith. Chia thư mục mà không ép ranh giới bằng công cụ thì chỉ là big ball of mud có sắp xếp gọn gàng. Trong tuần đầu tiên áp lực deadline, sẽ có người import xuyên ranh giới, và nó lan như cỏ dại. Mẹo: đưa kiểm tra ranh giới vào CI ngay từ commit đầu tiên.

Lỗi 2 — Dùng chung database bừa bãi, JOIN chéo mọi nơi. Đây là kẻ giết Modular Monolith phổ biến nhất. Một khi module Report JOIN thẳng vào bảng của 5 module khác, bạn đã hàn cứng chúng lại với nhau và mất luôn khả năng tách sau này. Mẹo: tách schema, cấp quyền DB theo schema nếu có thể, và với báo cáo/analytics thì dùng một đường dữ liệu riêng (read model) thay vì JOIN trực tiếp.

Lỗi 3 — Chia module quá nhỏ, quá sớm. 30 module cho một app 15 nghìn dòng chỉ tạo ma sát mà không mang lại lợi ích. Mẹo: bắt đầu với vài module lớn theo domain chính, tách nhỏ dần khi domain đó thực sự phình ra.

Lỗi 4 — Chia sẻ quá nhiều qua "shared kernel". Khi mọi module đều phụ thuộc vào một thư mục shared/ khổng lồ, bạn tạo ra coupling ngầm y hệt như monolith cũ. Mẹo: giữ shared kernel càng mỏng càng tốt — chỉ những khái niệm thật sự phổ quát (Money, kiểu ID, kết quả lỗi chung).

Lỗi 5 — Coi Modular Monolith là bến cuối bắt buộc phải đi tiếp tới microservices. Nhiều hệ thống sống trọn đời rất khỏe mạnh dưới dạng Modular Monolith và không bao giờ cần tách. Mẹo: xem việc tách service là một quyền chọn, không phải nghĩa vụ.

Mẹo tổng: Viết một ADR (bạn đã học ở Bài 4–5) ghi rõ quyết định chọn Modular Monolith, các ranh giới module, và những "tín hiệu" cụ thể sẽ khiến bạn tách service. ADR này sẽ cứu đội bạn khỏi vô số tranh cãi vô nghĩa về sau.

Bài tập thực hành

  • Vẽ lại ranh giới. Lấy một hệ thống bạn đang làm (hoặc sàn TMĐT "ChợNhanh" ở trên). Liệt kê 4–6 module nghiệp vụ và, với mỗi cặp module, ghi rõ: chúng có cần nói chuyện với nhau không, nếu có thì qua public API nào hay qua event nào. Đánh dấu mọi chỗ hiện đang JOIN chéo dữ liệu — đó là nợ cần trả.
  • Thiết kế public API. Chọn module Ordering. Viết ra interface public của nó (dạng chữ ký hàm) mà các module khác được phép gọi, ví dụ PlaceOrder(cart, customerId) -> OrderResult, GetOrderStatus(orderId) -> Status. Liệt kê những thứ phải là internal và tuyệt đối không lộ ra.
  • Chọn công cụ ép ranh giới. Với ngôn ngữ/stack của bạn (Java, .NET, Node, PHP, Go...), tìm và ghi lại tên công cụ có thể fail CI khi ranh giới bị vi phạm. Viết một quy tắc bằng lời: "Module X không được import internal của module Y".
  • Ra quyết định tách hay không. Cho một tình huống: module Notification gửi email/SMS, đôi khi chậm và làm nghẽn request đặt hàng. Câu hỏi: bạn tách nó thành service, hay chỉ cần xử lý bất đồng bộ nội bộ (in-process queue)? Viết 5–7 dòng lập luận, chỉ rõ tín hiệu nào ủng hộ mỗi hướng.
  • Viết ADR ngắn. Soạn một ADR nửa trang: "Chọn Modular Monolith cho dự án X". Nêu bối cảnh, lựa chọn thay thế (monolith thường, microservices), quyết định, và ba tín hiệu cụ thể sẽ khiến bạn tách một module ra thành service.

Tóm tắt

Modular Monolith là "đường giữa" thực dụng giữa monolith thường và microservices: deploy như một khối, nhưng bên trong có ranh giới rõ ràng như microservices. Nó tách được ba trục — code, dữ liệu, triển khai — nhưng chỉ ép hai trục đầu (code và dữ liệu ở mức logic), giữ nguyên deploy chung. Nhờ đó bạn có kỷ luật ranh giới mà không phải trả giá cho network latency, distributed transaction, và độ phức tạp vận hành.

Ba điều cần khắc cốt: (1) ranh giới phải được ép bằng công cụ trong CI, không dựa vào ý chí; (2) tách dữ liệu theo schema, cấm JOIN chéo — đây là nơi Modular Monolith sống hay chết; (3) xem việc tách service là quyền chọn rẻ trong tương lai, không phải nghĩa vụ.

Chọn Modular Monolith khi nỗi đau của bạn là coupling và va chạm giữa các team, chứ không phải scale — và khi đội ngũ chưa đủ trưởng thành về vận hành để gánh distributed system. Như "ChợNhanh", "VíSốPay", và cả Shopify đã cho thấy: đây không phải lựa chọn nửa vời, mà là điểm cân bằng mà cả người mới lẫn người khổng lồ đều tìm về khi họ chọn kiến trúc theo bài toán, chứ không theo mốt.