← Về danh sách
Kỹ nghệ & Quản lý phần mềm
#모듈러모놀리스#마이크로서비스#모듈화#경계컨텍스트#아키텍처패턴
Cập nhật lần cuối · 2026-11-01

Kiến trúc Modular Monolith (Khối nguyên khối theo mô-đun)

1. Tổng quan

Modular monolith là một phong cách kiến trúc, bên trong một đơn vị triển khai duy nhất (một tiến trình duy nhất), chia miền nghiệp vụ thành các mô-đun độc lập có ranh giới rõ ràng và buộc các mô-đun chỉ tương tác với nhau thông qua hợp đồng công khai (public API) và sự kiện — qua đó cùng lúc theo đuổi sự đơn giản trong vận hành của monolith và tính ghép lỏng của microservices.

Trong khoảng hơn một thập niên qua, nhiều tổ chức đã phân rã hệ thống của mình thành microservices với lý do khả năng mở rộng và tính tự chủ của đội ngũ, nhưng một số lượng đáng kể đã phải gánh chi phí thay vì hiệu quả kỳ vọng: độ trễ và lỗi của lời gọi mạng, giao dịch phân tán, tương thích phiên bản giữa các dịch vụ, và sự phức tạp của quan sát và truy vết. Đặc biệt, khi một sản phẩm giai đoạn đầu mà ranh giới miền chưa ổn định đầy đủ bị tách vội vàng, nó thường dẫn đến một "monolith phân tán (distributed monolith)" trong đó một thay đổi tính năng lan thành việc triển khai đồng thời trải trên nhiều dịch vụ. Trong sự phản tỉnh này, một cách tiếp cận giữ được sự đơn giản của triển khai đơn lẻ nhưng vẫn mô-đun hóa cấu trúc bên trong nghiêm ngặt ngang với microservices đã được chú ý trở lại, và đó chính là modular monolith.

Vấn đề của monolith truyền thống không nằm ở "triển khai đơn lẻ" mà ở "sự thiếu vắng ranh giới bên trong". Một mã nguồn chỉ được chia theo tầng (layer) theo thời gian sẽ thoái hóa thành một "quả cầu bùn lớn (big ball of mud)" trong đó không còn có thể truy vết tính năng nào chạm vào dữ liệu nào. Thay vì làm cho ranh giới triển khai là duy nhất, modular monolith dựng các ranh giới logic theo đơn vị miền và ẩn phần cài đặt bên trong của từng mô-đun khỏi bên ngoài để kiểm soát độ ghép. Nói cách khác, lối tư duy cốt lõi là xử lý "sự hợp nhất của triển khai" và "sự tách biệt của thiết kế" như hai mối quan tâm riêng biệt.

Sự cần thiết của cách tiếp cận này được phán đoán theo hướng của sự thay đổi. Nếu ranh giới miền còn linh động, quy mô đội ngũ dưới vài chục người, và tổ chức vận hành chưa đạt độ trưởng thành đủ để gánh vác các hệ thống phân tán, thì modular monolith là hợp lý. Ngược lại, nếu lưu lượng tăng vọt chỉ ở một mô-đun cụ thể khiến việc mở rộng độc lập là bắt buộc, hoặc nếu ngôn ngữ, runtime, chu kỳ triển khai khác nhau được yêu cầu mạnh mẽ, thì việc tách riêng chính mô-đun đó thành một dịch vụ là lựa chọn tốt hơn. Kỹ sư chuyên nghiệp phải có khả năng giải thích không phải sự phân đôi "monolith hay microservices", mà là nên đứng ở điểm nào trên các trục cô lập thay đổi, nhu cầu mở rộng và độ trưởng thành của tổ chức.

2. Cấu trúc tổng thể và các nguyên lý thiết kế cốt lõi

Modular monolith là một cấu trúc trong đó nhiều mô-đun miền cùng tồn tại bên trong một tệp thực thi (hoặc container) duy nhất, nhưng mỗi mô-đun ẩn phần nội bộ của nó sau hợp đồng công khai của mình. Nhìn từ bên ngoài, nó là một ứng dụng duy nhất, nhưng nhìn từ bên trong, nó là một tập hợp các "ứng viên dịch vụ" được tách biệt rõ ràng. Sơ đồ dưới đây cho thấy các mô-đun đơn hàng, thanh toán, vận chuyển và tồn kho được kết nối bên trong một tiến trình duy nhất chỉ thông qua public API và sự kiện miền.

flowchart TB
    subgraph APP["Đơn vị triển khai duy nhất (một tiến trình)"]
        direction LR
        O["Mô-đun đơn hàng\nPublic API + ẩn nội bộ"]
        P["Mô-đun thanh toán\nPublic API + ẩn nội bộ"]
        S["Mô-đun vận chuyển\nPublic API + ẩn nội bộ"]
        I["Mô-đun tồn kho\nPublic API + ẩn nội bộ"]
        BUS["Bus sự kiện trong tiến trình"]
        O -->|Gọi public API| P
        O -->|Sự kiện OrderConfirmed| BUS
        BUS -->|đăng ký| S
        BUS -->|đăng ký| I
    end
    UI["Client web / di động"] --> O
    O --> DBO[("Lược đồ đơn hàng")]
    P --> DBP[("Lược đồ thanh toán")]
    S --> DBS[("Lược đồ vận chuyển")]
    I --> DBI[("Lược đồ tồn kho")]

Quy tắc quan trọng nhất trong cấu trúc này là một mô-đun không được tham chiếu trực tiếp các lớp hay bảng nội bộ của mô-đun khác. Nếu mô-đun đơn hàng cần thanh toán, nó chỉ gọi public API của mô-đun thanh toán (ví dụ: PaymentPort.authorize(...)) và không thể biết logic cấp phép hay cấu trúc lưu trữ bên trong thanh toán. Bằng cách này, ngay cả khi mô-đun thanh toán được viết lại hoàn toàn hoặc sau này được tách ra thành một dịch vụ riêng, mô-đun đơn hàng vẫn không bị ảnh hưởng miễn là hợp đồng công khai được duy trì.

Nguyên lý thứ hai là sự tách biệt quyền sở hữu dữ liệu. Ngay cả khi tất cả các mô-đun chia sẻ một cơ sở dữ liệu duy nhất, mỗi mô-đun chỉ có quyền ghi vào lược đồ (hoặc tập bảng) của riêng mình và không join trực tiếp các bảng của mô-đun khác. Khi cần dữ liệu của mô-đun khác, thay vì join thì truy vấn thông qua public API, hoặc nhận qua sự kiện và sao chép vào mô hình đọc của riêng mình. Nếu quy tắc này đổ vỡ, hệ thống lập tức thoái lui thành monolith truyền thống trong đó "mọi thứ được kết nối ở cấp DB", nên đây là điểm nhạy cảm nhất quyết định sự thành bại của modular monolith.

Nguyên lý thứ ba là kiểm soát hướng phụ thuộc. Nếu các phụ thuộc giữa các mô-đun tạo thành chu trình thì việc tách sẽ không thể, nên người ta phân biệt một nhân chia sẻ (shared kernel) với các mô-đun theo miền và thiết kế đồ thị phụ thuộc sao cho phi chu trình (acyclic). Ví dụ, các định danh chung, đối tượng giá trị và định nghĩa sự kiện được đặt trong một mô-đun chia sẻ mỏng, còn quy tắc nghiệp vụ chỉ nằm trong từng mô-đun miền.

2.1 Nhận diện ranh giới mô-đun

Vẽ ranh giới mô-đun ở đâu không phải là câu hỏi của kiến trúc mà của miền. Bounded context của thiết kế hướng miền (DDD) là ứng viên mạnh nhất, và điểm mà cùng một thuật ngữ mang ý nghĩa khác nhau (ví dụ, "khách hàng" trong đặt hàng so với "khách hàng" trong tiếp thị) chính là đường phân chia các mô-đun. Nếu vẽ sai ranh giới, một thay đổi tính năng sẽ chạm vào nhiều mô-đun cùng lúc và độ ghép lại tăng lên, nên nguyên tắc là gom các khái niệm gắn kết cùng thay đổi với nhau vào một mô-đun.

Trong thực tế, an toàn hơn là áp dụng cách tiếp cận tiến hóa: ban đầu vẽ ranh giới lỏng lẻo, quan sát xem nhật ký thay đổi và commit tụ về mô-đun nào, rồi điều chỉnh lại ranh giới. Nỗ lực thiết kế ranh giới hoàn hảo trong một lần mang rủi ro cao làm hóa thạch các ranh giới sai trong giai đoạn đầu khi hiểu biết về miền còn thiếu.

2.2 Phương tiện cưỡng chế ranh giới (enforcement)

Ranh giới chỉ được duy trì khi chúng được cưỡng chế bằng công cụ, chứ không bằng tài liệu hay lời hứa miệng. Ở phe Java, Spring Modulith hoặc ArchUnit cố định các quy tắc dưới dạng kiểm thử như "nếu mô-đun A tham chiếu gói internal của mô-đun B thì build thất bại". Ở .NET, người ta chia tham chiếu dự án theo đơn vị mô-đun và hạn chế các kiểu nội bộ là internal, và từ Java 9 trở lên hệ thống mô-đun (JPMS) hoặc kiểm soát truy cập gói thu hẹp phạm vi công khai. Nếu không có kiểm chứng tự động như vậy, các lập trình viên sẽ chọn lối tắt dễ dàng (tham chiếu trực tiếp) trong lịch trình bận rộn và ranh giới sẽ biến mất trong vòng vài tháng.

3. Tương tác giữa các mô-đun và cấu trúc bên trong

Giao tiếp giữa các mô-đun về cơ bản chia thành lời gọi đồng bộ và sự kiện bất đồng bộ, và mỗi lựa chọn ảnh hưởng trực tiếp đến độ ghép và tính nhất quán. Sơ đồ dưới đây biểu diễn luồng trong đó một lần tạo đơn hàng lan truyền đến các mô-đun thanh toán, tồn kho và vận chuyển, bằng hỗn hợp tương tác đồng bộ và bất đồng bộ.

sequenceDiagram
    participant C as Client
    participant O as Mô-đun đơn hàng
    participant P as Mô-đun thanh toán
    participant B as Bus sự kiện
    participant I as Mô-đun tồn kho
    participant S as Mô-đun vận chuyển
    C->>O: Yêu cầu tạo đơn hàng
    O->>P: Gọi public API (cấp phép thanh toán)
    P-->>O: Trả về kết quả cấp phép
    O->>O: Lưu đơn hàng đã xác nhận
    O->>B: Phát sự kiện OrderConfirmed
    B->>I: Xử lý trừ tồn kho
    B->>S: Xử lý chuẩn bị vận chuyển
    O-->>C: Phản hồi số đơn hàng

Lời gọi đồng bộ được dùng khi cần kết quả ngay lập tức. Vì đơn hàng phải biết thanh toán có được cấp phép hay không trước khi được xác nhận, mô-đun đơn hàng gọi public API của mô-đun thanh toán trong tiến trình và chờ kết quả. Vì lời gọi trong tiến trình không đi qua mạng nên độ trễ gần như bằng không và việc xử lý có thể diễn ra trong cùng một ranh giới giao dịch, tránh được sự phức tạp của giao dịch phân tán — một lợi thế quyết định so với microservices.

Sự kiện bất đồng bộ được dùng khi không cần chờ kết quả ngay và khi mô-đun gửi không cần biết đến sự tồn tại của mô-đun nhận. Sau khi đơn hàng được xác nhận, việc trừ tồn kho và chuẩn bị vận chuyển không phải mối quan tâm của mô-đun đơn hàng, nên mô-đun đơn hàng chỉ phát sự kiện OrderConfirmed và không biết ai đăng ký nó. Bằng cách này, ngay cả khi thêm một mô-đun "tích điểm" sau này, chỉ cần thêm các mô-đun đăng ký sự kiện mà không cần đổi mã của mô-đun đơn hàng, nên nguyên tắc mở-đóng được hiện thực hóa ở cấp mô-đun.

Cạm bẫy thiết kế cần lưu ý ở đây là thay thế sự kiện bằng lời gọi phương thức đơn giản, dựa vào sự tiện lợi của việc "trong cùng một tiến trình". Ngay cả với sự kiện trong tiến trình, hợp đồng phát-đăng ký phải được làm rõ ràng để khi mô-đun đó về sau được tách thành một dịch vụ riêng, việc đổi sự kiện sang một message broker (ví dụ: Kafka) trở thành một thay đổi cục bộ. Ngược lại, nếu lạm dụng sự kiện đến mức xử lý bất đồng bộ cả mọi luồng đồng bộ, một nghịch lý nảy sinh trong đó người ta chấp nhận tính nhất quán cuối cùng một cách không cần thiết trong một tiến trình duy nhất, nên cần sự cân bằng.

3.1 Giao dịch và tính nhất quán

Một lợi thế lớn của modular monolith là khả năng gói các thay đổi trải trên nhiều mô-đun vào một giao dịch cục bộ duy nhất. Nếu việc lưu đơn hàng và bản ghi thanh toán được commit trong cùng một giao dịch thì có thể ngăn tận gốc sự bất nhất dữ liệu do lỗi cục bộ, nên dễ dàng đạt được tính nhất quán mà microservices chỉ đạt được một cách khó khăn qua Saga và giao dịch bù trừ. Dẫu vậy, phần xử lý bất đồng bộ dựa trên sự kiện vẫn là tính nhất quán cuối cùng, nên để đảm bảo tính nguyên tử của việc phát sự kiện và thay đổi trạng thái, an toàn hơn là áp dụng một mẫu tương tự transactional outbox bên trong mô-đun.

3.2 Cấu trúc gói bên trong

Mỗi mô-đun thường được chia bên trong thành api (hợp đồng công khai), domain (quy tắc nghiệp vụ), infrastructure (lưu trữ và tích hợp bên ngoài) và internal (cài đặt được ẩn). Thứ được phơi bày chỉ là các giao diện, sự kiện và DTO của gói api, còn phần còn lại bị chặn tham chiếu từ bên ngoài mô-đun. Cấu trúc này có thể được xem là kiến trúc lục giác (port và adapter) được áp dụng lặp lại ở cấp mô-đun, và nó giữ cho quy tắc nghiệp vụ không bị ô nhiễm bởi chi tiết kỹ thuật ngay cả bên trong một mô-đun.

4. So sánh với microservices và monolith truyền thống

Ba kiến trúc thường bị hiểu nhầm như một thứ bậc hơn kém, nhưng thực tế chúng là các lựa chọn tối ưu hóa những ràng buộc khác nhau. Khác biệt cốt lõi phát sinh từ "số lượng đơn vị triển khai" và "phương tiện cưỡng chế ranh giới", và khác biệt đó dẫn đến sự đánh đổi giữa chi phí vận hành và tính linh hoạt khi thay đổi.

Phân loại Monolith truyền thống Modular monolith Microservices
Đơn vị triển khai 1 1 N
Ranh giới mô-đun Mơ hồ (theo tầng) Rõ ràng (theo miền, công cụ cưỡng chế) Tách biệt vật lý
Giao tiếp giữa mô-đun Gọi trực tiếp, bảng chung Public API, sự kiện trong tiến trình Mạng (REST, gRPC, messaging)
Giao dịch Cục bộ đơn Cục bộ đơn Phân tán (Saga, bù trừ)
Mở rộng độc lập Không thể Không thể (chỉ toàn ứng dụng) Khả thi theo từng mô-đun
Độ phức tạp vận hành Thấp Thấp Cao
Tính tự chủ của đội Thấp Trung bình Cao

Như bảng cho thấy, modular monolith khác biệt dứt khoát với monolith truyền thống ở chỗ nó giữ độ phức tạp vận hành thấp trong khi làm ranh giới rõ ràng. Mặt khác, nó từ bỏ "mở rộng độc lập theo từng mô-đun" và "tính dị thể về ngôn ngữ/runtime" mà microservices cung cấp. Do đó, khoảnh khắc lưu lượng tập trung vào một mô-đun cụ thể và mở rộng độc lập trở thành yêu cầu cốt lõi chính là tín hiệu chuyển từ modular monolith sang microservices.

Các trường hợp thực tế làm sự đánh đổi này trở nên sinh động. Nền tảng thương mại điện tử Shopify, thay vì tách mã nguồn Ruby on Rails lớn của mình thành microservices, đã tái cấu trúc nó thành một modular monolith có ranh giới được cưỡng chế, giữ được lợi ích của triển khai đơn lẻ trong khi để một tổ chức phát triển quy mô hàng nghìn người làm việc song song. Đội Amazon Prime Video — không phải chi nhánh của Netflix — đã công bố vào năm 2023 rằng họ hợp nhất đường ống giám sát chất lượng video từ microservices phân tán thành một ứng dụng duy nhất và cắt giảm chi phí hạ tầng khoảng 90%, một trường hợp tiêu biểu cho thấy sự phân tán quá mức có thể lại sinh ra kém hiệu quả. Nền tảng dữ liệu khách hàng Segment cũng chia sẻ trải nghiệm không thể chịu nổi gánh nặng vận hành của microservices đã tăng lên hàng trăm và hợp nhất trở lại thành monolith; bài học chung của các chuyển đổi này là "sự tách biệt không miễn phí, và trì hoãn nó cho đến khi ranh giới ổn định cũng không muộn".

5. Chuyên sâu: Chiến lược chuyển đổi và xu hướng mới nhất

Một nơi mà modular monolith đặc biệt mạnh mẽ là khả năng hoạt động như một "ga trung chuyển" trên con đường đến microservices. Nguyên tắc "Monolith First" do Martin Fowler đề xuất khuyến nghị rằng, vì việc tách dịch vụ vội vàng từ sớm — khi ranh giới miền còn bất định — sẽ hóa thạch về mặt vật lý các ranh giới sai, nên trước hết hãy tinh chỉnh ranh giới bên trong một đơn vị triển khai duy nhất và chỉ tách các mô-đun cần thiết thành dịch vụ sau khi các ranh giới đó đã ổn định. Modular monolith trở thành nền tảng tự nhiên cho chiến lược này. Vì giao tiếp giữa các mô-đun đã được trừu tượng hóa thành public API và sự kiện, tại thời điểm tách, công việc biến lời gọi trong tiến trình thành lời gọi từ xa và sự kiện trong tiến trình thành một message broker được giới hạn trong ranh giới mô-đun.

Hệ sinh thái công cụ cũng đã trưởng thành để hỗ trợ cách tiếp cận này. Spring Modulith của phe Spring làm rõ ranh giới mô-đun, kiểm chứng vi phạm bằng kiểm thử, và hỗ trợ sự kiện giữa mô-đun cùng việc lập tài liệu. ArchUnit khẳng định các quy tắc phụ thuộc gói bằng mã để chặn vi phạm ranh giới trong CI, và NDepend của .NET cùng các thư viện kiểm thử kiến trúc đóng vai trò tương tự. Khi sự tự động hóa "phá ranh giới thì build hỏng" này trở nên phổ biến, modular monolith không còn là một mẫu mong manh chỉ dựa vào kỷ luật mà là một kiến trúc khả thi được chống đỡ bằng công cụ.

Gần đây, trong môi trường đám mây, ngay cả một triển khai đơn lẻ cũng thường hỗ trợ mở rộng theo chiều ngang (cùng một image trên nhiều phiên bản) và triển khai không gián đoạn, nên quan niệm "monolith không thể mở rộng" đã được làm dịu đi đáng kể. Vận hành một modular monolith trên nền serverless hoặc điều phối container có thể gánh được một quy mô đáng kể bằng cách sao chép toàn bộ ứng dụng để phân tán lưu lượng. Rốt cuộc, một hình thức "lai (hybrid)" chỉ tách riêng một cách chọn lọc ít mô-đun thực sự cần triển khai độc lập và mở rộng độc lập đang định hình thành cấu trúc chủ đạo thực tế.

6. Những điểm cần cân nhắc và hàm ý

Thứ nhất, sự tự động hóa việc cưỡng chế ranh giới quyết định thành bại. Modular monolith chỉ được duy trì khi ranh giới được giữ bằng công cụ build và kiểm thử, chứ không bằng thiện chí của lập trình viên. Các kiểm thử kiến trúc như ArchUnit và Spring Modulith nên được đặt làm cổng bắt buộc trong đường ống CI, và cơ chế chặn vi phạm ranh giới trước khi chúng được hợp nhất phải được chuẩn bị đồng thời với thiết kế kiến trúc.

Thứ hai, quyền sở hữu dữ liệu phải được đóng đinh ở giai đoạn thiết kế. Thất bại phổ biến nhất bắt đầu khi người ta join trực tiếp bảng của mô-đun khác "vì hiệu năng". Việc tách lược đồ theo từng mô-đun và cấm join chéo nên được quy định thành văn bản, và cần hướng dẫn rằng khi cần dữ liệu của mô-đun khác thì giải quyết bằng truy vấn public API hoặc sao chép mô hình đọc dựa trên sự kiện, để khả năng tách được bảo tồn về lâu dài.

Thứ ba, việc chuyển đổi nên được trì hoãn cho đến khi ranh giới đã ổn định một cách hợp lý. Quyết định đi tới microservices ngay từ đầu là nghịch lý của việc chọn cấu trúc đắt đỏ nhất vào thời điểm hiểu biết về miền yếu nhất. Bắt đầu với modular monolith, quan sát các mẫu thay đổi và đặc tính lưu lượng, rồi tách một cách chọn lọc chỉ những mô-đun mà mở rộng độc lập, triển khai độc lập và tính dị thể công nghệ thực sự được yêu cầu là một chiến lược hạ thấp cả chi phí lẫn rủi ro.

Thứ tư, cấu trúc tổ chức và kiến trúc phải được thiết kế cùng nhau. Theo định luật Conway, cấu trúc hệ thống giống cấu trúc giao tiếp của tổ chức. Việc khớp ranh giới mô-đun với ranh giới trách nhiệm của đội trong một modular monolith làm rõ quyền sở hữu mã và giảm xung đột thay đổi, nhưng vì một kho lưu trữ và một triển khai được chia sẻ, nên phải thỏa thuận quy trình điều phối triển khai và review mã giữa các đội thì lợi ích của sự hợp nhất mới được duy trì.

Thứ năm, khả năng quan sát và các chỉ số theo từng mô-đun nên được thiết kế cùng nhau. Ngay cả trong một tiến trình duy nhất, việc đo riêng thời gian xử lý, tỷ lệ lỗi và sự ùn ứ sự kiện theo từng mô-đun giúp có thể phán đoán bằng dữ liệu mô-đun nào là nút thắt cổ chai và mô-đun nào nên được tách trước, cung cấp cơ sở cho các quyết định chuyển đổi.

Tài liệu tham khảo


Tóm tắt một câu: Modular monolith giữ sự đơn giản của triển khai đơn lẻ trong khi kết nối các mô-đun miền chỉ thông qua public API và sự kiện và cưỡng chế ranh giới bằng công cụ, nhờ đó thu được tính ghép lỏng của microservices với chi phí thấp và cho phép chỉ tách những mô-đun cần thiết thành dịch vụ về sau.