← Backend← Backend
BackendBackend19 Th7, 2026Jul 19, 202627 phút đọc22 min read

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:

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.”

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ấpCoupling cao
Cohesion caoLý tưởng — module hóa, dễ thay đổi độc lậpCứng nhắc — module tốt nhưng dependency rối
Cohesion thấpRời rạc — logic liên quan bị rải khắp nơiTệ 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:

Kiến trúc phân tầng
  1. 01
    Presentation / API (HTTP handlers, controllers)dịch transport ↔ domain
  2. 02
    Application / Service (use cases, orchestration)điều phối, transaction
  3. 03
    Domain / Business (entities, rules)cái "tại sao" của hệ thống
  4. 04
    Persistence / Data (repositories, ORM, queries)nói chuyện với DB
  5. 05
    Infrastructure (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:

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).

Ports and adapters
Driving adapters (inbound) — điều khiển app
  • HTTP / REST
  • CLI
  • Message consumer
Application coredomain + ports
Driven adapters (outbound) — app điều khiển
  • 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:

Đ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.

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).

Đừ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).

Bảng so sánh

ChiềuMonolithModular MonolithMicroservicesSOAServerless
Đơn vị deployMộtMộtNhiều (mỗi service)Vài (coarse)Nhiều (mỗi function)
CouplingNguy cơ caoThấp (được ép)Thấp (qua network)Trung bình (ESB)Thấp
Dữ liệuMột DB chungSchema theo moduleDB per serviceThường dùng chungStore bên ngoài
Giao tiếpIn-processIn-process / eventNetwork (sync/async)ESB (SOAP/WS-*)Event / HTTP
TransactionACID, dễACID, dễSaga, eventualPhân tánSaga, eventual
ScalingCả appCả appTừng serviceTừng serviceTừng function, tự động
Độ phức tạp opsThấpThấpCaoCaoTrung bình (provider lo)
Hợp với teamTeam nhỏNhỏ–vừaNhiều team tự chủDoanh nghiệpNhỏ, event-driven
Hợp khiMới bắt đầu, phạm vi nhỏMặc định; lớn dần nhưng còn cohesiveQuy mô lớn, nhiều teamTích hợp legacy doanh nghiệpWorkload 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)
ProtocolREST/HTTP, gRPCKafka, RabbitMQ, SQS, NATS
CouplingTemporal — caller phải chờ, callee phải sốngDecoupled — bắn rồi quên
ConsistencyTức thờiEventual
Kiểu lỗiLỗi lan dây chuyền, cần timeout/retry/circuit breakerBroker đệm lại; cần idempotency
Hợp choQuery cần câu trả lời ngayNotification, 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ệueventual 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.

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:

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:

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:

  1. Codebase — một codebase được track trong version control, nhiều deploy.
  2. 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.
  3. Config — lưu config (bất cứ thứ gì thay đổi theo môi trường) trong environment, không trong code.
  4. Backing services — coi backing service (DB, cache, queue) là resource gắn kèm, thay được, tham chiếu qua URL/config.
  5. Build, release, run — tách biệt nghiêm ngặt ba giai đoạn build, release và run.
  6. Processes — chạy app như một hay nhiều process stateless, share-nothing; giữ state trong backing service.
  7. Port binding — export service qua port binding; app tự chứa và bind vào một port.
  8. Concurrency — scale ngang qua mô hình process (thêm process), không chỉ máy to hơn.
  9. Disposability — tối đa độ bền với khởi động nhanh và graceful shutdown; process là thứ vứt được.
  10. Dev/prod parity — giữ development, staging và production giống nhau nhất có thể.
  11. Logs — coi log là event stream ghi ra stdout; để environment lo route và lưu trữ.
  12. 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:

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.

Clients đi qua facade trước
  1. ?Facade / routing proxy: đã migrate?
    đã migrate
    Service mới
chưa
Monolithco lại theo thời gian

Ư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:

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:

Best Practices

Tài liệu tham khảo

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:

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.”

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 couplingHigh coupling
High cohesionIdeal — modular, independently changeableRigid — good modules but tangled dependencies
Low cohesionScattered — related logic spread across modulesWorst — 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:

Layered architecture
  1. 01
    Presentation / API (HTTP handlers, controllers)translate transport ↔ domain
  2. 02
    Application / Service (use cases, orchestration)orchestrate, transactions
  3. 03
    Domain / Business (entities, rules)the "why" of the system
  4. 04
    Persistence / Data (repositories, ORM, queries)talk to the DB
  5. 05
    Infrastructure (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:

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).

Ports and adapters
Driving adapters (inbound) — drive the app
  • HTTP / REST
  • CLI
  • Message consumer
Application coredomain + ports
Driven adapters (outbound) — the app drives
  • 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:

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.

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).

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).

Comparison table

DimensionMonolithModular MonolithMicroservicesSOAServerless
Deploy unitOneOneMany (per service)Several (coarse)Many (per function)
CouplingRisk of highLow (enforced)Low (network)Medium (ESB)Low
DataOne shared DBModule-owned schemasDB per serviceOften sharedExternal stores
CommsIn-processIn-process / eventsNetwork (sync/async)ESB (SOAP/WS-*)Events / HTTP
TransactionsACID, easyACID, easySaga, eventualDistributedSaga, eventual
ScalingWhole appWhole appPer servicePer servicePer function, auto
Ops complexityLowLowHighHighMedium (provider-managed)
Team fitSmall teamsSmall–mediumMany autonomous teamsEnterpriseSmall, event-driven
Best whenStarting out, small scopeDefault; growing but cohesiveLarge scale, many teamsLegacy enterprise integrationSpiky/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)
ProtocolsREST/HTTP, gRPCKafka, RabbitMQ, SQS, NATS
CouplingTemporal — caller waits, callee must be upDecoupled — fire and forget
ConsistencyImmediateEventual
Failure modeCascading failures, need timeouts/retries/circuit breakersBroker buffers; needs idempotency
Best forQueries needing an immediate answerNotifications, 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.

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:

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:

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:

  1. Codebase — one codebase tracked in version control, many deploys.
  2. Dependencies — explicitly declare and isolate dependencies; never rely on system-wide packages.
  3. Config — store config (anything that varies per environment) in the environment, not in code.
  4. Backing services — treat backing services (DB, cache, queue) as attached, swappable resources referenced by URL/config.
  5. Build, release, run — strictly separate the build, release, and run stages.
  6. Processes — run the app as one or more stateless, share-nothing processes; persist state in backing services.
  7. Port binding — export services via port binding; the app is self-contained and binds to a port.
  8. Concurrency — scale out horizontally via the process model (more processes), not just bigger machines.
  9. Disposability — maximize robustness with fast startup and graceful shutdown; processes are disposable.
  10. Dev/prod parity — keep development, staging, and production as similar as possible.
  11. Logs — treat logs as event streams written to stdout; let the environment route and store them.
  12. 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:

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.

Clients hit the facade first
  1. ?Facade / routing proxy: migrated?
    migrated
    New service
not yet
Monolithshrinks over time

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:

Trade-offs, cost, and Conway’s Law

Architecture is the art of trade-offs. Three lenses to apply to any decision:

Best Practices

References