Mở đầu — vì sao bài này quan trọng
Trong ngành phần mềm hiện đại, có một sự hiểu lầm nguy hiểm mà rất nhiều kỹ sư — kể cả những người đã đi làm vài năm — mắc phải: tin rằng "monolith là lỗi thời" và "microservices mới là kiến trúc chuẩn của công ty nghiêm túc". Sự hiểu lầm này đã đẩy vô số đội ngũ vào cảnh chia nhỏ hệ thống quá sớm, để rồi ngập trong độ phức tạp vận hành mà đáng lẽ họ chưa cần phải gánh.
Là người ra quyết định kiến trúc, bạn cần hiểu một điều cốt lõi: monolith không phải là "cái xấu mà ta buộc phải bắt đầu rồi phải thoát khỏi càng nhanh càng tốt". Monolith là một lựa chọn kiến trúc hợp lệ, có chủ đích, và trong rất nhiều trường hợp là lựa chọn tối ưu. Ngay cả những công ty công nghệ hàng đầu như Shopify, Stack Overflow hay GitHub đều vận hành trên monolith ở quy mô cực lớn trong nhiều năm.
Bài học hôm nay không dạy bạn cách viết monolith — đó là chuyện của lập trình. Bài này dạy bạn cách quyết định khi nào monolith là lựa chọn đúng, dựa trên bối cảnh cụ thể của đội và sản phẩm. Đây chính là bản chất của một Architecture Decision: chọn đúng công cụ cho đúng hoàn cảnh, chứ không chạy theo xu hướng. Ở các bài sau (17, 18) chúng ta sẽ so sánh trực tiếp với microservices và modular monolith; còn bài này tập trung làm rõ bản chất và thời điểm phù hợp của monolith.
Khái niệm cốt lõi
Monolith là gì
Monolith (kiến trúc nguyên khối) là một kiến trúc trong đó toàn bộ ứng dụng được đóng gói và triển khai (deploy) như một đơn vị duy nhất. Một artifact — có thể là một file JAR, một Docker image, một thư mục PHP, một binary Go — chứa tất cả:
- Tầng giao diện (UI / presentation layer)
- Tầng nghiệp vụ (business logic)
- Tầng truy cập dữ liệu (data access layer)
Điểm mấu chốt cần phân biệt: monolith nói về đơn vị triển khai, không nói về chất lượng code bên trong. Một monolith hoàn toàn có thể được tổ chức thành các module rõ ràng, tách bạch, có ranh giới chặt chẽ bên trong. Ngược lại, một hệ microservices tồi vẫn có thể là một "distributed monolith" — nghĩa là các service dính chặt vào nhau, deploy phải đồng bộ, nhưng lại gánh thêm toàn bộ chi phí mạng. Đừng nhầm "monolith" với "spaghetti code". Đó là hai chuyện khác nhau.
Vì sao monolith vẫn phổ biến
Đơn giản trong phát triển. Một repository, một codebase, một pipeline build. Lập trình viên mới vào chỉ cần clone một repo là chạy được toàn bộ hệ thống trên máy local. Không cần dựng 15 service, không cần Docker Compose khổng lồ, không cần mock các service phụ thuộc. Với đội nhỏ, khoản tiết kiệm về "chi phí nhận thức" (cognitive load) này là cực kỳ lớn.
Đơn giản trong debug và test. Khi một request đi qua hệ thống, bạn có thể đặt breakpoint và trace toàn bộ luồng trong một process. Stack trace liền mạch từ đầu đến cuối. So với việc phải ghép log từ 6 service khác nhau, tương quan trace-id qua mạng, thì đây là trời và đất. Integration test cũng dễ hơn nhiều vì không cần dựng cả một hạ tầng distributed.
Hiệu năng cao nhờ in-process call. Gọi một hàm trong cùng process mất vài nanosecond. Gọi qua mạng (dù là gRPC) mất từ vài trăm microsecond đến vài millisecond, cộng thêm rủi ro timeout, retry, serialize/deserialize. Với monolith, bạn không phải trả "thuế mạng" cho mỗi lần các thành phần nói chuyện với nhau.
Consistency dữ liệu dễ dàng. Vì thường dùng chung một database, bạn có thể dùng transaction ACID của chính database đó để đảm bảo tính nhất quán. Không cần đến các pattern phức tạp như Saga (mà chúng ta sẽ học ở bài 25) để xử lý transaction trải dài nhiều service.
Deploy đơn giản. Một artifact, một lần deploy. Không có bài toán "service A phiên bản mới có tương thích với service B phiên bản cũ không".
Đánh đổi (trade-off) của monolith
Không có kiến trúc nào miễn phí. Monolith có những giới hạn thật:
- Scaling thô. Bạn phải scale cả khối, kể cả khi chỉ có một phần nhỏ đang bị nghẽn. Không thể scale riêng module thanh toán mà giữ nguyên module còn lại.
- Deploy toàn khối. Sửa một dòng ở module báo cáo cũng phải deploy lại toàn bộ. Với codebase lớn, build và deploy có thể chậm.
- Ranh giới dễ bị xói mòn. Vì mọi thứ ở cùng một chỗ, lập trình viên dễ "đi tắt" — gọi thẳng vào bảng dữ liệu của module khác, tạo ra sự phụ thuộc chằng chịt nếu không có kỷ luật.
- Giới hạn công nghệ. Cả hệ thống bị ràng buộc vào một stack ngôn ngữ/framework. Khó dùng Go cho phần cần hiệu năng cao và Python cho phần ML trong cùng một monolith.
- Bán kính ảnh hưởng của lỗi. Một memory leak hay một vòng lặp vô hạn ở một module có thể kéo sập cả process.
Tình huống thực tế
Ví dụ 1 — Startup fintech Việt Nam khởi đầu đúng đắn
Hãy tưởng tượng một startup fintech tại TP.HCM, gọi là "VíXanh", cung cấp ví điện tử cho tiểu thương. Đội kỹ sư ban đầu chỉ có 4 người. Nhà sáng lập kỹ thuật vừa đọc nhiều bài về Netflix, Uber và tin rằng "phải làm microservices từ đầu để sau này khỏi phải viết lại".
Họ chia hệ thống thành 9 service: user-service, wallet-service, transaction-service, notification-service, kyc-service, và vài service khác. Kết quả sau 6 tháng: một tính năng đơn giản như "chuyển tiền có gửi thông báo" phải sửa 4 service, phối hợp 4 lần deploy, và mất 2 ngày để debug vì một service gọi service kia bị timeout ngẫu nhiên. Đội 4 người dành phần lớn thời gian cho hạ tầng thay vì làm sản phẩm. Tốc độ ra tính năng chậm đến mức nhà đầu tư bắt đầu lo lắng.
Bài học rút ra: Với đội 4 người và một sản phẩm chưa tìm được product-market fit, độ phức tạp vận hành của microservices lớn hơn nhiều so với giá trị nó mang lại. Nếu VíXanh khởi đầu bằng một monolith tổ chức tốt — chia module theo nghiệp vụ (ví, giao dịch, KYC) nhưng deploy chung — họ sẽ giao tính năng nhanh gấp nhiều lần, và việc tách service (nếu cần) hoàn toàn có thể làm sau khi đã hiểu rõ đâu là điểm nghẽn thật sự.
Ví dụ 2 — Shopify: monolith ở quy mô khổng lồ
Shopify là ví dụ kinh điển đập tan định kiến "quy mô lớn thì phải microservices". Nền tảng thương mại điện tử này phục vụ hàng triệu cửa hàng, xử lý hàng chục tỷ đô-la giao dịch mỗi năm, chịu tải khủng khiếp trong các đợt Black Friday. Vậy mà cốt lõi của Shopify vẫn là một monolith Ruby on Rails — một trong những codebase Rails lớn nhất thế giới.
Điều Shopify làm khác biệt không phải là chia nhỏ thành microservices, mà là đầu tư mạnh vào việc tổ chức bên trong monolith: họ áp dụng modular monolith với các "component" có ranh giới rõ ràng, ép buộc bằng công cụ để một module không được tùy tiện gọi vào ruột của module khác. Họ scale bằng cách chạy nhiều bản sao (replica) của cùng một monolith sau load balancer, và sharding database theo shop.
Bài học rút ra: Vấn đề của một codebase lớn không nhất thiết phải giải bằng cách bẻ nó thành nhiều service triển khai riêng. Kỷ luật về module bên trong một monolith có thể giải quyết phần lớn nỗi đau về khả năng bảo trì, mà không phải trả giá bằng độ phức tạp mạng. Quy mô lớn không tự động đồng nghĩa với việc phải rời bỏ monolith.
Ví dụ 3 — Sàn thương mại điện tử vừa tầm ở Đông Nam Á
Một công ty bán lẻ trực tuyến quy mô vừa ở Đông Nam Á, tạm gọi "ChợNhanh", có đội kỹ sư khoảng 25 người. Họ chạy monolith và mọi thứ vẫn ổn cho đến khi mảng thanh toán trở thành điểm đau: mỗi lần deploy monolith (khá lớn, build mất 25 phút), cả team thanh toán phải chờ, và một lỗi ở module đề xuất sản phẩm từng làm sập luôn cả luồng checkout trong giờ cao điểm.
Họ không vội vàng chuyển toàn bộ sang microservices. Thay vào đó, họ giữ monolith làm nòng cốt và chỉ tách ra một service duy nhất: payment-service — vì nó có yêu cầu về độ tin cậy, tuân thủ PCI-DSS, và nhịp thay đổi khác hẳn phần còn lại. Phần còn lại của hệ thống vẫn là monolith.
Bài học rút ra: Quyết định monolith hay không không phải là lựa chọn "tất cả hoặc không có gì". Cách tiếp cận trưởng thành là giữ monolith làm mặc định và chỉ tách ra khi có lý do cụ thể, đo được (điểm nghẽn scaling thật, ranh giới tuân thủ, nhịp thay đổi khác biệt rõ rệt). Đây gọi là cách tiếp cận "monolith-first".
Hướng dẫn từng bước
Khi đứng trước quyết định "có nên chọn monolith cho dự án này không", hãy đi qua các bước sau:
Bước 1 — Đánh giá quy mô và độ trưởng thành của đội. Đội dưới 15–20 kỹ sư gần như luôn nên bắt đầu bằng monolith. Chi phí phối hợp giữa các team là thứ biện minh cho việc tách service — mà đội nhỏ thì chưa có vấn đề đó. Hãy trung thực: bạn đang có bao nhiêu người vận hành hạ tầng?
Bước 2 — Đánh giá độ chắc chắn của domain. Nếu sản phẩm còn đang tìm product-market fit, ranh giới nghiệp vụ (bounded context — bài 13) còn đang dịch chuyển liên tục. Vẽ ranh giới service quá sớm khi chưa hiểu domain là công thức cho đau khổ, vì di chuyển ranh giới giữa các service tốn kém gấp bội so với refactor trong một monolith.
Bước 3 — Xác định các yêu cầu phi chức năng thật sự (NFR). Bạn có thật sự cần scale độc lập từng phần không? Có phần nào cần công nghệ khác hẳn không? Có ranh giới tuân thủ (như PCI, dữ liệu y tế) buộc phải cách ly không? Nếu câu trả lời là "không" cho tất cả, monolith là lựa chọn mặc định hợp lý.
Bước 4 — Nếu chọn monolith, hãy thiết kế nó có kỷ luật. Chia code thành các module theo nghiệp vụ, không theo tầng kỹ thuật. Định nghĩa interface rõ ràng giữa các module. Dùng công cụ để ép buộc ranh giới (ví dụ static analysis chặn import chéo). Đây chính là con đường dẫn tới modular monolith (bài 18), giúp việc tách service sau này — nếu cần — trở nên dễ dàng.
Bước 5 — Ghi lại quyết định bằng một ADR. Nhớ lại bài 4 và 5: hãy viết một Architecture Decision Record nêu rõ tại sao bạn chọn monolith, trong bối cảnh nào, và điều kiện nào sẽ khiến bạn xem xét lại (ví dụ: "khi đội vượt 30 người" hoặc "khi build vượt 30 phút"). Điều này giúp quyết định của bạn minh bạch và có thể xem lại một cách khách quan.
Lỗi thường gặp & mẹo
Lỗi 1 — Chọn microservices vì sợ "bị chê là lạc hậu". Đây là quyết định dựa trên cảm xúc và định kiến, không dựa trên bối cảnh. Kiến trúc phải phục vụ đội và sản phẩm, không phục vụ cái tôi kỹ thuật. Mẹo: luôn tự hỏi "vấn đề cụ thể nào của tôi mà microservices giải được, mà monolith không giải được?". Nếu không trả lời được rõ ràng, hãy chọn monolith.
Lỗi 2 — Đánh đồng monolith với code lộn xộn. Nhiều người viết monolith cẩu thả rồi kết luận "monolith không mở rộng được". Vấn đề là ở kỷ luật thiết kế, không phải ở kiến trúc triển khai. Mẹo: đầu tư vào ranh giới module ngay từ đầu, coi mỗi module như một "service tương lai tiềm năng".
Lỗi 3 — Không đặt sẵn điều kiện thoát. Chọn monolith không có nghĩa là "mãi mãi monolith". Sai lầm là không định nghĩa trước dấu hiệu nào cho biết đã đến lúc tách. Mẹo: trong ADR, ghi rõ các "trigger" định lượng — thời gian build, số kỹ sư, tần suất xung đột deploy.
Lỗi 4 — Chia module theo tầng kỹ thuật thay vì theo nghiệp vụ. Nếu bạn tổ chức code thành các gói "controllers", "services", "repositories" khổng lồ, bạn sẽ có một monolith khó tách. Mẹo: tổ chức theo nghiệp vụ (order, payment, inventory), mỗi nghiệp vụ chứa đủ các tầng của riêng nó.
Lỗi 5 — Bỏ qua chiến lược database. Ngay cả trong monolith, nếu mọi module đọc/ghi bừa bãi vào bảng của nhau, bạn sẽ khóa chặt chúng lại. Mẹo: mỗi module chỉ nên "sở hữu" bảng của mình; module khác muốn dùng thì gọi qua interface, không truy cập trực tiếp bảng.
Bài tập thực hành
Bài tập 1 — Phân tích quyết định. Cho tình huống: một đội 6 người xây dựng ứng dụng đặt lịch khám bệnh cho phòng khám tư ở Hà Nội. Sản phẩm chưa ra mắt, chưa có người dùng. Hãy viết một đoạn khuyến nghị (khoảng 150–200 từ) lập luận vì sao họ nên chọn monolith, nêu ít nhất 3 lý do dựa trên các yếu tố trong bài (quy mô đội, độ chắc chắn domain, NFR).
Bài tập 2 — Viết ADR ngắn. Áp dụng cấu trúc ADR đã học, hãy viết một ADR cho quyết định "Bắt đầu bằng monolith cho sản phẩm X". ADR phải có: Bối cảnh, Quyết định, Hệ quả, và đặc biệt là phần "Điều kiện xem xét lại" với ít nhất 2 trigger định lượng.
Bài tập 3 — Thiết kế module. Cho một hệ thống thương mại điện tử đơn giản gồm: quản lý người dùng, giỏ hàng, đơn hàng, thanh toán, kho. Hãy phác thảo cách bạn chia monolith này thành các module theo nghiệp vụ, chỉ rõ module nào sở hữu bảng dữ liệu nào, và module nào có nguy cơ cần tách ra service riêng đầu tiên nếu hệ thống lớn lên. Giải thích lựa chọn.
Tóm tắt
Monolith là một kiến trúc trong đó toàn bộ ứng dụng — UI, business logic, data access — được đóng gói và triển khai như một đơn vị duy nhất. Đây không phải lựa chọn lạc hậu, mà là một quyết định kiến trúc hợp lệ và thường là tối ưu, đặc biệt với đội nhỏ, domain chưa ổn định, và không có yêu cầu bắt buộc phải scale hay cách ly độc lập.
Những điểm cần khắc cốt ghi tâm:
- Monolith nói về đơn vị triển khai, không phải chất lượng code. Một monolith có thể được tổ chức module cực kỳ sạch sẽ.
- Ưu điểm cốt lõi: đơn giản trong phát triển, debug, test; hiệu năng cao nhờ in-process call; consistency dữ liệu dễ nhờ transaction ACID; deploy đơn giản.
- Đánh đổi: scaling thô, deploy toàn khối, ranh giới dễ xói mòn, khóa vào một stack, bán kính lỗi rộng.
- Nguyên tắc "monolith-first": bắt đầu bằng monolith có kỷ luật, chỉ tách service khi có lý do cụ thể và đo được. Ngay cả Shopify, GitHub, Stack Overflow đều vận hành monolith ở quy mô lớn.
- Luôn ghi lại quyết định bằng ADR, kèm điều kiện định lượng để xem xét lại trong tương lai.