Design review thứ Năm, hai senior engineer tranh luận trước whiteboard. Một bên muốn tách service payment ra khỏi monolith vì “nó deploy riêng được, scale riêng được”. Bên kia phản bác: “team có 6 người, tách ra thì ai maintain service mới? Debugging phải trace qua hai service thay vì đọc stack trace một chỗ.” CTO ngồi nghe, cuối cùng hỏi: “Các bạn quyết định dựa trên tiêu chí nào?”

Câu hỏi đó không có đáp án mặc định. Monolith không xấu — Shopify chạy monolith Ruby on Rails phục vụ hàng triệu merchant. Microservice không tự động tốt — nhiều startup 10 người chia 15 service rồi dành nửa thời gian debug distributed system thay vì ship feature. Vấn đề không phải “kiến trúc nào đúng” mà là “kiến trúc nào phù hợp với context hiện tại” — team size, deployment cadence, ranh giới domain, và khả năng vận hành.

Bài này đặt ra framework đánh giá thực tế để trả lời câu hỏi “tách hay giữ”, không dựa trên dogma mà dựa trên tín hiệu đo được từ hệ thống và tổ chức.


Monolith không phải là vấn đề

Từ “monolith” thường mang hàm ý tiêu cực trong các buổi tech talk — gợi hình ảnh codebase khổng lồ, deploy mất 2 tiếng, một bug nhỏ kéo sập toàn hệ thống. Nhưng hình ảnh đó mô tả big ball of mud, không phải monolith. Monolith chỉ đơn giản nghĩa là toàn bộ ứng dụng được deploy như một đơn vị duy nhất — một binary, một container, một process. Điều đó không hàm ý code phải lộn xộn hay không có cấu trúc.

Một monolith được thiết kế tốt có ranh giới module rõ ràng bên trong, có interface giữa các domain, có thể test từng phần độc lập. Shopify, GitHub (giai đoạn đầu), Stack Overflow — đều là monolith phục vụ traffic khổng lồ. Stack Overflow nổi tiếng với việc phục vụ hàng triệu request/ngày trên vài server .NET, codebase monolith, team nhỏ. Họ không tách microservice vì không cần — deployment đơn giản, debug nhanh, team đủ nhỏ để coordinate trong một codebase.

Lợi thế cốt lõi của monolith nằm ở đơn giản hoá vận hành. Một deployment pipeline, một bộ log, một process để monitor. Function call giữa các module là in-process — không có network latency, không có serialization overhead, không có partial failure. Transaction database là local — BEGIN; UPDATE orders; UPDATE inventory; COMMIT; chạy trong một connection, ACID đảm bảo, không cần saga hay eventual consistency. Khi có bug, stack trace chỉ thẳng từ HTTP handler xuống database query — không cần trace ID, không cần correlation, không cần mở 3 tab Jaeger.

Đây không phải ưu điểm nhỏ. Mỗi thứ kể trên — distributed tracing, saga pattern, service mesh — đều là chi phí mà microservice bắt buộc phải trả. Nếu hệ thống chưa cần tách, trả chi phí đó là lãng phí engineering effort.


Modular monolith — lựa chọn bị bỏ quên

Giữa monolith thuần và microservice có một vùng mà nhiều team bỏ qua: modular monolith. Ý tưởng đơn giản — deploy như một đơn vị nhưng cấu trúc code theo module có ranh giới rõ ràng, mỗi module có public interface, không truy cập trực tiếp internal của module khác.

graph TB subgraph "Modular Monolith (1 deployment unit)" direction TB API[API Gateway Layer] subgraph "Order Module" OC[Order Controller] OS[Order Service] OR[Order Repository] end subgraph "Payment Module" PC[Payment Controller] PS[Payment Service] PR[Payment Repository] end subgraph "Inventory Module" IC[Inventory Controller] IS[Inventory Service] IR[Inventory Repository] end DB[(Shared Database)] API --> OC API --> PC API --> IC OS -->|"public interface"| PS OS -->|"public interface"| IS OC --> OS --> OR --> DB PC --> PS --> PR --> DB IC --> IS --> IR --> DB end

So sánh với microservice, ranh giới tương tự nhưng triển khai khác hoàn toàn:

graph TB subgraph "Microservices (N deployment units)" direction TB GW[API Gateway] subgraph "Order Service" OC2[Controller] OS2[Service] OR2[Repository] ODB[(Order DB)] OC2 --> OS2 --> OR2 --> ODB end subgraph "Payment Service" PC2[Controller] PS2[Service] PR2[Repository] PDB[(Payment DB)] PC2 --> PS2 --> PR2 --> PDB end subgraph "Inventory Service" IC2[Controller] IS2[Service] IR2[Repository] IDB[(Inventory DB)] IC2 --> IS2 --> IR2 --> IDB end GW --> OC2 GW --> PC2 GW --> IC2 OS2 -->|"HTTP/gRPC"| PS2 OS2 -->|"HTTP/gRPC"| IS2 end

Trong modular monolith, giao tiếp giữa Order và Payment là function call qua public interface — nhanh, type-safe, không có partial failure. Trong microservice, giao tiếp đó là HTTP/gRPC call qua network — thêm latency, thêm failure mode (timeout, retry, circuit breaker), thêm serialization. Cả hai đều có ranh giới domain rõ ràng, nhưng chi phí vận hành khác nhau một trời một vực.

Modular monolith cho phép team xây dựng ranh giới module trước, kiểm chứng ranh giới đó có đúng không qua thực tế phát triển, rồi mới tách service khi có tín hiệu rõ ràng. Đây là con đường an toàn hơn nhiều so với tách microservice từ ngày đầu rồi phát hiện ranh giới sai — merge hai service lại khó hơn rất nhiều so với tách một module ra.

Quy tắc enforce ranh giới trong modular monolith: mỗi module expose interface (hoặc facade) duy nhất, các module khác chỉ import từ interface đó. Không import trực tiếp internal class, không query trực tiếp bảng của module khác. Nhiều ngôn ngữ hỗ trợ enforce ở mức tooling: Java có module system (JPMS), .NET có project reference rules, Go có package visibility, TypeScript/Node có exports field trong package.json nội bộ. Nếu ngôn ngữ không enforce, dùng linting rule (ArchUnit cho Java, dependency-cruiser cho Node) để phát hiện vi phạm ranh giới trong CI.

Schema database trong modular monolith có hai trường phái. Trường phái thứ nhất: shared database, mỗi module “sở hữu” một tập bảng, không module nào query trực tiếp bảng của module khác mà phải gọi qua interface. Enforce bằng convention hoặc schema prefix (order_, payment_). Trường phái thứ hai: shared database nhưng schema riêng per module — enforce cứng hơn vì permission database có thể giới hạn module chỉ truy cập schema của mình. Cả hai đều hoạt động; quan trọng là có quy tắc rõ ràng và enforce được, không phải “nói miệng rồi ai cũng join bảng lung tung”.


Conway’s Law — team size quyết định kiến trúc

Melvin Conway viết năm 1967: “Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations.” Nói đơn giản: kiến trúc hệ thống phản ánh cấu trúc tổ chức. Một team 6 người ngồi cùng phòng tự nhiên sẽ tạo ra monolith — vì giao tiếp dễ, coordinate nhanh, không cần protocol formal. Ba team 5 người ở ba timezone tự nhiên sẽ tạo ra ba service — vì mỗi team cần autonomy để ship mà không chờ team khác.

Đây không phải observation trừu tượng — nó là lực mạnh nhất quyết định kiến trúc nào thực sự hoạt động trong tổ chức.

Team nhỏ (dưới 8-10 người)

Với team nhỏ, monolith (hoặc modular monolith) gần như luôn là lựa chọn tốt hơn. Lý do không phải kỹ thuật mà là chi phí cognitive và vận hành trên đầu người.

Mỗi service thêm vào hệ thống là thêm một deployment pipeline cần duy trì, thêm một bộ config cần quản lý, thêm một target cần monitor, thêm potential failure point cần xử lý. Với team 6 người, mỗi người vừa viết feature vừa maintain infrastructure, thời gian dành cho “giữ hệ thống chạy” ăn vào thời gian “ship giá trị cho user”. Hai service nghĩa là hai lần effort đó. Năm service nghĩa là team dành phần lớn thời gian vận hành thay vì phát triển.

Hơn nữa, team nhỏ thường có deploy cadence tương tự cho toàn bộ hệ thống — không có tình huống “module A cần deploy 10 lần/ngày trong khi module B deploy 1 lần/tháng”. Khi deploy cadence đồng nhất, lợi ích chính của microservice (independent deployability) biến mất, chỉ còn lại chi phí.

Team vừa (2-4 team, 15-40 người)

Đây là vùng mà quyết định bắt đầu phức tạp. Ở quy mô này, coordination cost giữa các team trở thành bottleneck thực sự. Nếu team Order và team Payment cùng sửa monolith, merge conflict tăng, release train phải đợi nhau, một bug của team này block deploy của team kia.

Tín hiệu cần chú ý: nếu hai team thường xuyên phải coordinate merge vào cùng module, hoặc deploy bị delay vì chờ team khác fix bug, hoặc on-call phải hiểu code của team khác để debug — đó là lúc tách service bắt đầu có giá trị. Nhưng không phải tách tất cả — chỉ tách phần nào có ranh giới domain rõ, có deploy cadence khác biệt, và team có đủ người để own service riêng.

Tổ chức lớn (5+ team, 50+ người)

Ở quy mô này, microservice thường là kết quả tự nhiên của cấu trúc tổ chức. Mỗi team own một hoặc vài service, deploy độc lập, on-call riêng. Lợi ích rõ nhất không phải kỹ thuật mà là organisational: team có autonomy, không bị block bởi team khác, có thể chọn tech stack phù hợp (dù thực tế ít team làm vậy vì chi phí maintain nhiều stack).

Nhưng ngay cả ở quy mô lớn, không phải mọi thứ nên là service riêng. Shared library, platform service (auth, notification, logging) thường được vài team chung maintain. Và nguyên tắc “mỗi service phải có team own” vẫn đúng — service không có owner rõ ràng là service sẽ rotten.


Deployment coupling vs domain coupling

Hai khái niệm này thường bị nhầm lẫn nhưng khác nhau hoàn toàn, và hiểu rõ chúng là chìa khoá để quyết định tách hay giữ.

Deployment coupling là khi thay đổi ở module A bắt buộc phải deploy lại module B cùng lúc. Trong monolith, mọi module deployment coupled by definition — deploy là deploy cả cục. Trong microservice, mỗi service deploy độc lập (trên lý thuyết). Nhưng nếu service A và B chia sẻ schema database, và mỗi lần đổi schema phải deploy cả A lẫn B cùng lúc, thì dù tách service nhưng deployment coupling vẫn còn — và bạn có distributed monolith, thứ tệ nhất của cả hai thế giới.

Domain coupling là khi logic nghiệp vụ của module A phụ thuộc vào hành vi của module B. Order cần biết Inventory còn hàng hay không trước khi tạo đơn — đó là domain coupling tự nhiên, không thể loại bỏ vì nó phản ánh thực tế kinh doanh. Domain coupling không xấu — nó là bản chất của hệ thống. Nhưng domain coupling mạnh (A gọi B synchronously, B gọi C synchronously, fail ở C cascade lên A) thì nguy hiểm hơn khi ở dạng distributed so với in-process.

Nguyên tắc quan trọng: giảm deployment coupling, chấp nhận domain coupling. Tách service để deploy độc lập — nhưng chỉ khi domain coupling giữa chúng đủ loose để cho phép điều đó. Nếu hai module có domain coupling chặt (luôn phải gọi nhau synchronously, share entity, chung transaction boundary), tách chúng thành hai service chỉ tạo thêm network hop mà không giảm coupling — thậm chí tệ hơn vì giờ phải xử lý distributed transaction.

Cách kiểm tra domain coupling trước khi tách: vẽ dependency graph giữa các module. Nếu module A gọi module B ở mọi request path quan trọng, tách chúng nghĩa là mọi request quan trọng đều có network call — latency tăng, failure mode tăng. Nếu A chỉ gọi B ở một số flow phụ hoặc async (đặt đơn xong mới gửi notification), tách sẽ ít đau hơn.


Tín hiệu cần tách — khi nào tách có giá trị

Tách service không nên là quyết định dựa trên cảm tính “monolith đã quá lớn” mà nên dựa trên tín hiệu đo được. Dưới đây là những tín hiệu mà khi xuất hiện đồng thời, việc tách bắt đầu có giá trị thực sự.

Deploy cadence khác biệt rõ rệt

Khi team Payment cần deploy 5 lần/ngày để iterate nhanh tính năng mới, nhưng mỗi lần deploy phải kéo theo toàn bộ monolith (bao gồm cả module Inventory vốn ổn định, deploy 1 lần/tuần) — deploy cadence khác biệt là tín hiệu mạnh. Mỗi deploy mang theo rủi ro cho code không liên quan; test suite chạy lâu hơn cần thiết vì test cả phần không đổi; rollback ảnh hưởng nhiều hơn phạm vi thay đổi.

Nhưng cần phân biệt: deploy cadence khác biệt vì nhu cầu thực (Payment iterate nhanh vì thị trường) hay vì quy trình chưa tốt (deploy chậm vì CI pipeline chưa tối ưu, test flaky, review bottleneck)? Nếu lý do là quy trình, fix quy trình rẻ hơn tách service nhiều.

Scaling needs khác biệt

Module Search cần 20 instance để handle QPS cao trong giờ cao điểm, nhưng module Admin chỉ cần 1 instance vì chỉ vài chục internal user dùng. Trong monolith, scale nghĩa là scale toàn bộ — 20 instance monolith dù Admin chỉ cần 1. Tốn resource nhưng đơn giản. Tách Search thành service riêng cho phép scale chỉ Search, tiết kiệm infra cost.

Tuy nhiên, “tiết kiệm cost” phải lớn hơn “chi phí vận hành thêm service”. Nếu 20 instance monolith tốn thêm $200/tháng so với tách service, nhưng tách service tốn 2 tuần engineering effort + chi phí maintain ongoing — thì giữ monolith rẻ hơn.

Team autonomy bị cản trở

Đây thường là tín hiệu mạnh nhất vì nó ảnh hưởng đến con người, không chỉ máy móc. Khi team Order muốn thử nghiệm Go cho module mới nhưng monolith viết bằng Python, khi team Payment cần deploy hotfix nhưng phải chờ team Inventory merge PR đang review, khi on-call Payment phải debug code Inventory lúc 2 giờ sáng vì deploy chung — autonomy bị cản trở là vấn đề tổ chức mà kiến trúc có thể giải quyết.

Fault isolation

Khi bug ở module Recommendation (không critical) kéo sập toàn bộ monolith (bao gồm cả Checkout, rất critical), fault isolation trở thành yêu cầu. Trong monolith, một memory leak ở bất kỳ module nào đều ảnh hưởng toàn process. Tách service critical (Checkout, Payment) ra khỏi service non-critical (Recommendation, Analytics) giảm blast radius — bug Recommendation chỉ ảnh hưởng Recommendation, Checkout vẫn chạy.

Kiểm tra tổng hợp

Không tín hiệu nào đứng một mình là đủ để quyết định tách. Deploy cadence khác biệt nhưng team chỉ có 5 người — giữ monolith và tối ưu CI pipeline. Scaling khác biệt nhưng domain coupling rất chặt — scale toàn bộ rẻ hơn xử lý distributed transaction. Cần ít nhất 2-3 tín hiệu xuất hiện đồng thời, và team có đủ năng lực vận hành microservice, thì tách mới có giá trị.


Tín hiệu không nên tách — giữ monolith

Có những tình huống mà bản năng nói “tách đi” nhưng phân tích kỹ thì giữ lại tốt hơn.

Shared database schema chưa tách được

Hai module dùng chung bảng, join trực tiếp trong query, share foreign key — tách thành hai service nghĩa là mỗi service cần database riêng, và mọi join trở thành API call. Query SELECT o.*, p.status FROM orders o JOIN payments p ON o.id = p.order_id trở thành: service Order gọi API Payment lấy status, rồi ghép data ở application layer. Latency tăng, code phức tạp hơn, consistency model thay đổi từ strong (database join) sang eventual (API call có thể trả data cũ).

Nếu chưa tách được schema — chưa tách service. Tách schema trước (trong monolith, module Payment chỉ access bảng payments qua repository pattern, không join trực tiếp), chạy ổn một thời gian, rồi mới tách service. Thứ tự này giảm rủi ro đáng kể.

Team chưa có nền tảng vận hành

Microservice yêu cầu nền tảng vận hành tối thiểu: CI/CD pipeline per service, centralized logging, distributed tracing, health check endpoint, circuit breaker, service discovery. Nếu team chưa có những thứ này — hoặc đang dùng nhưng chưa thành thạo — tách service sẽ tạo ra hệ thống mà không ai debug được. Bug production từ “đọc stack trace” thành “mở Jaeger, tìm trace, xem span nào lỗi, rồi mở Loki tìm log với trace ID” — nếu Jaeger hay Loki chưa setup, debug trở thành mò.

Xây nền tảng vận hành trước, rồi mới tách service. Thứ tự ngược lại là nguồn gốc của rất nhiều incident “không ai biết chuyện gì xảy ra”.

Tách vì “clean code” thay vì nhu cầu thực

“Module này đã quá lớn, 50,000 dòng code” — nghe có lý nhưng không phải lý do đủ để tách service. Module lớn có thể refactor bên trong monolith: tách file, tách class, tách package, cải thiện interface. Tách service chỉ vì code lớn mà không có tín hiệu deploy cadence, scaling, hay team autonomy là premature decomposition — trả chi phí distributed system mà không nhận lợi ích nào.

Domain boundary chưa rõ ràng

Nếu team vẫn đang tranh luận “Order nên chứa thông tin shipping hay Shipping là module riêng?”, ranh giới domain chưa ổn định. Tách service khi boundary chưa rõ nghĩa là ranh giới sẽ cần thay đổi sau — và thay đổi ranh giới service (chuyển API, migrate data, đổi contract) đắt hơn rất nhiều so với thay đổi ranh giới module trong monolith (move file, rename package).

Dùng modular monolith để thử nghiệm ranh giới. Khi boundary ổn định qua 3-6 tháng phát triển thực tế, lúc đó tách service với confidence cao hơn.


Strangler fig — tách dần thay vì big bang

Khi quyết định tách, cách an toàn nhất là strangler fig pattern — lấy tên từ loại cây đa bóp chết cây chủ từ từ. Thay vì viết lại toàn bộ hệ thống (big bang rewrite — tỷ lệ thất bại rất cao), tách từng phần nhỏ ra service mới, route traffic dần sang service mới, cho đến khi monolith co lại hoặc biến mất.

flowchart LR subgraph "Phase 1: Facade" Client1[Client] --> Proxy1[Proxy/Gateway] Proxy1 -->|100%| Mono1[Monolith] end subgraph "Phase 2: Shadow + Canary" Client2[Client] --> Proxy2[Proxy/Gateway] Proxy2 -->|95%| Mono2[Monolith] Proxy2 -->|5%| New2[New Service] end subgraph "Phase 3: Cutover" Client3[Client] --> Proxy3[Proxy/Gateway] Proxy3 -->|0%| Mono3[Monolith - deprecated] Proxy3 -->|100%| New3[New Service] end

Bước đầu tiên: đặt proxy (hoặc API gateway, hoặc routing layer trong monolith) trước endpoint cần tách. Tất cả traffic vẫn đi vào monolith — proxy chỉ là lớp routing, chưa thay đổi hành vi gì. Bước này kiểm chứng proxy hoạt động đúng mà không ảnh hưởng user.

Bước thứ hai: xây service mới, triển khai logic tương đương. Route một phần nhỏ traffic (1-5%) sang service mới, so sánh response với monolith (shadow testing hoặc canary). Nếu response khớp và metric (latency, error rate) chấp nhận được, tăng dần phần trăm. Nếu có vấn đề, route 100% về monolith — rollback an toàn, không cần deploy.

Bước thứ ba: khi service mới đã nhận 100% traffic ổn định, xoá code cũ trong monolith và retire endpoint cũ ở proxy.

Điều quan trọng nhất trong strangler fig: không rush. Mỗi phase có thể kéo dài tuần đến tháng. Pressure “tách nhanh cho xong” thường dẫn đến bỏ qua shadow testing, bỏ qua canary, rồi phát hiện data inconsistency khi đã cutover 100%. Rollback lúc đó phức tạp hơn nhiều vì service mới có thể đã ghi data riêng.

Data migration trong strangler fig

Data là phần khó nhất khi tách service. Có ba strategy phổ biến.

Shared database (tạm thời): service mới vẫn đọc/ghi vào database cũ trong giai đoạn chuyển đổi. Ưu điểm: đơn giản, data nhất quán, không cần sync. Nhược điểm: deployment coupling vẫn còn vì schema change ảnh hưởng cả hai; đây chỉ là bước trung gian, không phải đích đến.

Change Data Capture (CDC): dùng Debezium, Maxwell, hay tương tự để stream changes từ database cũ sang database mới. Service mới đọc từ database riêng, ghi vào database riêng. Data sync qua CDC với độ trễ vài giây. Ưu điểm: service mới có database riêng, truly independent. Nhược điểm: eventual consistency, CDC pipeline cần monitor và maintain.

Dual write (cẩn thận): service mới ghi vào cả database cũ và mới. Nghe đơn giản nhưng rất dễ gặp inconsistency — nếu ghi database cũ thành công nhưng ghi database mới fail (hoặc ngược lại), data drift. Dùng dual write chỉ khi có cơ chế reconciliation tự động để phát hiện và fix drift.


Chi phí thực sự của microservice

Trước khi quyết định tách, team cần hiểu rõ những chi phí mà microservice bắt buộc phải trả — không phải để sợ mà để budget đúng.

Distributed debugging

Trong monolith, bug report “user click checkout nhưng thấy lỗi” → đọc log file → tìm stack trace → thấy NullPointerException ở dòng 347 → fix. Trong microservice, cùng bug đó → request đi qua gateway, vào Order service, Order gọi Payment service, Payment gọi external gateway → lỗi ở đâu? Phải mở distributed trace, xem span nào fail, rồi mở log của service đó với trace ID.

Distributed debugging không phải không thể — nhưng đòi hỏi tooling (distributed tracing, centralized logging, correlation ID) và skill (đọc trace, hiểu async flow, biết failure mode của network call). Nếu team chưa có cả hai, debug sẽ mất gấp 3-5 lần thời gian so với monolith.

Network failure modes

Function call trong monolith: gọi → trả kết quả. Không có failure mode nào khác ngoài exception từ logic. HTTP/gRPC call giữa service: gọi → timeout? → retry? → retry bao nhiêu lần? → idempotent không? → response 200 nhưng body rỗng? → connection reset giữa chừng, request đã được xử lý hay chưa? → circuit breaker đã mở, trả fallback hay fail?

Mỗi network call giữa service là một cơ hội cho partial failure — request đã được xử lý bởi downstream nhưng response bị mất trên đường về, caller không biết nên retry, downstream xử lý lần hai. Đây là lý do idempotency là bắt buộc trong microservice, không phải nice-to-have.

Data consistency

Monolith: BEGIN TRANSACTION; debit(account_a); credit(account_b); COMMIT; — ACID, đơn giản, data luôn nhất quán.

Microservice: Account service giữ balance, Transaction service giữ lịch sử. Debit account A (service 1) → ghi transaction (service 2). Nếu bước 2 fail sau khi bước 1 thành công, tiền đã trừ nhưng transaction chưa ghi. Cần saga pattern (compensating transaction), hoặc outbox pattern, hoặc event sourcing. Mỗi pattern đều phức tạp hơn nhiều so với database transaction, và đều có edge case cần xử lý.

Không phải mọi flow cần strong consistency — notification sau khi đặt đơn có thể eventual, nhưng thanh toán phải nhất quán. Phân loại flow theo consistency requirement trước khi tách: flow cần strong consistency nên ở trong cùng service (cùng database, cùng transaction boundary); flow chấp nhận eventual consistency có thể cross service boundary.

Observability tax

Mỗi service thêm vào là thêm một nguồn log, thêm metric endpoint cần scrape, thêm span trong trace. Chi phí observability tỷ lệ thuận với số service — không chỉ infra cost (storage, compute cho Prometheus/Loki/Tempo) mà cả cognitive cost (dashboard nhiều hơn, alert nhiều hơn, noise nhiều hơn).

Với 3 service, quản lý 3 dashboard, 10-20 alert rule, vài chục metric mỗi service — con người còn theo dõi nổi. Với 30 service, 30 dashboard, vài trăm alert rule — cần platform team chuyên lo observability. Đó là chi phí tổ chức, không phải chỉ kỹ thuật.

Service mesh và infrastructure

Khi số service tăng lên vài chục, quản lý traffic giữa chúng (routing, load balancing, mTLS, rate limiting, retry policy) trở nên phức tạp. Service mesh (Istio, Linkerd) giải quyết bài toán này bằng cách đẩy logic traffic vào sidecar proxy — nhưng bản thân service mesh là một hệ thống phức tạp cần learn, deploy, và maintain. Istio nổi tiếng với learning curve dốc và debug khó khi có vấn đề.

Không phải mọi hệ thống microservice đều cần service mesh. Với dưới 10 service, client-side load balancing (SDK trong app) và mTLS qua cert manager là đủ. Service mesh thường cần khi vượt 20-30 service và cần policy routing phức tạp.


Anti-pattern: distributed monolith

Đây là kết quả phổ biến nhất khi team tách service mà không hiểu rõ coupling.

Distributed monolith là khi hệ thống có nhiều service nhưng vẫn phải deploy đồng loạt, vẫn share database, vẫn có synchronous call chain dài. Nó mang chi phí của cả monolith (coordinate deploy) lẫn microservice (network failure, distributed debugging) mà không có lợi ích nào của hai bên.

Dấu hiệu nhận biết distributed monolith: deploy service A bắt buộc phải deploy service B cùng lúc vì API contract đổi; nhiều service đọc/ghi vào cùng bảng database; khi service C chậm, toàn bộ hệ thống chậm theo vì synchronous call chain A → B → C; team vẫn phải coordinate release giữa các service.

Nguyên nhân thường gặp: tách service theo tầng kỹ thuật (API service, business logic service, data access service) thay vì theo domain (Order service, Payment service, Inventory service). Tách theo tầng nghĩa là mỗi request đi qua tất cả service — tight coupling hoàn toàn, không service nào deploy được độc lập.

flowchart TD subgraph "Anti-pattern: tách theo tầng kỹ thuật" C[Client] --> API[API Service] API --> BIZ[Business Logic Service] BIZ --> DAL[Data Access Service] DAL --> DB[(Database)] end
flowchart TD subgraph "Đúng: tách theo domain" C2[Client] --> GW[API Gateway] GW --> OS[Order Service + DB] GW --> PS[Payment Service + DB] GW --> IS[Inventory Service + DB] OS -.->|async event| PS OS -.->|async event| IS end

Tách theo domain nghĩa là mỗi service own trọn vẹn một business capability: nhận request, xử lý logic, đọc/ghi data riêng, trả response. Giao tiếp giữa service ưu tiên async (event/message queue) để giảm temporal coupling. Synchronous call chỉ khi cần response ngay lập tức (query data real-time) và phải có circuit breaker + timeout.


Anti-pattern: premature decomposition

Startup ngày đầu, 3 engineer, chưa có user — nhưng kiến trúc đã có 8 service vì “sau này scale lớn sẽ cần”. Kết quả: 3 tháng đầu dành phần lớn thời gian setup Kubernetes, service mesh, CI/CD cho 8 service thay vì ship MVP. Khi cần thay đổi business logic cross-service, mỗi thay đổi ảnh hưởng 3-4 service, cần 3-4 PR, coordinate deploy — trong khi monolith chỉ cần 1 PR.

Premature decomposition thường đến từ hai nguồn: kinh nghiệm trước đó ở công ty lớn (“ở công ty cũ dùng microservice nên ở đây cũng vậy”) hoặc cargo cult từ tech blog (“Netflix dùng microservice nên chúng ta cũng nên”). Cả hai đều bỏ qua context — Netflix có hàng nghìn engineer và platform team riêng; startup 3 người không có.

Quy tắc mà nhiều engineer có kinh nghiệm đồng ý: bắt đầu với monolith (hoặc modular monolith), tách khi có tín hiệu rõ ràng. Martin Fowler gọi đây là “monolith first”. Sam Newman (tác giả “Building Microservices”) cũng khuyến nghị tương tự. Không phải vì microservice xấu — mà vì cần hiểu domain đủ sâu trước khi vẽ ranh giới service, và modular monolith cho phép thử nghiệm ranh giới rẻ hơn.


Framework quyết định

Tổng hợp lại thành flowchart hỗ trợ quyết định. Không phải câu trả lời tuyệt đối — nhưng là cấu trúc để đặt câu hỏi đúng.

flowchart TD Start([Bắt đầu]) --> Q1{Team size > 8-10
và có nhiều team?} Q1 -->|Không| Mono[Giữ Modular Monolith] Q1 -->|Có| Q2{Deploy cadence
khác biệt rõ rệt
giữa các module?} Q2 -->|Không| Mono Q2 -->|Có| Q3{Domain boundary
đã ổn định?
Schema đã tách?} Q3 -->|Chưa| ModMono[Modular Monolith
+ tách schema trước] Q3 -->|Rồi| Q4{Team có nền tảng
vận hành?
CI/CD, tracing, logging?} Q4 -->|Chưa| Platform[Xây platform trước
rồi tách service] Q4 -->|Có| Q5{Scaling needs hoặc
fault isolation thực sự
cần thiết?} Q5 -->|Không rõ| Mono Q5 -->|Rõ ràng| Split[Tách service
bằng Strangler Fig]

Lưu ý flowchart này thiên bảo thủ — mặc định giữ monolith trừ khi có đủ tín hiệu tách. Điều này có chủ đích: chi phí tách sai (distributed monolith, merge lại) cao hơn nhiều so với chi phí giữ monolith lâu hơn cần thiết (chậm ship ở một số team). Sai về phía monolith dễ fix hơn sai về phía microservice.

Checklist trước khi tách

Trước khi tạo service mới, chạy qua danh sách kiểm tra. Team có đủ người own service mới không — ít nhất 2 người hiểu sâu, có thể on-call? Ranh giới domain đã ổn định chưa — module này có bị sửa ranh giới liên tục trong 3 tháng gần nhất không? Schema database đã tách chưa — module mới có join trực tiếp bảng của module khác không? Có CI/CD pipeline sẵn sàng cho service mới không? Có distributed tracing và centralized logging để debug cross-service không? Có monitoring và alerting cho service mới không?

Nếu bất kỳ câu nào trả lời “chưa”, đó là việc cần làm trước khi tách service — không phải lý do để từ chối vĩnh viễn, mà là prerequisite cần hoàn thành.


Evolutionary architecture — không có kiến trúc “đúng vĩnh viễn”

Kiến trúc không phải quyết định một lần rồi giữ mãi. Hệ thống thay đổi, team thay đổi, business thay đổi — kiến trúc phải thay đổi theo. Monolith hôm nay có thể cần tách 2 service sang năm khi team tăng gấp đôi. 10 microservice hôm nay có thể cần merge lại 5 khi 3 team bị cắt giảm.

Điều quan trọng là xây hệ thống cho phép thay đổi kiến trúc với chi phí hợp lý. Modular monolith với ranh giới module rõ ràng cho phép tách service khi cần mà không viết lại từ đầu. Microservice với contract API rõ ràng (protobuf, OpenAPI) cho phép merge lại khi cần mà không mất interface.

Thiết kế module interface (public API của module) trước, triển khai bên trong sau. Interface ổn định là thứ cho phép thay đổi implementation — dù implementation đó là function call trong monolith hay HTTP call giữa service. Đây là nguyên tắc information hiding của David Parnas từ 1972, vẫn đúng 50 năm sau.

Revisit quyết định kiến trúc định kỳ

Mỗi quý, hoặc khi có thay đổi lớn (team size thay đổi, business pivot, traffic pattern đổi), revisit kiến trúc hiện tại. Hỏi: các tín hiệu tách đã xuất hiện chưa? Các service hiện tại có coupling quá chặt không? Có service nào không có owner rõ ràng không? Có team nào đang bị block bởi kiến trúc hiện tại không?

Đây không phải “redesign mỗi quý” — mà là health check để phát hiện sớm khi kiến trúc hiện tại bắt đầu cản trở thay vì hỗ trợ team.


Tóm tắt

Monolith không xấu, microservice không tự động tốt — kiến trúc phù hợp phụ thuộc vào team size, deployment cadence, domain boundary, và khả năng vận hành. Team nhỏ (dưới 8-10 người) gần như luôn nên bắt đầu với modular monolith — chi phí vận hành microservice trên đầu người quá cao so với lợi ích.

Modular monolith là bước trung gian giá trị: ranh giới module rõ ràng, enforce bằng tooling, nhưng deploy và debug đơn giản như monolith. Khi có tín hiệu tách (deploy cadence khác biệt, scaling needs khác biệt, team autonomy bị cản trở, fault isolation cần thiết), tách bằng strangler fig pattern — dần dần, có shadow testing, có rollback plan.

Chi phí microservice không nhỏ: distributed debugging, network failure modes, data consistency phức tạp, observability tax, và infrastructure overhead. Trả chi phí đó chỉ hợp lý khi lợi ích (independent deploy, independent scale, team autonomy) thực sự cần thiết. Distributed monolith — tách service nhưng vẫn deploy đồng loạt, share database, synchronous call chain — là kết quả tệ nhất: trả chi phí cả hai mà không nhận lợi ích nào.

Tách theo domain (Order, Payment, Inventory), không tách theo tầng kỹ thuật (API, Business, Data). Tách schema database trước khi tách service. Xây nền tảng vận hành (CI/CD, tracing, logging) trước khi tách service. Và luôn nhớ: quyết định kiến trúc là quyết định có thể thay đổi — thiết kế để thay đổi rẻ, không phải thiết kế để không bao giờ thay đổi.