Monolith truyền thống: Khi nào Monolith vẫn là lựa chọn số 1?
7/19/2026 · 13p đọc
title: "Monolith truyền thống: Khi nào Monolith vẫn là lựa chọn số 1?"
series: "Software Architecture Masterclass"
part: "Phần 2 — Cấu trúc & Patterns"
order: 11
audience: "Solutions Architect & Technical Lead"
reading_time: "13 phút"
tags: ["monolith", "modular monolith", "microservices", "bounded context", "conway's law", "architecture trade-off"]
Monolith truyền thống: Khi nào Monolith vẫn là lựa chọn số 1?
Một team 6 kỹ sư, sản phẩm mới ra mắt được 8 tháng, chưa có 1000 người dùng hoạt động hàng ngày. Trong buổi retro kiến trúc quý, ai đó đề xuất: "Mình nên tách microservices ngay từ bây giờ, để sau này khỏi phải refactor lại." Cả phòng gật đầu. Ba tháng sau, team dành nhiều thời gian debug distributed tracing giữa 7 service, vá lỗi network timeout giả (transient), và viết lại logic retry cho một luồng nghiệp vụ vốn chỉ là một transaction SQL đơn giản trước đây — hơn là dành cho việc xây tính năng mới.
Đây không phải một câu chuyện hư cấu để răn đe — đây là một khuôn mẫu lặp lại đến mức nhàm chán trong ngành phần mềm suốt một thập kỷ qua. Nguyên nhân gốc rễ hiếm khi là kỹ thuật. Nó là văn hóa: "monolith" đã trở thành một từ mang hàm ý tiêu cực — đồng nghĩa với "legacy", "cũ kỹ", "không scale được", "không phải cách làm hiện đại". Trong khi "microservices" mang hàm ý ngược lại — hiện đại, được các công ty lớn như Netflix hay Amazon dùng, do đó "đúng".
Vấn đề là kiến trúc không vận hành theo hàm ý ngôn ngữ. Nó vận hành theo đánh đổi cụ thể, và đánh đổi đó phụ thuộc vào quy mô team, độ trưởng thành của domain, và mức độ chịu đựng vận hành (operational tolerance) thực tế của tổ chức — không phải theo việc công ty nào đó nổi tiếng đang dùng gì. Bài này lập luận một điều đi ngược trào lưu: modular monolith không phải là "bước đệm tạm thời trước khi trưởng thành thành microservices" — nó là một kiến trúc chính danh, hạng nhất, và với phần lớn sản phẩm ở phần lớn giai đoạn, nó là lựa chọn đúng đắn nhất về mặt kỹ thuật, không phải lựa chọn thỏa hiệp vì thiếu nguồn lực.
The Problem
Vấn đề kiến trúc cụ thể ở đây là: một team nhỏ (dưới ~10-15 kỹ sư), đang xây một sản phẩm còn đang tìm product-market fit hoặc mới ổn định domain, quyết định áp dụng microservices ngay từ ngày đầu — không phải vì có bằng chứng cụ thể cần nó, mà vì niềm tin ngầm định rằng "monolith là kiến trúc của quá khứ, mọi hệ thống nghiêm túc cuối cùng đều phải thành microservices."
Hệ quả trực tiếp là một dạng "thuế vận hành sớm" (premature operational tax) đánh vào chính lúc team ít khả năng chi trả nhất. Mỗi service mới kéo theo: một pipeline CI/CD riêng, một bộ observability riêng (logging, tracing, alerting), một hợp đồng API (API contract) phải version hóa và duy trì tương thích ngược, một chiến lược retry/timeout/circuit breaker cho lời gọi liên service, và một bài toán data consistency phân tán (vì transaction ACID đơn giản trong một DB giờ trở thành vấn đề đa hệ thống). Với một team 6 người đang cần tốc độ ra tính năng để tìm product-market fit, phần lớn năng lực kỹ sư bị chuyển từ "xây tính năng" sang "vận hành hạ tầng phân tán" — một khoản đầu tư mà lợi ích (scale độc lập, deploy độc lập theo team) chỉ trả về giá trị khi có nhiều hơn MỘT team cùng làm việc trên MỘT domain đủ lớn để cần tách.
Cái giá đắt hơn nữa là chi phí cơ hội ẩn: ranh giới service được vẽ ra khi domain còn chưa ổn định — trước khi ai hiểu rõ đâu là bounded context thật sự (xem Bài 10). Kết quả thường gặp là ranh giới sai: hai service gọi lẫn nhau đồng bộ (synchronous) dày đặc tới mức chúng thực chất là một domain logic bị chẻ đôi bởi một cuộc gọi network không cần thiết — tệ hơn coupling chặt trong cùng process, vì giờ coupling đó đi kèm độ trễ mạng, điểm lỗi mới, và không thể refactor bằng một thao tác "rename" trong IDE nữa mà phải qua một cuộc thương lượng API version giữa các team.
The Theory
Nền tảng lý thuyết cần làm rõ trước: monolith không đồng nghĩa với "big ball of mud". Một monolith là một đơn vị triển khai (deployment unit) duy nhất — toàn bộ ứng dụng chạy trong một process, dùng chung một database, được build và deploy như một artifact. Đây là một phát biểu về ranh giới triển khai (deployment boundary), không phải một phát biểu về chất lượng cấu trúc code bên trong nó.
Điều này dẫn tới khái niệm trung tâm của bài: modular monolith. Đây là một monolith về mặt triển khai, nhưng bên trong được tổ chức thành các module có ranh giới rõ ràng, mỗi module tương ứng với một bounded context — giống hệt cách bạn sẽ vẽ ranh giới nếu tách microservices, chỉ khác là các module này giao tiếp với nhau bằng lời gọi hàm trong cùng process (in-process function call) thay vì network call.
Ba nguyên tắc kỹ thuật biến một "monolith" thành "modular monolith" đúng nghĩa:
1. Ranh giới module là ranh giới API, không phải ranh giới thư mục. Một module chỉ được truy cập qua một tập hợp interface công khai (public interface) được định nghĩa rõ ràng — tương tự cách một microservice chỉ expose qua API contract. Module khác KHÔNG được import trực tiếp class nội bộ, repository, hay entity của module khác. Trong hệ sinh thái NestJS chẳng hạn, đây chính là lý do các module phải export service qua exports của @Module() thay vì cho phép import chéo bằng đường dẫn relative xuyên module — một quy ước tưởng nhỏ nhưng chính là ranh giới kiến trúc sống còn.
2. Mỗi module sở hữu dữ liệu của riêng nó (data ownership). Đây là nguyên tắc bị vi phạm nhiều nhất trong thực tế. Dùng chung một database vật lý là bình thường và hợp lý cho modular monolith (đó chính là lợi thế lớn nhất của nó) — nhưng module A không được join trực tiếp vào bảng của module B bằng SQL, và không được biết schema nội bộ của module B. Nếu cần dữ liệu từ module B, module A phải gọi qua service interface của B. Vi phạm nguyên tắc này là con đường ngắn nhất khiến modular monolith thoái hóa thành big ball of mud.
3. Phụ thuộc là một chiều và được kiểm tra tự động (enforced), không chỉ là quy ước bằng lời. Một modular monolith nghiêm túc dùng công cụ phân tích kiến trúc (architecture fitness function — xem Bài 8) để chặn CI build nếu có import vi phạm ranh giới module. Nếu ranh giới chỉ tồn tại trong tài liệu và không ai enforce, nó sẽ bị xói mòn trong vòng vài sprint dưới áp lực deadline.
Điểm mấu chốt về mặt triết lý: modular monolith và microservices thực chất chia sẻ CÙNG một tư duy thiết kế domain (DDD, bounded context) — chúng chỉ khác nhau ở cơ chế thực thi ranh giới: in-process function call (an toàn kiểu, nhanh, transaction đơn giản) so với network call (an toàn kiểu yếu hơn, có độ trễ, cần retry/timeout). Một team chưa vẽ được ranh giới module rõ ràng bên trong monolith gần như chắc chắn sẽ vẽ ranh giới service sai khi tách microservices — vì bản chất vấn đề (xác định bounded context đúng) không hề biến mất, nó chỉ bị che giấu tạm thời bởi ảo giác rằng "tách ra thành service riêng" tự động tạo ra ranh giới tốt.
The Trade-off
Chọn Monolith (modular) — được gì:
- Triển khai đơn giản tuyệt đối. Một artifact, một pipeline CI/CD, một lần deploy. Không có vấn đề "service A đã deploy version mới nhưng service B vẫn gọi API cũ" — vì mọi thứ deploy đồng thời, cùng version.
- Debug và tracing cực kỳ dễ. Một stack trace duy nhất đi xuyên suốt toàn bộ luồng nghiệp vụ, trong cùng một process. Không cần distributed tracing (Jaeger, Zipkin), không cần correlation ID xuyên nhiều service để ghép lại một request.
- Transaction đơn giản, ACID thật sự. Một nghiệp vụ chạm vào 3 module khác nhau vẫn có thể nằm trong một database transaction duy nhất, rollback tức thì nếu có lỗi. Không cần SAGA pattern (xem Bài 19), không cần xử lý eventual consistency, không cần lo về trạng thái nửa vời (partial failure) giữa các bước.
- Chi phí hạ tầng và vận hành thấp hơn nhiều lần. Không cần service mesh, không cần API gateway phức tạp, không cần một đội SRE riêng để vận hành hàng chục service.
Chọn Monolith (modular) — mất gì (cái giá cụ thể):
- Rủi ro coupling chặt dần theo thời gian nếu không kỷ luật. Vì ranh giới module chỉ được thực thi bằng quy ước code review và công cụ lint/kiến trúc — không có "bức tường vật lý" như network boundary — chúng dễ bị xói mòn hơn microservices dưới áp lực deadline. Một kỹ sư mới, gấp deadline, import thẳng một internal class của module khác — nếu không có fitness function chặn CI, việc này lọt qua và tích lũy dần thành nợ kiến trúc.
- Không scale độc lập theo tải. Nếu module Notification cần xử lý 100x tải so với module User Profile, bạn vẫn phải scale toàn bộ monolith (thêm instance của TOÀN BỘ ứng dụng), lãng phí tài nguyên cho các module không cần scale.
- Không deploy độc lập theo team. Nếu 5 team cùng làm việc trên một monolith, mọi deploy đều là deploy của TOÀN BỘ hệ thống — một bug nhỏ ở module của team A có thể chặn release của team B. Đây chính là điểm giới hạn tự nhiên: khi số team làm việc trên cùng một codebase vượt quá một ngưỡng nhất định, tần suất xung đột deploy tăng phi tuyến.
- Công nghệ đồng nhất bắt buộc. Toàn bộ monolith thường dùng chung một ngôn ngữ, một runtime, một phiên bản framework. Không thể để một module dùng Python cho machine learning trong khi phần còn lại dùng TypeScript — muốn vậy phải tách ra thành service riêng.
Chọn Microservices — được gì, mất gì (để đối chiếu, chi tiết ở Bài 12): được scale độc lập, deploy độc lập theo team, tự do công nghệ theo từng service; nhưng trả giá bằng vận hành phân tán khổng lồ — network là điểm lỗi mới, transaction phân tán buộc phải chấp nhận eventual consistency, observability trở thành bài toán bắt buộc (không phải tùy chọn), và chi phí hạ tầng/nhân sự vận hành tăng đáng kể ngay từ ngày một, bất kể quy mô team có tận dụng hết lợi ích đó hay không.
Bản chất của đánh đổi này không phải "monolith tệ hơn microservices" hay ngược lại — mà là: microservices bán một khoản thuế vận hành cố định (fixed operational tax) để đổi lấy khả năng scale độc lập theo team và theo tải. Nếu bạn chưa có đủ team để cần deploy độc lập, hoặc chưa có đủ tải để cần scale lệch giữa các phần hệ thống, bạn đang trả thuế đó mà không nhận được lợi ích tương xứng.
The Architecture Diagram
Sơ đồ dưới đây đối chiếu ranh giới NỘI BỘ trong modular monolith (thực thi bằng interface/module boundary trong cùng process) với ranh giới NGOÀI của microservices (thực thi bằng network boundary giữa các process độc lập).
flowchart TB
subgraph MM["MODULAR MONOLITH — Một deployment unit duy nhất"]
direction TB
subgraph MM_Proc["Một process, một pipeline deploy"]
M1["Module: Order\n(controller + service + entity)"]
M2["Module: Billing\n(controller + service + entity)"]
M3["Module: Inventory\n(controller + service + entity)"]
M1 -->|"in-process call\nqua exported interface"| M2
M2 -->|"in-process call"| M3
end
DB1[("Một database\nchung, transaction ACID")]
MM_Proc --> DB1
end
subgraph MS["MICROSERVICES — Nhiều deployment unit độc lập"]
direction TB
S1["Service: Order\n(process riêng)"]
S2["Service: Billing\n(process riêng)"]
S3["Service: Inventory\n(process riêng)"]
S1 -->|"network call\n(REST/gRPC/message)\n+ retry, timeout,\ncircuit breaker"| S2
S2 -->|"network call\n+ SAGA nếu cần\ntransaction xuyên service"| S3
DB2[("DB riêng\ncho Order")]
DB3[("DB riêng\ncho Billing")]
DB4[("DB riêng\ncho Inventory")]
S1 --> DB2
S2 --> DB3
S3 --> DB4
end
MM -.->|"Tách khi có bằng chứng cụ thể:\nteam xung đột deploy,\ncần scale lệch tải"| MS
Điểm mấu chốt của sơ đồ: ranh giới module trong modular monolith (đường mũi tên trong MM_Proc) và ranh giới service trong microservices (đường mũi tên giữa S1, S2, S3) đại diện cho CÙNG một ranh giới domain logic — khác nhau ở cơ chế thực thi. Mũi tên chấm ở dưới cùng thể hiện đúng tinh thần bài này: việc tách chỉ nên xảy ra khi có bằng chứng cụ thể, không phải mặc định từ ngày đầu.
Implementation Guardrails
Bắt buộc ranh giới module rõ ràng ngay từ ngày đầu, thực thi bằng công cụ, không chỉ bằng quy ước. Mỗi module chỉ expose một public interface (trong NestJS: service được
exportqua@Module()); cấm import trực tiếp entity/repository/class nội bộ của module khác. Dùng công cụ phân tích kiến trúc (dependency-cruiser, ESLint boundary rules, hoặc tương đương) chặn CI build nếu phát hiện vi phạm — đừng để ranh giới sống chỉ trong đầu vài kỹ sư senior.Mỗi module sở hữu schema dữ liệu của mình; cấm join trực tiếp xuyên module bằng SQL thô. Nếu module Order cần dữ liệu từ module Billing, gọi qua service interface của Billing, không viết
JOINtrực tiếp vào bảng của Billing. Vi phạm nguyên tắc này là dấu hiệu sớm nhất và chắc chắn nhất cho thấy modular monolith đang thoái hóa — vì lúc đó bạn không còn có thể tách module ra được nữa mà không phá vỡ dữ liệu.Mọi thay đổi schema DB phải qua migration có review, không dùng auto-sync trên môi trường thật. Đây là nền tảng để modular monolith giữ được khả năng tách sau này — một lịch sử migration rõ ràng, gắn với từng module, là tài liệu sống về ranh giới dữ liệu đã tiến hóa ra sao theo thời gian, và là thứ đầu tiên bạn cần khi thật sự phải tách một module thành service riêng.
Định kỳ (mỗi quý hoặc mỗi lần review kiến trúc) đo coupling giữa các module bằng số liệu, không bằng cảm giác (xem Bài 8 — afferent/efferent coupling, change coupling). Nếu hai module có tần suất bị sửa cùng nhau (change coupling) tăng dần dù không có lý do nghiệp vụ rõ ràng, đó là tín hiệu ranh giới đang bị vẽ sai hoặc đang bị xói mòn — xử lý ngay lúc này, khi chi phí sửa còn thấp (đổi vài import trong IDE), thay vì đợi tới lúc phải tách microservices rồi mới phát hiện ra domain logic đã dính chặt vào nhau.
Architect's Verdict
Modular monolith không phải "phiên bản rẻ tiền" của microservices, và cũng không phải trạm trung chuyển bắt buộc trước khi "trưởng thành". Với tuyệt đại đa số sản phẩm — đặc biệt là những sản phẩm chưa có domain ổn định và team chưa đủ lớn để va vào xung đột deploy thật sự — nó là kiến trúc đúng đắn về mặt kỹ thuật, không phải một thỏa hiệp tạm bợ. Đầu tư kỷ luật vào ranh giới module ngay từ đầu — vì đó chính là bài tập bạn phải làm dù chọn kiến trúc nào, và nó là thứ duy nhất quyết định việc tách microservices sau này (nếu thực sự cần) sẽ là một buổi chiều đổi vài import, hay một dự án tái cấu trúc kéo dài cả năm.
🔗 Bài viết liên quan
- Bounded Contexts — ranh giới module trong modular monolith chính là bounded context được thực thi bằng in-process boundary thay vì network boundary.
- Microservices: Điểm rơi — phân tích chi tiết bằng chứng cụ thể nào (quy mô team, xung đột deploy, nhu cầu scale khác biệt) mới đủ để biện minh cho việc trả thuế vận hành phân tán.
Bài trước: Bounded Contexts · Bài tiếp theo: Microservices