Kiến trúc phần mềmSoftware Architecture
Thuộc bộ kiến thức Backend Roadmap.
Tổng quan
Software architecture (kiến trúc phần mềm) là tập hợp các quyết định cấu trúc ở mức cao về một hệ thống: nó được chia thành những phần nào, các phần đó giao tiếp ra sao, dữ liệu nằm ở đâu, và bạn tối ưu cho những phẩm chất nào (performance, scalability, resilience, khả năng bảo trì, security, chi phí). Đây là những quyết định rất tốn kém khi thay đổi về sau — định nghĩa hay được trích dẫn của Grady Booch: “architecture là những quyết định thiết kế quan trọng định hình một hệ thống, trong đó ‘quan trọng’ được đo bằng chi phí thay đổi.”
Với một backend engineer, kiến trúc không phải bài tập vẽ sơ đồ trừu tượng. Nó quyết định trực tiếp:
- Một team ship feature nhanh đến đâu mà không giẫm chân nhau.
- Hệ thống hành xử thế nào dưới tải cao và khi có sự cố.
- Vận hành tốn kém đến mức nào (server, network, gánh nặng on-call).
- Việc hiểu, test và tiến hóa hệ thống khó đến đâu.
Không có kiến trúc nào là “tốt nhất” — mọi lựa chọn đều là trade-off. Một startup ba người và một công ty 300 kỹ sư giải “cùng một bài toán” gần như không bao giờ nên chọn cùng một kiến trúc. Việc của bạn là hiểu các lực đang tác động (quy mô team, độ phức tạp domain, yêu cầu về scale, deadline) và chọn thứ đơn giản nhất thỏa mãn chúng, đồng thời chừa cửa để tiến hóa.
Note này bao gồm: các nguyên tắc nền tảng (coupling, cohesion, separation of concerns, layering); các architectural pattern và style chính (layered, hexagonal, clean, event-driven, CQRS, event sourcing); cuộc tranh luận lớn về topology triển khai (monolith vs microservices vs SOA vs serverless, cùng vùng trung gian modular monolith); những mối quan tâm mà microservices đặt ra (boundary, communication, data ownership, saga, gateway, discovery, service mesh); phương pháp luận Twelve-Factor App; evolutionary architecture và strangler fig; và các design principle kinh điển (SOLID, DRY, KISS, YAGNI).
Note liên quan: APIs · Message Brokers và xử lý Async · Scalability và Reliability.
Kiến thức nền tảng
Trước khi chọn pattern hay topology, hãy thấm nhuần vài nguyên tắc. Mọi kiến trúc tốt đều là sự vận dụng chúng; mọi kiến trúc tồi đều vi phạm chúng.
Coupling và cohesion
Đây là hai từ quan trọng nhất trong thiết kế phần mềm. Gần như mọi quy tắc kiến trúc đều quy về “giảm coupling, tăng cohesion.”
- Coupling — mức độ một module phụ thuộc vào module khác. Khi hai module coupling chặt, thay đổi ở cái này buộc phải sửa cái kia. Coupling cao khiến hệ thống dễ vỡ: thay đổi nhỏ lan rộng, và bạn không thể hiểu hay deploy một module một cách độc lập.
- Cohesion — mức độ các thành phần bên trong một module thuộc về nhau. Cohesion cao nghĩa là module làm một việc được định nghĩa rõ ràng; mọi thứ trong nó phục vụ đúng mục đích đó. Cohesion thấp là module “hổ lốn” (
Utils,Helpers,Manager) làm nhiều việc không liên quan.
Mục tiêu: coupling thấp giữa các module, cohesion cao bên trong mỗi module. Robert C. Martin diễn đạt là “gom lại những thứ thay đổi vì cùng một lý do; tách những thứ thay đổi vì các lý do khác nhau” — chính là tinh thần của Single Responsibility Principle.
| Coupling thấp | Coupling cao | |
|---|---|---|
| Cohesion cao | Lý tưởng — module hóa, dễ thay đổi độc lập | Cứng nhắc — module tốt nhưng dependency rối |
| Cohesion thấp | Rời rạc — logic liên quan bị rải khắp nơi | Tệ nhất — “big ball of mud” |
Các loại coupling, từ lỏng nhất (tốt) đến chặt nhất (tệ): message coupling → data coupling (truyền tham số đơn giản) → stamp coupling (truyền cả struct) → control coupling → shared/global coupling → content coupling (một module thò tay vào ruột module khác). Hãy ưu tiên dạng lỏng nhất đủ dùng.
Separation of concerns
Separation of concerns (SoC) là nguyên tắc chia hệ thống sao cho mỗi phần lo một mối quan tâm riêng biệt (persistence, business rules, presentation, transport). Khi đó mỗi concern có thể được hiểu, thay đổi và test độc lập. Layering (bên dưới) là một cách hiện thực hóa phổ biến; hexagonal architecture là cách khác.
Lợi ích thực tế: bạn có thể đổi database, đổi API framework, hay thay UI mà không phải viết lại business logic — nếu các concern đó đã được tách bạch đúng cách ngay từ đầu.
Layering
Một layer là một lát cắt ngang của trách nhiệm. Quy tắc dependency ràng buộc các layer: layer cao có thể phụ thuộc layer thấp, không bao giờ ngược lại. Một stack backend kinh điển:
- 01Presentation / API (HTTP handlers, controllers)dịch transport ↔ domain
- 02Application / Service (use cases, orchestration)điều phối, transaction
- 03Domain / Business (entities, rules)cái "tại sao" của hệ thống
- 04Persistence / Data (repositories, ORM, queries)nói chuyện với DB
- 05Infrastructure (DB, cache, queues, external APIs)
Layering cho bạn các đường nối rõ ràng để test (mock layer bên dưới), một mental model chung cho team, và dependency được kiểm soát. Rủi ro của nó là anemic domain model và “sinkhole anti-pattern” — request đi xuyên qua mọi layer chỉ để forward, thêm nghi thức mà không thêm giá trị. Layer phải “xứng đáng với chỗ nó chiếm”.
Architecture vs design vs implementation
Một thang đo hữu ích:
- Architecture — phạm vi toàn hệ thống, khó đảo ngược (monolith vs microservices, sync vs async, SQL vs NoSQL).
- Design — mức module, đảo ngược được ở mức vừa phải (chọn pattern nào, cấu trúc class, interface).
- Implementation — mức dòng code, đổi rất rẻ (tên biến, một vòng lặp cụ thể).
Hãy phân bổ “ngân sách suy nghĩ cẩn thận từ trước” tỉ lệ thuận với độ khó đảo ngược. Quyết định kiến trúc xứng đáng có một ADR (Architecture Decision Record): tài liệu ngắn ghi lại bối cảnh, quyết định và hệ quả, commit cùng với code.
Khái niệm chính
Các architectural pattern và style
Chúng mô tả cách bạn tổ chức code bên trong một đơn vị triển khai. Chúng phần lớn độc lập với — và có thể kết hợp cùng — topology triển khai (monolith/microservices) bàn ở phần sau.
Layered / N-tier
Pattern mặc định và được dùng rộng rãi nhất (đã mô tả ở trên). Code được tổ chức thành các layer ngang (presentation, business, persistence). “N-tier” thêm các tier triển khai vật lý (web tier, app tier, DB tier). Đơn giản, quen thuộc, tuyệt cho app nặng CRUD. Điểm yếu: business logic hay rò rỉ vào controller và schema DB hay lái toàn bộ thiết kế (“database-centric”).
Hexagonal (Ports and Adapters)
Do Alistair Cockburn đặt ra. Application core (domain + use case) nằm ở trung tâm và không biết gì về thế giới bên ngoài. Nó định nghĩa các port (interface) cho những gì nó cần (OrderRepository, PaymentGateway) và những gì nó cung cấp (interface use-case). Các adapter bên ngoài hiện thực các port đó cho từng công nghệ cụ thể (adapter Postgres, adapter Stripe, controller HTTP, consumer Kafka).
- HTTP / REST
- CLI
- Message consumer
- Postgres
- Stripe API
- Kafka
- SMTP
Cái được quan trọng: domain phụ thuộc vào abstraction, không phụ thuộc framework hay database. Bạn có thể test core bằng adapter in-memory, và đổi hạ tầng bằng cách viết adapter mới. Đây là hiện thực cụ thể của Dependency Inversion Principle.
Clean Architecture
Bản tổng hợp của Robert C. Martin từ hexagonal, onion và các kiến trúc “dependency-rule” khác. Các vòng tròn đồng tâm: Entities (enterprise rules) → Use Cases (application rules) → Interface Adapters (controllers, presenters, gateways) → Frameworks & Drivers (web, DB, UI). Dependency Rule: dependency ở mức source code chỉ trỏ vào trong. Vòng trong không bao giờ biết về vòng ngoài; qua ranh giới thì dùng interface và DTO. Thực tế rất giống hexagonal, chỉ quy định tên gọi chặt chẽ hơn.
Event-Driven Architecture (EDA)
Các component giao tiếp bằng cách phát ra và tiêu thụ event (“có việc gì đó vừa xảy ra”) thay vì gọi trực tiếp nhau. Một producer phát OrderPlaced; bất kỳ số consumer nào cũng phản ứng (charge payment, reserve stock, gửi email) mà producer không cần biết chúng tồn tại. Hai topology:
- Broker / pub-sub — event chảy qua một broker (Kafka, RabbitMQ, NATS); consumer subscribe. Decoupling cao, scale tốt.
- Mediator / orchestration — một orchestrator trung tâm điều khiển một workflow nhiều bước.
Điểm mạnh: decoupling cực lớn, scale tự nhiên, tính đáp ứng cao. Cái giá: eventual consistency, debug khó hơn (không có call stack duy nhất), cần idempotency và xử lý lỗi cẩn thận. Xem Message Brokers và xử lý Async.
CQRS (Command Query Responsibility Segregation)
Tách model ghi dữ liệu (command) khỏi model đọc dữ liệu (query). Thay vì một model phục vụ cả hai, bạn có một write model tối ưu cho validation và consistency, và một hay nhiều read model (thường denormalized, ở store khác) tối ưu cho query performance. Hai bên được đồng bộ, thường là bất đồng bộ qua event.
Dùng nó khi workload đọc và ghi rất khác nhau, hoặc đọc áp đảo ghi. Tránh dùng cho CRUD đơn giản — nó thêm phức tạp thật sự (hai model, độ trễ đồng bộ, eventual consistency).
Event Sourcing
Thay vì lưu trạng thái hiện tại, lưu toàn bộ chuỗi event dẫn tới nó. Trạng thái được suy ra bằng cách replay event. Event log là nguồn sự thật và chỉ append.
Lợi ích: có sẵn audit trail đầy đủ, khả năng dựng lại trạng thái tại bất kỳ thời điểm quá khứ nào (“time travel”), và ăn khớp tự nhiên với CQRS và EDA. Cái giá: truy vấn trạng thái hiện tại cần projection, versioning schema của event khó, và đây là một cú chuyển tư duy lớn. Thường đi kèm CQRS. Chỉ dùng cho domain nơi bản thân lịch sử có giá trị (finance, ledger, hệ thống nặng audit).
Topology triển khai: Monolith vs Microservices vs SOA vs Serverless
Đây là cuộc tranh luận mà đa số người ám chỉ khi nói “kiến trúc”. Nó nói về cách hệ thống được đóng gói, triển khai và chạy — trực giao với các pattern nội bộ ở trên (bạn có thể xây một monolith phân layer đẹp hoặc một microservice big-ball-of-mud).
Monolith
Một đơn vị triển khai duy nhất chứa toàn bộ chức năng. Một codebase, một build, một lần deploy, thường một database.
- Ưu: đơn giản nhất để xây, test, deploy và debug; không có network giữa các component (nhanh, không partial failure); transaction dễ (một DB, ACID); chỉ một chỗ để nhìn. Là điểm khởi đầu tốt nhất cho gần như mọi dự án.
- Nhược: khi lớn lên, coupling len lỏi vào; toàn bộ deploy cùng nhau (thay đổi của team này chờ team kia); scale nghĩa là scale tất cả; một memory leak có thể hạ gục mọi feature; khó áp dụng công nghệ mới từng phần.
Monolith không xấu về bản chất. Một monolith cấu trúc tồi (“big ball of mud”) mới xấu. Dẫn tới:
Modular Monolith (vùng trung gian)
Một đơn vị triển khai duy nhất nhưng bên trong được chia thành các module bounded rõ ràng, loosely coupled với interface tường minh — thường mỗi module sở hữu schema/table riêng và giao tiếp qua in-process API hoặc event, không bao giờ thò tay vào ruột nhau. Bạn có sự đơn giản của monolith (một lần deploy, một process, gọi in-process, transaction dễ) cùng phần lớn tính module hóa khiến microservices hấp dẫn.
Đây là lựa chọn mặc định được khuyến nghị cho đa số team hiện nay. Nó giữ cửa mở cho tương lai: nếu một module thực sự cần scale hoặc deploy độc lập, ranh giới sạch của nó khiến việc tách thành service dễ hơn nhiều. Bắt đầu từ đây; chỉ tách service khi nhu cầu thật sự, đo được, xuất hiện.
Microservices
Hệ thống được phân rã thành nhiều service nhỏ, deploy độc lập, mỗi cái sở hữu một lát business bounded và dữ liệu riêng. Các service giao tiếp qua network (HTTP/gRPC hoặc async messaging).
- Ưu: deploy độc lập (team ship mà không cần phối hợp); scale độc lập (chỉ scale service nóng); technology heterogeneity (đúng công cụ cho từng service); fault isolation (một service chết không nhất thiết kéo cả hệ thống theo, nếu thiết kế đúng); ăn khớp với team tự chủ.
- Nhược: bạn đánh đổi độ phức tạp code lấy độ phức tạp của distributed system — network latency và partial failure, eventual consistency, distributed transaction, debug/trace phân tán, chi phí vận hành (deploy, monitoring, service discovery cho từng service), và trùng lặp dữ liệu. Như Martin Fowler lưu ý, microservices đi kèm một khoản “microservices premium” đáng kể mà chỉ hoàn vốn khi vượt một ngưỡng quy mô và team nhất định.
Đừng bắt đầu bằng microservices. Khuyến nghị mạnh (“MonolithFirst”) là bắt đầu bằng một (modular) monolith và tách service khi các bounded context đã ổn định và nhu cầu scale/deploy độc lập xuất hiện.
SOA (Service-Oriented Architecture)
Tiền thân cấp doanh nghiệp của microservices (thập niên 2000). Cũng dựa trên service, nhưng thường coarse-grained hơn, dùng chung hạ tầng, và — quan trọng nhất — được điều phối qua một ESB (Enterprise Service Bus) nặng nề lo routing, transformation và orchestration tập trung, thường qua SOAP/WS-*. ESB có xu hướng trở thành nút thắt cổ chai và nguồn coupling. Microservices ra đời như phản ứng chống lại điều này: “smart endpoints, dumb pipes” — đặt logic vào service, giữ transport (một broker đơn giản hoặc HTTP thuần) “ngu”. Service cũng sở hữu dữ liệu riêng, trong khi SOA thường dùng chung database.
Serverless (FaaS)
Function (hoặc managed service) chạy theo yêu cầu; nhà cung cấp lo toàn bộ quản lý server, và bạn trả tiền theo lần chạy. Mỗi function nhỏ xíu, stateless, và được kích hoạt bởi event (HTTP request, message trong queue, upload file, cron).
- Ưu: không quản lý server, tự động scale (kể cả về 0), pay-per-use (rẻ khi traffic thấp/giật cục), xây nhanh các mảnh event-driven nhỏ.
- Nhược: cold start, giới hạn thời gian chạy/bộ nhớ, vendor lock-in, test local khó, statelessness buộc phải để state ra ngoài, và chi phí có thể vượt server chuyên dụng khi traffic cao và đều. Tuyệt cho lớp keo (glue), xử lý event và workload giật cục; ít lý tưởng làm xương sống cho một core system lớn, throughput cao.
Bảng so sánh
| Chiều | Monolith | Modular Monolith | Microservices | SOA | Serverless |
|---|---|---|---|---|---|
| Đơn vị deploy | Một | Một | Nhiều (mỗi service) | Vài (coarse) | Nhiều (mỗi function) |
| Coupling | Nguy cơ cao | Thấp (được ép) | Thấp (qua network) | Trung bình (ESB) | Thấp |
| Dữ liệu | Một DB chung | Schema theo module | DB per service | Thường dùng chung | Store bên ngoài |
| Giao tiếp | In-process | In-process / event | Network (sync/async) | ESB (SOAP/WS-*) | Event / HTTP |
| Transaction | ACID, dễ | ACID, dễ | Saga, eventual | Phân tán | Saga, eventual |
| Scaling | Cả app | Cả app | Từng service | Từng service | Từng function, tự động |
| Độ phức tạp ops | Thấp | Thấp | Cao | Cao | Trung bình (provider lo) |
| Hợp với team | Team nhỏ | Nhỏ–vừa | Nhiều team tự chủ | Doanh nghiệp | Nhỏ, event-driven |
| Hợp khi | Mới bắt đầu, phạm vi nhỏ | Mặc định; lớn dần nhưng còn cohesive | Quy mô lớn, nhiều team | Tích hợp legacy doanh nghiệp | Workload giật cục/event, glue |
Quy tắc ngón tay cái: Bắt đầu bằng modular monolith. Chuyển sang microservices khi (và chỉ khi) bạn thấy đau thật sự, đo được — phối hợp deploy, điểm nóng scale, tính tự chủ của team — mà monolith không giải được. Conway’s Law (bên dưới) nghĩa là việc chia phải bám theo ranh giới team và domain, không phải theo mốt.
Những mối quan tâm riêng của microservices
Nếu bạn quyết định đi phân tán, đây là các bài toán phải giải. Bỏ qua bất kỳ cái nào là cách microservices biến thành “distributed monolith” — tốn kém đủ đường mà chẳng được lợi gì.
Service boundary: DDD bounded context
Quyết định khó và quan trọng nhất. Chia boundary sai là nguyên nhân số 1 khiến microservices thất bại. Công cụ tốt nhất là bounded context của Domain-Driven Design: một ranh giới mà bên trong đó một domain model và ngôn ngữ chung (ubiquitous language) của nó nhất quán. Customer có thể mang nghĩa khác trong Sales so với trong Support — đó là hai bounded context, và mỗi cái map tốt vào một service. Boundary nên là các lát high-cohesion, low-coupling của business, không phải các layer kỹ thuật. Một boundary tốt phần lớn có thể được phát triển, deploy và suy luận một cách độc lập.
Giao tiếp giữa các service: sync vs async
| Synchronous (request/response) | Asynchronous (messaging/event) | |
|---|---|---|
| Protocol | REST/HTTP, gRPC | Kafka, RabbitMQ, SQS, NATS |
| Coupling | Temporal — caller phải chờ, callee phải sống | Decoupled — bắn rồi quên |
| Consistency | Tức thời | Eventual |
| Kiểu lỗi | Lỗi lan dây chuyền, cần timeout/retry/circuit breaker | Broker đệm lại; cần idempotency |
| Hợp cho | Query cần câu trả lời ngay | Notification, workflow, decoupling |
Ưu tiên async messaging để giảm temporal coupling ở bất cứ đâu không cần câu trả lời tức thời. Với gọi synchronous, luôn áp dụng resilience pattern (timeout, retry có backoff, circuit breaker, bulkhead) — xem Scalability và Reliability. Xem thêm Message Brokers và xử lý Async.
Data ownership: database per service
Quy tắc dữ liệu định danh của microservices: mỗi service sở hữu dữ liệu của nó, và không service nào khác được chạm trực tiếp vào database đó. Các service khác chỉ lấy dữ liệu qua API của service sở hữu hoặc qua event. Một database dùng chung sẽ re-couple các service (một thay đổi schema làm hỏng tất cả) và phá hủy khả năng deploy độc lập. Cái giá là trùng lặp dữ liệu và eventual consistency giữa các service — điều mà hai mối quan tâm tiếp theo giải quyết.
Distributed transaction: pattern Saga
Bạn không thể dùng một transaction ACID duy nhất qua database của nhiều service. Thay vào đó, một business transaction trải trên nhiều service trở thành một saga: một chuỗi local transaction, mỗi cái phát một event hoặc command kích hoạt bước tiếp theo. Nếu một bước lỗi, saga chạy compensating transaction để hoàn tác các bước trước.
- Choreography — không có điều phối viên trung tâm; mỗi service phản ứng với event và phát ra cái tiếp theo. Đơn giản và decoupled, nhưng luồng tổng thể là ngầm định và khó theo dõi khi lớn.
- Orchestration — một saga orchestrator trung tâm điều khiển tường minh từng bước và lo compensation. Dễ nhìn, dễ kiểm soát hơn; orchestrator là một component mới phải xây và vận hành.
Saga cho bạn eventual consistency và tính nguyên tử-qua-compensation, chứ không cho isolation — hãy thiết kế với giả định các trạng thái trung gian có thể bị quan sát thấy.
API Gateway
Một điểm vào duy nhất đặt trước các service. Nó lo các cross-cutting concern để mỗi service không phải làm: routing/aggregation, authentication và authorization, rate limiting, TLS termination, transformation request/response, và caching. Nó che client khỏi topology service nội bộ (client gọi một endpoint thay vì mười). Biến thể: Backend for Frontend (BFF) — một gateway cho từng loại client (web, mobile) may đo theo nhu cầu client đó. Cẩn thận để gateway không trở thành nút thắt cổ chai hay một monolith business logic mới — giữ nó mỏng.
Service discovery
Với nhiều service trên host/port động (container scale lên xuống, IP thay đổi), service không thể hardcode địa chỉ của nhau. Service discovery giải bài này:
- Client-side — client hỏi một registry (Consul, Eureka, etcd) và chọn một instance.
- Server-side — client gọi một load balancer / DNS name ổn định, nó route tới một instance đang sống (mô hình Service của Kubernetes).
Service mesh
Khi các concern giữa các service (retry, mTLS, timeout, định hình traffic, observability) nhân lên, hiện thực chúng trong mọi service ở mọi ngôn ngữ trở nên bất khả thi. Một service mesh (Istio, Linkerd) chuyển chúng vào một sidecar proxy deploy cạnh mỗi service. Data plane (các sidecar, ví dụ Envoy) lo traffic thực tế — mTLS, load balancing, retry, circuit breaking; control plane cấu hình các sidecar tập trung. Code ứng dụng vẫn “vô tư”. Cái giá là thêm overhead vận hành và latency, nên chỉ adopt khi số lượng service đủ lớn để xứng đáng.
Integration pattern
Ba cách rộng để các hệ thống và service tích hợp, đại khái từ coupling chặt nhất tới lỏng nhất:
- Shared data — các service đọc/ghi một database hoặc file chung. Đơn giản nhưng coupling chặt nhất; schema trở thành một contract chung khó thay đổi. Chấp nhận được trong một module của modular monolith, không khuyến khích giữa các microservices.
- API / RPC (request-response) — một service gọi API của service khác (REST, gRPC, GraphQL). Contract rõ ràng và câu trả lời tức thời, đổi lại là temporal coupling và phụ thuộc runtime. Xem APIs.
- Messaging (event/command) — các service trao đổi message qua broker. Coupling lỏng nhất và là xương sống của hệ thống event-driven; đưa vào eventual consistency và đòi consumer phải idempotent. Xem Message Brokers và xử lý Async.
Một hệ thống phân tán lành mạnh dùng cả ba một cách có chủ đích: messaging cho workflow decoupled và notification, API cho query cần câu trả lời ngay, shared data giới hạn trong một service hoặc module duy nhất.
The Twelve-Factor App
Một phương pháp luận (từ Heroku) để xây các ứng dụng portable, scalable, cloud-native. Ngay cả ngoài bối cảnh PaaS gốc, nó vẫn là một checklist tuyệt vời cho thiết kế backend service:
- Codebase — một codebase được track trong version control, nhiều deploy.
- Dependencies — khai báo và cô lập dependency tường minh; không bao giờ dựa vào package cài toàn hệ thống.
- Config — lưu config (bất cứ thứ gì thay đổi theo môi trường) trong environment, không trong code.
- Backing services — coi backing service (DB, cache, queue) là resource gắn kèm, thay được, tham chiếu qua URL/config.
- Build, release, run — tách biệt nghiêm ngặt ba giai đoạn build, release và run.
- Processes — chạy app như một hay nhiều process stateless, share-nothing; giữ state trong backing service.
- Port binding — export service qua port binding; app tự chứa và bind vào một port.
- Concurrency — scale ngang qua mô hình process (thêm process), không chỉ máy to hơn.
- Disposability — tối đa độ bền với khởi động nhanh và graceful shutdown; process là thứ vứt được.
- Dev/prod parity — giữ development, staging và production giống nhau nhất có thể.
- Logs — coi log là event stream ghi ra stdout; để environment lo route và lưu trữ.
- Admin processes — chạy tác vụ admin/quản trị (migration, script one-off) như process one-off trong cùng environment.
Evolutionary architecture và refactoring
Kiến trúc không được quyết một lần rồi thôi. Evolutionary architecture (Ford, Parsons, Kua) thiết kế để thay đổi: hỗ trợ thay đổi có định hướng, tăng dần trên nhiều chiều theo thời gian. Ý tưởng chính:
- Fitness functions — các kiểm tra tự động canh giữ đặc tính kiến trúc (ví dụ “không có cyclic dependency giữa các module”, “p99 latency < 200 ms”, “layer API không bao giờ import layer persistence”). Chúng biến nguyên tắc kiến trúc thành test làm fail build khi bị vi phạm.
- Incremental change — các bước nhỏ, đảo ngược được thay cho rewrite big-bang.
- Continuous refactoring — trả nợ kiến trúc liên tục thay vì trong một cú rewrite.
Strangler Fig: monolith → microservices
Được Martin Fowler đặt tên theo cây strangler fig, đây là cách an toàn để migrate một monolith từng bước thay vì “big-bang rewrite” đầy rủi ro. Bạn đặt một facade/proxy trước monolith và, từng mảnh một, tách chức năng ra thành service mới. Facade route mỗi request tới hoặc service mới (cho feature đã migrate) hoặc monolith cũ (cho phần còn lại). Theo thời gian hệ thống mới “bóp nghẹt” cái cũ cho tới khi nó có thể được cho về hưu.
- ?Facade / routing proxy: đã migrate?đã migrateService mới
Ưu điểm: không rewrite rủi ro, liên tục giao giá trị, khả năng tạm dừng hoặc rollback, và học dần. Nó đòi kỷ luật để thật sự hoàn thành (tránh “hybrid vĩnh viễn” khi migration đứng mãi).
Các design principle cốt lõi
Các nguyên tắc tái sử dụng nằm dưới tất cả những điều trên:
- SOLID — năm nguyên tắc OO:
- Single Responsibility — một class/module chỉ có một lý do để thay đổi.
- Open/Closed — mở để mở rộng, đóng để sửa đổi.
- Liskov Substitution — subtype phải dùng được ở bất cứ đâu base type của nó được kỳ vọng.
- Interface Segregation — nhiều interface nhỏ, tập trung tốt hơn một interface phình to.
- Dependency Inversion — phụ thuộc vào abstraction, không phải concretion (nền tảng của hexagonal/clean architecture).
- DRY (Don’t Repeat Yourself) — mỗi mẩu tri thức có một biểu diễn duy nhất, có thẩm quyền. Coi chừng áp dụng thái quá cho trùng lặp ngẫu nhiên (xem AHA: “avoid hasty abstractions”).
- KISS (Keep It Simple, Stupid) — ưu tiên giải pháp đơn giản nhất mà chạy được; độ phức tạp phải xứng đáng với chỗ nó chiếm.
- YAGNI (You Aren’t Gonna Need It) — đừng xây cho yêu cầu tương lai mang tính đầu cơ; làm khi bạn thật sự cần.
Trade-off, chi phí, và Conway’s Law
Kiến trúc là nghệ thuật của trade-off. Ba lăng kính để soi mọi quyết định:
- Complexity budget — phân tán, async và các layer thêm vào đều thêm độ phức tạp nhận thức và vận hành. Mỗi thành phần thêm vào phải mua về nhiều hơn cái nó tốn. Mặc định nên là thứ đơn giản nhất mà chạy được.
- Operational cost — microservices nhân lên những gì bạn phải chạy và quan sát: nhiều pipeline deploy hơn, nhiều monitoring hơn, nhiều kiểu lỗi hơn, nhiều on-call hơn. Hãy tính tổng chi phí sở hữu, không chỉ tốc độ phát triển.
- Team topology và Conway’s Law — “các tổ chức thiết kế hệ thống phản chiếu cấu trúc giao tiếp của chính họ” (Melvin Conway, 1967). Ranh giới service của bạn sẽ có xu hướng khớp với ranh giới team dù bạn có định hay không. Inverse Conway Maneuver biến điều này thành công cụ: cố ý định hình team quanh kiến trúc bạn muốn. Team Topologies chính thức hóa điều này với stream-aligned team sở hữu bounded context và platform team giảm tải nhận thức cho họ.
Best Practices
- Bắt đầu đơn giản; mặc định là modular monolith. Chỉ với tới microservices khi có nỗi đau thật sự, đo được (phối hợp deploy, điểm nóng scale, tự chủ team) xứng với khoản premium của distributed system.
- Thiết kế quanh business capability / bounded context, không quanh layer kỹ thuật. Boundary là quyết định quan trọng nhất — hãy cho cohesion cao và coupling thấp.
- Phụ thuộc vào abstraction, không vào framework. Giữ business logic độc lập với DB, web framework và external service (hexagonal/clean). Nó phải test được mà không cần chạy bất cứ thứ nào trong số đó.
- Ép database-per-service trong microservices; đừng bao giờ để các service dùng chung database. Tích hợp qua API và event thay vào đó.
- Ưu tiên async messaging để cắt temporal coupling; chỉ gọi synchronous khi cần câu trả lời ngay, và luôn bọc trong timeout, retry và circuit breaker.
- Chấp nhận eventual consistency ở nơi không đòi strong consistency; dùng saga cho business transaction xuyên service và làm consumer idempotent.
- Áp dụng các nguyên tắc Twelve-Factor — process stateless, config trong environment, log ra stdout, dev/prod parity — cho mọi service.
- Làm kiến trúc test được bằng fitness function (quy tắc dependency, budget latency, boundary module) để vi phạm làm fail build.
- Migrate tăng dần (strangler fig), không bao giờ bằng big-bang rewrite.
- Ghi lại quyết định trong ADR. Nắm bối cảnh, quyết định và hệ quả để người bảo trì sau hiểu được tại sao.
- Tôn trọng Conway’s Law. Căn chỉnh team và service một cách có chủ đích; đừng chống lại sơ đồ tổ chức của bạn.
- Tránh abstraction quá sớm (YAGNI) và phân tán quá sớm. Kiến trúc đắt đỏ nhất thường là kiến trúc over-engineered.
Tài liệu tham khảo
- The Twelve-Factor App
- Martin Fowler — Microservices
- Martin Fowler — MonolithFirst
- Martin Fowler — Strangler Fig Application
- microservices.io — Patterns (Sam Newman / Chris Richardson)
- Robert C. Martin — The Clean Architecture
- Alistair Cockburn — Hexagonal Architecture
- Domain-Driven Design Reference (Eric Evans)
Part of the Backend Roadmap knowledge base.
Overview
Software architecture is the set of high-level structural decisions about a system: how it is decomposed into parts, how those parts communicate, where data lives, and which qualities (performance, scalability, resilience, maintainability, security, cost) you optimize for. These are the decisions that are expensive to change later — Grady Booch’s often-quoted definition is that “architecture represents the significant design decisions that shape a system, where significant is measured by cost of change.”
For a backend engineer, architecture is not an abstract diagram exercise. It directly determines:
- How fast a team can ship features without stepping on each other.
- How the system behaves under load and under failure.
- How much it costs to operate (servers, network, on-call burden).
- How hard it is to reason about, test, and evolve.
There is no single “best” architecture — every choice is a trade-off. A three-person startup and a 300-engineer company solving the “same” problem should almost never pick the same architecture. The job is to understand the forces at play (team size, domain complexity, scale requirements, deadlines) and pick the simplest thing that satisfies them, while keeping the door open to evolve.
This note covers the foundational principles (coupling, cohesion, separation of concerns, layering), the major architectural patterns and styles (layered, hexagonal, clean, event-driven, CQRS, event sourcing), the big deployment-topology debate (monolith vs microservices vs SOA vs serverless, plus the modular monolith middle ground), the specific concerns microservices introduce (boundaries, communication, data ownership, sagas, gateways, discovery, service mesh), the Twelve-Factor App methodology, evolutionary architecture and the strangler fig pattern, and the classic design principles (SOLID, DRY, KISS, YAGNI).
Related notes: APIs · Message Brokers and Async Processing · Scalability and Reliability.
Fundamentals
Before choosing a pattern or a topology, internalize a handful of principles. Every good architecture is an application of these; every bad one violates them.
Coupling and cohesion
These are the two most important words in software design. Almost every architectural rule reduces to “lower coupling, higher cohesion.”
- Coupling — the degree to which one module depends on another. When two modules are tightly coupled, a change in one forces a change in the other. High coupling makes systems brittle: small changes ripple widely, and you cannot understand or deploy a module in isolation.
- Cohesion — the degree to which the elements inside a module belong together. High cohesion means a module does one well-defined thing; everything in it serves that single purpose. Low cohesion is a “grab bag” module (
Utils,Helpers,Manager) that does unrelated things.
The goal: low coupling between modules, high cohesion within each module. Robert C. Martin frames this as “gather together the things that change for the same reason; separate the things that change for different reasons” — the essence of the Single Responsibility Principle.
| Low coupling | High coupling | |
|---|---|---|
| High cohesion | Ideal — modular, independently changeable | Rigid — good modules but tangled dependencies |
| Low cohesion | Scattered — related logic spread across modules | Worst — the “big ball of mud” |
Types of coupling, from loosest (good) to tightest (bad): message coupling → data coupling (passing simple parameters) → stamp coupling (passing whole structures) → control coupling → shared/global coupling → content coupling (one module reaching into another’s internals). Prefer the loosest form that gets the job done.
Separation of concerns
Separation of concerns (SoC) is the principle of dividing a system so each part addresses a distinct concern (persistence, business rules, presentation, transport). Each concern can then be understood, changed, and tested independently. Layering (below) is one common realization; hexagonal architecture is another.
The practical payoff: you can swap your database, change your API framework, or replace your UI without rewriting business logic — if those concerns were properly separated in the first place.
Layering
A layer is a horizontal slice of responsibility. A dependency rule constrains layers: higher layers may depend on lower layers, never the reverse. A classic backend stack:
- 01Presentation / API (HTTP handlers, controllers)translate transport ↔ domain
- 02Application / Service (use cases, orchestration)orchestrate, transactions
- 03Domain / Business (entities, rules)the "why" of the system
- 04Persistence / Data (repositories, ORM, queries)talk to the DB
- 05Infrastructure (DB, cache, queues, external APIs)
Layering gives you clear seams for testing (mock the layer below), a shared mental model for the team, and controlled dependencies. Its risk is the anemic domain model and the “sinkhole anti-pattern” — requests that pass straight through every layer doing nothing but forwarding, adding ceremony without value. Layers should earn their keep.
Architecture vs design vs implementation
A useful mental scale:
- Architecture — system-wide, hard to reverse (monolith vs microservices, sync vs async, SQL vs NoSQL).
- Design — module-level, moderately reversible (which pattern, class structure, interfaces).
- Implementation — line-level, cheap to change (variable names, a specific loop).
Spend your careful, up-front thinking budget in proportion to reversibility. Architecture decisions deserve an ADR (Architecture Decision Record): a short document capturing the context, the decision, and the consequences, committed alongside the code.
Key Concepts
Architectural patterns and styles
These describe how you structure code within a deployable unit. They are largely independent of, and combinable with, the deployment topology (monolith/microservices) discussed later.
Layered / N-tier
The default and most widely used pattern (described above). Code is organized into horizontal layers (presentation, business, persistence). “N-tier” adds physical deployment tiers (web tier, app tier, DB tier). Simple, familiar, great for CRUD-heavy apps. Weakness: business logic tends to leak into controllers and the DB schema tends to drive the design (“database-centric”).
Hexagonal (Ports and Adapters)
Coined by Alistair Cockburn. The application core (domain + use cases) sits in the center and knows nothing about the outside world. It defines ports (interfaces) for what it needs (OrderRepository, PaymentGateway) and what it offers (use-case interfaces). Adapters on the outside implement those ports for specific technologies (a Postgres adapter, a Stripe adapter, an HTTP controller, a Kafka consumer).
- HTTP / REST
- CLI
- Message consumer
- Postgres
- Stripe API
- Kafka
- SMTP
The key win: the domain depends on abstractions, not on frameworks or databases. You can test the core with in-memory adapters, and swap infrastructure by writing a new adapter. This is a concrete realization of the Dependency Inversion Principle.
Clean Architecture
Robert C. Martin’s synthesis of hexagonal, onion, and other “dependency-rule” architectures. Concentric circles: Entities (enterprise rules) → Use Cases (application rules) → Interface Adapters (controllers, presenters, gateways) → Frameworks & Drivers (web, DB, UI). The Dependency Rule: source-code dependencies point only inward. Inner circles never know about outer circles; crossing the boundary uses interfaces and DTOs. Practically very similar to hexagonal, with more prescriptive naming.
Event-Driven Architecture (EDA)
Components communicate by producing and consuming events (“something happened”) rather than calling each other directly. A producer emits OrderPlaced; any number of consumers react (charge payment, reserve stock, send email) without the producer knowing they exist. Two topologies:
- Broker / pub-sub — events flow through a broker (Kafka, RabbitMQ, NATS); consumers subscribe. Highly decoupled, scalable.
- Mediator / orchestration — a central orchestrator directs a multi-step workflow.
Strengths: extreme decoupling, natural scalability, responsiveness. Costs: eventual consistency, harder debugging (no single call stack), the need for idempotency and careful error handling. See Message Brokers and Async Processing.
CQRS (Command Query Responsibility Segregation)
Separate the model that writes data (commands) from the model that reads it (queries). Instead of one model serving both, you have a write model optimized for validation and consistency, and one or more read models (often denormalized, in a different store) optimized for query performance. The two are kept in sync, usually asynchronously via events.
Use it when read and write workloads are very different, or reads vastly outnumber writes. Avoid it for simple CRUD — it adds real complexity (two models, sync lag, eventual consistency).
Event Sourcing
Instead of storing the current state, store the full sequence of events that led to it. State is derived by replaying events. The event log is the source of truth and is append-only.
Benefits: a complete audit trail for free, the ability to reconstruct state at any past point (“time travel”), and natural fit with CQRS and EDA. Costs: querying current state requires projections, schema/versioning of events is hard, and it is a big conceptual shift. Often paired with CQRS. Reserve it for domains where history itself is valuable (finance, ledgers, audit-heavy systems).
Deployment topologies: Monolith vs Microservices vs SOA vs Serverless
This is the debate most people mean by “architecture.” It is about how the system is packaged, deployed, and run — orthogonal to the internal patterns above (you can build a well-layered monolith or a big-ball-of-mud microservice).
Monolith
A single deployable unit containing all functionality. One codebase, one build, one deploy, usually one database.
- Pros: simplest to build, test, deploy, and debug; no network between components (fast, no partial failure); transactions are trivial (one DB, ACID); one place to look. Best starting point for almost every project.
- Cons: as it grows, coupling creeps in; the whole thing deploys together (one team’s change waits on another’s); scaling means scaling everything; a single memory leak can take down all features; hard to adopt new tech incrementally.
A monolith is not inherently bad. A poorly structured monolith (“big ball of mud”) is. Which leads to:
Modular Monolith (the middle ground)
A single deployable unit that is internally divided into well-bounded, loosely coupled modules with explicit interfaces — often each module owning its own schema/tables and communicating through in-process APIs or events, never reaching into each other’s internals. You get monolith simplicity (one deploy, one process, in-process calls, easy transactions) with much of the modularity that makes microservices attractive.
This is the recommended default for most teams today. It keeps future options open: if a module genuinely needs independent scaling or deployment, its clean boundary makes extracting it into a service much easier. Start here; extract services only when a real, measured need appears.
Microservices
The system is decomposed into many small, independently deployable services, each owning a bounded slice of the business and its own data. Services communicate over the network (HTTP/gRPC or async messaging).
- Pros: independent deployment (teams ship without coordinating); independent scaling (scale only the hot service); technology heterogeneity (right tool per service); fault isolation (one service failing needn’t take down others, with proper design); aligns with autonomous teams.
- Cons: you trade code complexity for distributed-system complexity — network latency and partial failure, eventual consistency, distributed transactions, distributed debugging/tracing, operational overhead (deployment, monitoring, service discovery per service), and data duplication. As Martin Fowler notes, microservices come with a significant “microservices premium” that only pays off past a certain scale and team size.
Do not start with microservices. The strong recommendation (“MonolithFirst”) is to begin with a (modular) monolith and extract services as bounded contexts prove stable and independent scaling/deployment needs emerge.
SOA (Service-Oriented Architecture)
The enterprise predecessor of microservices (2000s). Also service-based, but typically coarser-grained services, sharing infrastructure, and — critically — orchestrated through a heavyweight ESB (Enterprise Service Bus) that handles routing, transformation, and orchestration centrally, often over SOAP/WS-* protocols. The ESB tended to become a bottleneck and a source of coupling. Microservices reacted against this: “smart endpoints, dumb pipes” — put logic in services, keep the transport (a simple broker or plain HTTP) dumb. Services also own their own data, whereas SOA often shared a database.
Serverless (FaaS)
Functions (or managed services) run on-demand; the provider handles all server management, and you pay per execution. Each function is tiny, stateless, and event-triggered (HTTP request, queue message, file upload, cron).
- Pros: no server management, automatic scaling (including to zero), pay-per-use (cheap at low/spiky traffic), fast to build small event-driven pieces.
- Cons: cold starts, execution time/memory limits, vendor lock-in, harder local testing, statelessness forces external state, and cost can exceed dedicated servers at high steady traffic. Great for glue, event processing, and spiky workloads; less ideal as the backbone of a large, high-throughput core system.
Comparison table
| Dimension | Monolith | Modular Monolith | Microservices | SOA | Serverless |
|---|---|---|---|---|---|
| Deploy unit | One | One | Many (per service) | Several (coarse) | Many (per function) |
| Coupling | Risk of high | Low (enforced) | Low (network) | Medium (ESB) | Low |
| Data | One shared DB | Module-owned schemas | DB per service | Often shared | External stores |
| Comms | In-process | In-process / events | Network (sync/async) | ESB (SOAP/WS-*) | Events / HTTP |
| Transactions | ACID, easy | ACID, easy | Saga, eventual | Distributed | Saga, eventual |
| Scaling | Whole app | Whole app | Per service | Per service | Per function, auto |
| Ops complexity | Low | Low | High | High | Medium (provider-managed) |
| Team fit | Small teams | Small–medium | Many autonomous teams | Enterprise | Small, event-driven |
| Best when | Starting out, small scope | Default; growing but cohesive | Large scale, many teams | Legacy enterprise integration | Spiky/event workloads, glue |
Rule of thumb: Start with a modular monolith. Move to microservices when (and only when) you feel real, measured pain — deployment coordination, scaling hotspots, team autonomy — that a monolith cannot solve. Conway’s Law (below) means the split should follow team and domain boundaries, not fashion.
Microservices-specific concerns
If you do go distributed, these are the problems you must solve. Ignoring any of them is how microservices become a “distributed monolith” — all the cost, none of the benefit.
Service boundaries: DDD bounded contexts
The hardest and most important decision. Getting boundaries wrong is the #1 cause of failed microservices. The best tool is Domain-Driven Design’s bounded context: a boundary within which a domain model and its ubiquitous language are consistent. Customer might mean something different in Sales than in Support — those are two bounded contexts, and each maps well to a service. Boundaries should be high-cohesion, low-coupling slices of the business, not technical layers. A good boundary can be developed, deployed, and reasoned about mostly in isolation.
Inter-service communication: sync vs async
| Synchronous (request/response) | Asynchronous (messaging/events) | |
|---|---|---|
| Protocols | REST/HTTP, gRPC | Kafka, RabbitMQ, SQS, NATS |
| Coupling | Temporal — caller waits, callee must be up | Decoupled — fire and forget |
| Consistency | Immediate | Eventual |
| Failure mode | Cascading failures, need timeouts/retries/circuit breakers | Broker buffers; needs idempotency |
| Best for | Queries needing an immediate answer | Notifications, workflows, decoupling |
Prefer async messaging to reduce temporal coupling wherever an immediate answer isn’t required. For synchronous calls, always apply resilience patterns (timeouts, retries with backoff, circuit breakers, bulkheads) — see Scalability and Reliability. See also Message Brokers and Async Processing.
Data ownership: database per service
The defining rule of microservices data: each service owns its data, and no other service touches its database directly. Other services get data only via the owning service’s API or via events. A shared database re-couples services (a schema change breaks everyone) and destroys independent deployability. The cost is data duplication and eventual consistency across services — which the next two concerns address.
Distributed transactions: the Saga pattern
You cannot use a single ACID transaction across multiple services’ databases. Instead, a business transaction spanning services becomes a saga: a sequence of local transactions, each publishing an event or command that triggers the next. If a step fails, the saga runs compensating transactions to undo prior steps.
- Choreography — no central coordinator; each service reacts to events and emits the next. Simple and decoupled, but the overall flow is implicit and hard to follow at scale.
- Orchestration — a central saga orchestrator explicitly drives each step and handles compensation. More visible and controllable; the orchestrator is a new component to build and operate.
Sagas give you eventual consistency and atomicity-via-compensation, not isolation — design for intermediate states being observable.
API Gateway
A single entry point in front of the services. It handles cross-cutting concerns so each service doesn’t have to: routing/aggregation, authentication and authorization, rate limiting, TLS termination, request/response transformation, and caching. It shields clients from the internal service topology (a client calls one endpoint instead of ten). Variant: Backend for Frontend (BFF) — a gateway per client type (web, mobile) tailored to that client’s needs. Watch out for the gateway becoming a bottleneck or a new monolith of business logic — keep it thin.
Service discovery
With many services on dynamic hosts/ports (containers scale up and down, IPs change), services can’t hardcode each other’s addresses. Service discovery solves this:
- Client-side — the client queries a registry (Consul, Eureka, etcd) and picks an instance.
- Server-side — the client hits a stable load balancer / DNS name that routes to a live instance (the Kubernetes Service model).
Service mesh
As inter-service concerns (retries, mTLS, timeouts, traffic shaping, observability) proliferate, implementing them in every service in every language becomes untenable. A service mesh (Istio, Linkerd) moves them into a sidecar proxy deployed next to each service. The data plane (sidecars, e.g. Envoy) handles the actual traffic — mTLS, load balancing, retries, circuit breaking; the control plane configures the sidecars centrally. The application code stays oblivious. The trade-off is added operational and latency overhead, so adopt it only when the number of services justifies it.
Integration patterns
Three broad ways systems and services integrate, roughly from most-coupled to least:
- Shared data — services read/write a common database or files. Simple but the tightest coupling; the schema becomes a shared contract that resists change. Acceptable within a modular monolith module, discouraged across microservices.
- API / RPC (request-response) — one service calls another’s API (REST, gRPC, GraphQL). Clear contracts and immediate answers, at the cost of temporal coupling and runtime dependency. See APIs.
- Messaging (events/commands) — services exchange messages through a broker. The loosest coupling and the backbone of event-driven systems; introduces eventual consistency and requires idempotent consumers. See Message Brokers and Async Processing.
A healthy distributed system uses all three deliberately: messaging for decoupled workflows and notifications, APIs for queries needing an immediate answer, shared data confined within a single service or module.
The Twelve-Factor App
A methodology (from Heroku) for building portable, scalable, cloud-native applications. Even outside its original PaaS context, it is a superb checklist for backend service design:
- Codebase — one codebase tracked in version control, many deploys.
- Dependencies — explicitly declare and isolate dependencies; never rely on system-wide packages.
- Config — store config (anything that varies per environment) in the environment, not in code.
- Backing services — treat backing services (DB, cache, queue) as attached, swappable resources referenced by URL/config.
- Build, release, run — strictly separate the build, release, and run stages.
- Processes — run the app as one or more stateless, share-nothing processes; persist state in backing services.
- Port binding — export services via port binding; the app is self-contained and binds to a port.
- Concurrency — scale out horizontally via the process model (more processes), not just bigger machines.
- Disposability — maximize robustness with fast startup and graceful shutdown; processes are disposable.
- Dev/prod parity — keep development, staging, and production as similar as possible.
- Logs — treat logs as event streams written to stdout; let the environment route and store them.
- Admin processes — run admin/management tasks (migrations, one-off scripts) as one-off processes in the same environment.
Evolutionary architecture and refactoring
Architecture is not decided once. Evolutionary architecture (Ford, Parsons, Kua) designs for change: support guided, incremental change across multiple dimensions over time. Key ideas:
- Fitness functions — automated checks that guard architectural characteristics (e.g., “no cyclic dependencies between modules,” “p99 latency < 200 ms,” “the API layer never imports the persistence layer”). They turn architecture principles into tests that fail the build when violated.
- Incremental change — small, reversible steps over big-bang rewrites.
- Continuous refactoring — pay down architectural debt continuously rather than in a rewrite.
Strangler Fig: monolith → microservices
Named by Martin Fowler after the strangler fig vine, this is the safe way to migrate a monolith incrementally rather than a risky “big-bang rewrite.” You place a facade/proxy in front of the monolith and, piece by piece, extract functionality into new services. The facade routes each request to either the new service (for migrated features) or the old monolith (for the rest). Over time the new system “strangles” the old one until it can be retired.
- ?Facade / routing proxy: migrated?migratedNew service
Advantages: no risky rewrite, continuous delivery of value, ability to pause or roll back, and gradual learning. It requires discipline to actually finish (avoid the “permanent hybrid” where migration stalls forever).
Core design principles
The reusable principles underneath all of the above:
- SOLID — five OO principles:
- Single Responsibility — a class/module has one reason to change.
- Open/Closed — open for extension, closed for modification.
- Liskov Substitution — subtypes must be usable wherever their base type is expected.
- Interface Segregation — many small, focused interfaces beat one fat interface.
- Dependency Inversion — depend on abstractions, not concretions (the basis of hexagonal/clean architecture).
- DRY (Don’t Repeat Yourself) — every piece of knowledge has a single, authoritative representation. Beware over-applying it to coincidental duplication (see AHA: “avoid hasty abstractions”).
- KISS (Keep It Simple, Stupid) — prefer the simplest solution that works; complexity must earn its place.
- YAGNI (You Aren’t Gonna Need It) — don’t build for speculative future requirements; implement things when you actually need them.
Trade-offs, cost, and Conway’s Law
Architecture is the art of trade-offs. Three lenses to apply to any decision:
- Complexity budget — distribution, async, and extra layers all add cognitive and operational complexity. Every added element must buy more than it costs. The default should be the simplest thing that works.
- Operational cost — microservices multiply what you must run and observe: more deployment pipelines, more monitoring, more failure modes, more on-call. Factor total cost of ownership, not just development speed.
- Team topology and Conway’s Law — “organizations design systems that mirror their own communication structure” (Melvin Conway, 1967). Your service boundaries will tend to match your team boundaries whether you plan it or not. The Inverse Conway Maneuver turns this into a tool: deliberately shape teams around the architecture you want. Team Topologies formalizes this with stream-aligned teams owning bounded contexts and platform teams reducing their cognitive load.
Best Practices
- Start simple; default to a modular monolith. Reach for microservices only when real, measured pain (deployment coordination, scaling hotspots, team autonomy) justifies the distributed-systems premium.
- Design around business capabilities / bounded contexts, not technical layers. Boundaries are the decision that matters most — get cohesion high and coupling low.
- Depend on abstractions, not frameworks. Keep business logic independent of the DB, the web framework, and external services (hexagonal/clean). It should be testable without any of them running.
- Enforce database-per-service in microservices; never let services share a database. Integrate through APIs and events instead.
- Prefer asynchronous messaging to cut temporal coupling; use synchronous calls only when you need an immediate answer, and always wrap them in timeouts, retries, and circuit breakers.
- Embrace eventual consistency where strong consistency isn’t required; use sagas for cross-service business transactions and make consumers idempotent.
- Apply the Twelve-Factor principles — stateless processes, config in the environment, logs to stdout, dev/prod parity — to every service.
- Make architecture testable with fitness functions (dependency rules, latency budgets, module boundaries) so violations fail the build.
- Migrate incrementally (strangler fig), never with a big-bang rewrite.
- Record decisions in ADRs. Capture context, decision, and consequences so future maintainers understand why.
- Respect Conway’s Law. Align teams and services deliberately; don’t fight your org chart.
- Avoid premature abstraction (YAGNI) and premature distribution. The most expensive architectures are usually the over-engineered ones.
References
- The Twelve-Factor App
- Martin Fowler — Microservices
- Martin Fowler — MonolithFirst
- Martin Fowler — Strangler Fig Application
- microservices.io — Patterns (Sam Newman / Chris Richardson)
- Robert C. Martin — The Clean Architecture
- Alistair Cockburn — Hexagonal Architecture
- Domain-Driven Design Reference (Eric Evans)