
Ddd Architecture Selector
- 15 installs
- 1 repo stars
- Updated July 29, 2026
- full-statck-skills/ddd-skills
Helps choose among five DDD architectures (Layered/Onion/Hexagonal/Clean/COLA) via a decision matrix, team-size mapping, and CQRS-level suggestion.
About
Guides architecture selection across five DDD patterns using a decision matrix, decision tree, and project-context inputs. A developer uses it when deciding which DDD architecture and CQRS level fits a project.
- Decision matrix across seven-plus dimensions
- CQRS level recommendation L0-L3
Ddd Architecture Selector by the numbers
- 15 all-time installs (skills.sh)
- Ranked #3,491 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/full-statck-skills/ddd-skills --skill ddd-architecture-selectorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 15 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 29, 2026 |
| Repository | full-statck-skills/ddd-skills ↗ |
What it does
Helps choose among five DDD architectures (Layered/Onion/Hexagonal/Clean/COLA) via a decision matrix, team-size mapping, and CQRS-level suggestion.
Files
DDD Architecture Selector
Architecture selection decision guide that evaluates 5 DDD architecture patterns (Layered, Onion, Hexagonal, Clean, COLA) against project context — team size, business complexity, tech stack, infrastructure change frequency, and ecosystem preference.
Workflow
Step 1: Collect Project Context
- Team size & DDD experience level
- Business complexity (simple CRUD / moderate / high)
- Technical stack (Spring Boot / Go / Node.js / .NET / Python)
- Infrastructure change frequency (low / medium / high)
- Multi-entry needs (REST + CLI + MQ + gRPC?)
- Test coverage requirements (unit / integration / E2E)
- Ecosystem preference (Chinese community / international)
Step 2: Run Decision Matrix
- Compare 5 architectures across 7+ dimensions
Step 3: Apply Decision Tree
- Complexity → Team Size → Tech Stack → Recommended Architecture
Step 4: Recommend CQRS Level
- L0: None, L1: Model Separation, L2: DB Separation, L3: Event Sourcing
Step 5: Classify Domain Types
- Core / Generic / Supporting → Apply different architectures per domain
Step 6: Suggest Microservice Splitting
- One BC → One Service, with merge/split rules
Step 7: Route to Specific Architecture Skill
- Link to detailed implementation guidance
When to Use (and When NOT to)
| ✅ Use When | ❌ Skip When |
|---|---|
| Starting a new project: need architecture decision | Architecture already decided → go directly to that skill |
| Comparing multiple DDD architectures | Just need DDD learning → use ddd-architecture-awesome |
| Team unsure which approach fits best | Need architecture evaluation of existing project → use ddd-architecture-evaluator |
| Planning microservice splitting strategy | Need domain modeling after selection → use ddd-domain-designer |
| Evaluating CQRS necessity | Non-DDD project evaluation (consider standard MVC) |
| Chinese enterprise team making tech decisions | Simple CRUD with no DDD needed → use standard MVC |
| Team size / DDD maturity unknown | Need code review of existing DDD code → use ddd-code-reviewer |
Boundary
| Category | Description | Alternative |
|---|---|---|
| ✅ Handles | New project architecture selection (5 DDD architectures) | — |
| ✅ Handles | Architecture comparison for migration decisions | — |
| ✅ Handles | CQRS / Event Sourcing necessity assessment | — |
| ✅ Handles | Domain classification (Core / Generic / Supporting) | — |
| ✅ Handles | Microservice splitting based on bounded contexts | — |
| ✅ Handles | Team size → architecture mapping | — |
| ⚠️ Requires | Basic project context: team size, business complexity, tech stack | — |
| ⚠️ Requires | Domain expert or product owner involvement for classification | — |
| ❌ Out of Scope | Architecture already decided — do not use this skill | ddd-architecture-layered / onion / hexagonal / clean / cola |
| ❌ Out of Scope | Just need DDD learning & concepts — should not use this skill | ddd-architecture-awesome |
| ❌ Out of Scope | Evaluate existing architecture health & quality — do not use | ddd-architecture-evaluator |
| ❌ Out of Scope | Domain modeling / aggregate design after selection — not use | ddd-domain-designer |
| ❌ Out of Scope | Code review for DDD compliance — do not use this skill | ddd-code-reviewer |
| ❌ Out of Scope | Non-DDD / standard MVC projects — not use this skill | Use standard MVC guides (not DDD skills) |
Audience
This skill is designed for: Backend developers (implementing DDD architectures), Software architects (evaluating and selecting patterns), Tech leads (reviewing team implementations), and DDD beginners (learning domain-driven design fundamentals).
Rules
1. Architecture selection must evaluate at least 5 dimensions. 2. Never recommend Event Sourcing as default — only after L2 CQRS success. 3. Team size and domain complexity must be the primary selection factors.
5-Architecture Decision Matrix
| Dimension | Layered | Onion | Hexagonal | Clean | COLA |
|---|---|---|---|---|---|
| Learning Cost | ★☆☆ | ★★★ | ★★★ | ★★★ | ★★☆ |
| Business Complexity Fit | ★☆☆ | ★★★ | ★★★ | ★★★ | ★★★ |
| CRUD Efficiency | ★★★ | ★☆☆ | ★☆☆ | ★☆☆ | ★★☆ |
| Infrastructure Replaceability | ★☆☆ | ★★★ | ★★★ | ★★★ | ★★☆ |
| Test Friendliness | ★☆☆ | ★★★ | ★★★ | ★★★ | ★★★ |
| Chinese Community | ★★★ | ★☆☆ | ★☆☆ | ★☆☆ | ★★★ |
| Code Generation Support | Good | Poor | Poor | Poor | Excellent |
| Module Physical Isolation | Low | Medium | Medium | High | High |
| Evolution Path Clarity | ★☆☆ | ★★★ | ★★★ | ★★★ | ★★★ |
| Team Size Fit | 1-5 | 5-15 | 5-15 | 15-50 | 5-50 |
| Origin | Martin Fowler | Jeffrey Palermo (2008) | Alistair Cockburn (2005) | Robert C. Martin (2012) | Alibaba (2018) |
| Architecture | Best For | Avoid When |
|---|---|---|
| Layered | Simple CRUD, small teams, traditional MVC migration | Complex business rules, frequent infra changes |
| Onion | High test coverage, changing infrastructure | CRUD-heavy apps, teams with no abstraction experience |
| Hexagonal | Multi-entry systems, microservice standardization | Simple single-entry CRUD, quick prototypes |
| Clean | Large enterprise systems, strict physical isolation | Small teams (<5), rapid MVP iterations |
| COLA | Chinese Spring Boot ecosystem, engineering standards | Non-Java stacks, international teams |
3-Step Decision Tree
Step 1: Assess Business Complexity
Business Complexity?
│
├── Simple CRUD (80%+ CRUD operations)
│ └── Planning DDD adoption in future?
│ ├── No → LAYERED ARCHITECTURE
│ └── Yes → COLA Simplified (single module)
│
├── Moderate (core business logic with rules)
│ ├── Chinese ecosystem / Spring Boot + MyBatis? → COLA
│ ├── Value domain layer purity most? → HEXAGONAL
│ ├── Multi-entry system (REST + CLI + MQ)? → HEXAGONAL
│ ├── Infrastructure changes often? → HEXAGONAL / ONION
│ └── .NET / Python stack? → ONION
│
└── High (multiple BCs, microservices)
├── Enterprise / international team? → CLEAN ARCHITECTURE
├── Chinese enterprise, per-service standards? → COLA (multi-module)
├── Mixed tech stack per service? → HEXAGONAL per service
└── Need microservice + standard per module? → COLA + HEXAGONAL hybridStep 2: Match Team Size
| Team Size | Recommended Architecture | Rationale |
|---|---|---|
| 1-5 | Layered or COLA simplified | Lowest ceremony, fastest delivery |
| 5-15 | Hexagonal / Onion / COLA (single module) | Balance abstraction and productivity |
| 15-50 | Clean / COLA (multi-module) | Physical isolation, parallel team work |
| 50+ | Microservices + Hexagonal per service | Autonomous teams, independent deploy |
Evolution Path: Team grows → upgrade architecture progressively: 1-5 (Layered) → 5-15 (Hexagonal) → 15-50 (Clean/COLA) → 50+ (Microservices+Hex).
Step 3: Final Recommendation
| If you have... | Then choose... | Why |
|---|---|---|
| Spring Boot + MyBatis + Chinese team | COLA | Ecosystem match, Chinese docs, scaffolding |
| Multi-entry (REST + CLI + MQ + gRPC) | Hexagonal | Adapter pattern handles multiple entry points |
| Strict module physical isolation, large enterprise | Clean | Entity → UseCase → Adapter enforced isolation |
| Infrastructure changes often (DB, MQ swap) | Hexagonal or Onion | Port/Adapter makes swapping trivial |
| Rapid prototype → evolve DDD later | Layered → upgrade | Lowest startup cost, clear evolution path |
| Complex business rules + TDD | Hexagonal | Domain layer independently testable |
| Team new to DDD, incremental adoption | Layered | Closest to traditional 3-tier, gentlest learning curve |
| Microservice internal architecture standard | Hexagonal + COLA per service | Port isolation + engineering standards |
| Read-heavy, complex queries | CQRS L2 | Read/write separation, independent optimization |
| Full audit trail needed | Event Sourcing (L3) | Event stream naturally supports audit |
Domain Partitioning + Microservice Splitting
Domain Classification (Core / Generic / Supporting)
| Domain Type | Investment Strategy | Architecture Recommendation | Examples |
|---|---|---|---|
| Core Domain (核心域) | Max investment, build in-house | Hexagonal / Clean / COLA with rich domain model | Order management, Payment processing, Pricing engine |
| Generic Domain (通用域) | Purchase or open-source reuse | Layered or off-the-shelf SaaS | Authentication, Authorization, Notification |
| Supporting Domain (支撑域) | Outsource or low priority | Layered or simple CRUD | Reports, Admin dashboard, Data export |
Key rule: Don't apply Hexagonal or Clean to generic/supporting domains — it wastes effort. Reserve complex architectures for Core domains only.
Microservice Splitting Rules
1. Default: One Bounded Context → One Microservice 2. Split when: Different deployment cadence, different scaling needs, different team ownership 3. Merge when: Strong transactional consistency needed, small context (< 2 weeks dev), same team 4. Start conservative: Fewer services, split as needed (proven by need, not anticipation) 5. Communication: Events for eventual consistency, RPC for strong consistency (rare)
CQRS Level Suggestion
| Level | Description | Cost | Architecture Support | When to Use |
|---|---|---|---|---|
| L0 — None | Single model, single DB | Zero | All architectures (default) | Simple CRUD, no read/write conflict |
| L1 — Model Separation | CommandService + QueryService, shared DB | Low | All architectures | Moderate read/write disparity |
| L2 — DB Separation | Command DB + Read DB (ES/slave), sync via events | Medium | Hexagonal / Clean / COLA | High read volume, complex queries |
| L3 — Event Sourcing | Event Store + Projections, full event replay | High | Hexagonal / Clean | Audit trail, temporal queries, compliance |
Recommendation: Start at L0, prove need for higher levels. L2+ should only be adopted alongside Hexagonal or Clean architectures for proper port isolation.
Architecture Skill Navigation
| Selected | Next Skill |
|---|---|
| Layered | ddd-architecture-layered |
| Onion | ddd-architecture-onion |
| Hexagonal | ddd-architecture-hexagonal |
| Clean | ddd-architecture-clean |
| COLA | ddd-architecture-cola |
Related: ddd-cqrs-architecture, ddd-domain-designer, ddd-architecture-evaluator
Gotchas
See references/gotchas.md for 15 pitfalls.
FAQ
See references/faq.md for 15 Q&A.
Security & Safety
This skill is pure documentation. It does not collect user data, does not access external services or networks, and contains no executable scripts.
References
See references/ for deep comparisons, microservice/domain, clean+DDD+hexagonal, ddd4j analysis, gotchas, FAQ, external resources, and 5 case studies.
Case Study: E-Commerce Platform Architecture Selection
Project Context
| Dimension | Detail |
|---|---|
| Team | 12 developers (3 frontend, 7 backend, 2 QA) |
| DDD Experience | 2 members have DDD exposure, rest are beginners |
| Business Complexity | Moderate-High: order workflow, inventory, payment, promotion |
| Tech Stack | Spring Boot 3.x + MyBatis + MySQL + RocketMQ |
| Infra Change Frequency | Medium: potential DB migration, MQ upgrade planned |
| Entry Points | REST API (web + mobile), MQ events (inventory sync) |
| Ecosystem | Strong Chinese ecosystem preference |
| CQRS Need | Read/write disparity moderate (product search heavy) |
Decision Process
Step 1: Matrix Evaluation
Business Complexity: Moderate-High → Hexagonal / Clean / COLA
Team Size: 12 → All fit, but Clean too heavy for team DDD level
Tech Stack: Spring Boot + MyBatis → COLA has best tooling support
Ecosystem: Chinese → COLA preferred
Entry Points: Multi (REST + MQ) → Hexagonal also strong candidateStep 2: Decision Tree Result
Following the decision tree: 1. Business complexity: Moderate-High → not simple CRUD 2. Chinese ecosystem + Spring Boot + MyBatis → COLA primary recommendation 3. Multi-entry concern → adopt Hexagonal Port/Adapter pattern within COLA's structure (COLA's Adapter layer + Port interfaces)
Step 3: CQRS Assessment
- Read/write disparity: Moderate (product search is heavy read)
- Recommendation: CQRS L1 — Model Separation only
- CommandService for writes (order creation, payment)
- QueryService for reads (product search, order listing)
- Shared database initially, can upgrade to L2 later
Step 4: Domain Classification
| Domain | Type | Architecture | Priority |
|---|---|---|---|
| Order Management | Core | COLA (rich model) | P0 |
| Payment Processing | Core | COLA (rich model) | P0 |
| Inventory | Core | COLA (rich model) | P0 |
| Product Catalog | Core | COLA (rich model) | P0 |
| User Auth | Generic | Layered (shiro) | P2 |
| Notification | Generic | Off-the-shelf | P2 |
| Admin Reports | Supporting | Simple CRUD | P3 |
Final Recommendation
Primary Architecture: COLA v5 (Multi-Module)
CQRS Level: L1 — Model Separation
Layering Strategy: Strict (with ArchUnit enforcement)
Module Structure:
├── order-adapter / order-app / order-domain / order-infrastructure
├── payment-adapter / payment-app / payment-domain / payment-infrastructure
├── inventory-adapter / inventory-app / inventory-domain / inventory-infrastructure
├── product-adapter / product-app / product-domain / product-infrastructure
└── auth-adapter / auth-app (simplified — Generic domain)Rationale
1. COLA provides the best ecosystem fit for Spring Boot + MyBatis + Chinese team 2. COLA's Adapter + Port pattern naturally handles both REST and MQ entry points 3. CQRS L1 enough for current stage — product search performance is adequate with indexing 4. Generic/Supporting domains use simpler architectures to avoid over-engineering 5. Strict layering enforced via ArchUnit in CI prevents domain corruption
Next Steps
1. Proceed to ddd-architecture-cola for implementation 2. Order BC as first implementation module (highest priority Core domain) 3. Set up ArchUnit dependency checks in CI pipeline 4. Revisit CQRS upgrade to L2 when product search becomes bottleneck
Case Study: Enterprise Logistics Platform Architecture Selection
Project Context
| Dimension | Detail |
|---|---|
| Team | 45 developers (7 squads: routing, tracking, dispatch, billing, warehouse, analytics, integration) |
| DDD Experience | High — core team of 12 with multiple DDD projects |
| Business Complexity | Very High: real-time routing optimization, multi-carrier integration, SLA management |
| Tech Stack | Java 21 + Spring Boot + MongoDB + PostgreSQL + Kafka + Redis |
| Infra Change Frequency | High: carriers change API frequently (50+ carrier integrations) |
| Entry Points | REST API + WebSocket (real-time tracking) + MQ events + Batch processing + Scheduled jobs |
| Ecosystem | International + Chinese (multi-region deployment) |
| CQRS Need | Very High — tracking reads 1000:1 vs writes, analytics runs complex aggregations |
Decision Process
Step 1: Matrix Evaluation
Business Complexity: Very High → Hexagonal / Clean / COLA all valid
Team Size: 45 (7 squads) → Clean for strict isolation, Hexagonal per squad flexibility
Infra Change Frequency: High → Hexagonal (carrier adapter swap out)
Entry Points: 5 types (REST+WS+MQ+Batch+Schedule) → Hexagonal wins on adapter diversity
Ecosystem: Multi-region → No ecosystem preference, Hexagonal is language-agnostic
CQRS Need: Very High → L2+ architecture requiredStep 2: Decision Tree Result
1. Very High complexity + frequent infra changes → Hexagonal Architecture primary recommendation 2. 7 squads need clear boundaries → One Hexagonal module per bounded context per squad 3. CQRS L2 — DB Separation for tracking, CQRS L1 for other domains
Step 3: CQRS Assessment
Tracking Domain (read:write ≈ 1000:1):
- CQRS L2 — DB Separation (MongoDB for write, Elasticsearch for read)
- Synchronization via Kafka domain events → ES indexing service
- Real-time WebSocket reads from ES
Analytics Domain (complex aggregations):
- CQRS L2 — DB Separation (event-sourced write, materialized views for read)
- Pre-computed aggregations updated via event stream processing
Other Domains (order, dispatch, billing):
- CQRS L1 — Model Separation only (shared DB)
Step 4: Domain Classification
| Domain | Type | Architecture | CQRS | Squad |
|---|---|---|---|---|
| Real-time Routing | Core | Hexagonal + L2 CQRS | L2 | Squad A |
| Shipment Tracking | Core | Hexagonal + L2 CQRS (ES) | L2 | Squad B |
| Order Dispatch | Core | Hexagonal + L1 CQRS | L1 | Squad C |
| Carrier Integration | Generic | Hexagonal (adapter-heavy) | L0 | Squad D |
| Billing | Core | Hexagonal + L1 CQRS | L1 | Squad E |
| Warehouse Ops | Supporting | Hexagonal (simplified) | L0 | Squad F |
| Analytics | Supporting | Hexagonal + L2 CQRS (ES) | L2 | Squad G |
Final Recommendation
Primary Architecture: Hexagonal (per bounded context)
CQRS: Mixed (L2 for tracking & analytics, L1 for others, L0 for generic)
Cross-cutting:
- Common: Shared kernel (Money, GeoPoint, TimeWindow)
- Anti-Corruption Layer (ACL) for each carrier integration
- Event backbone: Kafka for all domain events
- API Gateway: GraphQL for client-facing, gRPC for inter-serviceRationale
1. Hexagonal is the best fit for multi-adapter diversity (REST + WS + MQ + Batch + Schedule) 2. Carrier integrations map naturally to Outbound Adapters — swap without domain impact 3. CQRS L2 for tracking is justified by 1000:1 read/write ratio 4. 7 squads each own a Hexagonal module provides autonomy without architecture divergence 5. Anti-Corruption Layer is critical for 50+ carrier API integrations with different data models
Next Steps
1. Proceed to ddd-architecture-hexagonal for Port/Adapter implementation 2. Follow with ddd-cqrs-architecture for CQRS L2 + ES deep-dive 3. Carrier Integration ACL as first pilot (highest adapter diversity, lowest risk domain) 4. Shipment Tracking as second pilot (demonstrates CQRS L2 value) 5. Set up Architecture Decision Records (ADR) per squad to track evolution 6. Establish cross-squad Architecture Review Board for shared kernel changes
Case Study: Fintech Billing System Architecture Selection
Project Context
| Dimension | Detail |
|---|---|
| Team | 25 developers (split across 3 squads: billing, invoice, collection) |
| DDD Experience | Intermediate — 5 developers with DDD project experience |
| Business Complexity | High: complex billing rules, multi-currency, proration, tax calculation |
| Tech Stack | Java 17 + Spring Boot + PostgreSQL + Kafka |
| Infra Change Frequency | Low (regulated industry, changes are slow and audited) |
| Entry Points | REST API + Kafka events (from upstream order system) + Scheduled jobs |
| Ecosystem | International (no Chinese ecosystem preference) |
| CQRS Need | High — billing has heavy audit requirements, complex queries |
| Compliance | SOX compliance, full audit trail required |
Decision Process
Step 1: Matrix Evaluation
Business Complexity: High → Hexagonal / Clean / COLA
Team Size: 25 (3 squads) → Clean (best isolation), COLA also fits
Tech Stack: Java + Spring Boot + Kafka → Clean has strong Java support
Ecosystem: International → Clean / Hexagonal preferred
Compliance: SOX audit trail → CQRS L3 (Event Sourcing) needed
Entry Points: Multi (REST + Kafka + Scheduler) → HexagonalStep 2: Decision Tree Result
1. Business complexity: High → enterprise-level architecture needed 2. International team + strict module isolation requirement → Clean Architecture primary recommendation 3. Audit trail → CQRS L3 with Event Sourcing
Step 3: CQRS Assessment
- Read/write disparity: High (billing generates many read reports)
- Audit requirement: SOX compliance demands full audit trail
- Recommendation: CQRS L3 — Event Sourcing
- Command side: Event Store (Kafka as event log)
- Query side: Materialized views (PostgreSQL projections)
- All billing operations recorded as event streams
Step 4: Domain Classification
| Domain | Type | Architecture | Priority |
|---|---|---|---|
| Billing Engine | Core | Clean + Event Sourcing | P0 |
| Invoice Generation | Core | Clean + Event Sourcing | P0 |
| Collection Management | Core | Clean | P0 |
| Tax Calculation | Core | Clean | P0 |
| Customer Management | Generic | Layered | P2 |
| Report Generation | Supporting | Simple Clean (read-only) | P2 |
Final Recommendation
Primary Architecture: Clean Architecture + Event Sourcing (CQRS L3)
Layering Strategy: Strict (physical module isolation)
Squad Structure:
Squad A (Billing): Billing-core + Billing-usecase + Billing-adapter + Billing-framework
Squad B (Invoice): Invoice-core + Invoice-usecase + Invoice-adapter + Invoice-framework
Squad C (Collection): Collection-core + Collection-usecase + Collection-adapter + Collection-framework
Shared Kernel: Money, Currency, Rate (across all squads)Rationale
1. Clean Architecture provides the strictest module isolation, essential for 3 squads working in parallel 2. Event Sourcing meets SOX compliance requirement for full audit trail 3. Separate modules per squad allows independent development and deployment 4. Generic domains simplified to Layered to conserve effort 5. Kafka as event store aligns with existing infrastructure
Next Steps
1. Proceed to ddd-architecture-clean for Clean Architecture implementation 2. Follow with ddd-cqrs-architecture for Event Sourcing deep-dive 3. Billing Engine as first implementation (highest complexity Core domain) 4. Define shared kernel module for Money, Currency value objects 5. Set up ArchUnit + commit hooks for dependency rule enforcement
Case Study: Healthcare Payer System Architecture Selection
Project Context
| Dimension | Detail |
|---|---|
| Team | 10 developers (split across 2 squads: claims, provider network) |
| DDD Experience | Intermediate — team lead has DDD experience, 3 members trained |
| Business Complexity | High: claims adjudication rules, network contract management, regulatory compliance |
| Tech Stack | .NET 8 + C# + Entity Framework + SQL Server + RabbitMQ |
| Infra Change Frequency | Medium: potential DB migration (SQL Server → Cosmos DB for claims) |
| Entry Points | REST API + MQ events (from provider portal) + Batch (nightly claims processing) |
| Ecosystem | International (.NET ecosystem, no Chinese ecosystem preference) |
| CQRS Need | Moderate — claims processing is write-heavy, reporting is read-heavy |
Decision Process
Step 1: Matrix Evaluation
Business Complexity: High → Onion / Hexagonal / Clean all fit
Team Size: 10 → Onion (5-15) best match
Tech Stack: .NET 8 + EF Core → Onion has strong .NET ecosystem alignment
Ecosystem: International → Onion / Hexagonal
CQRS Need: Moderate → L1 adequate
Infra Change: Medium (DB swap) → Onion's domain isolation handles thisStep 2: Decision Tree Result
1. Business complexity: High → not simple CRUD 2. .NET ecosystem + intermediate DDD team → Onion Architecture primary recommendation 3. EF Core's DbContext pattern maps naturally to Onion's Repository + UnitOfWork 4. Moderate CQRS need → CQRS L1 — Model Separation
Step 3: CQRS Assessment
- Read/write disparity: Moderate (claims reports are heavy read)
- Recommendation: CQRS L1 — Model Separation
- CommandService for write operations (claims submission, adjudication)
- QueryService for read operations (claims status, provider reports)
- Shared SQL Server database initially, project for DB separation later
Step 4: Domain Classification
| Domain | Type | Architecture | Priority |
|---|---|---|---|
| Claims Adjudication | Core | Onion (rich domain model) | P0 |
| Provider Network | Core | Onion (rich domain model) | P0 |
| Member Eligibility | Core | Onion | P0 |
| Claims Payment | Core | Onion | P0 |
| User Auth | Generic | Layered (ASP.NET Identity) | P2 |
| Report Generation | Supporting | Simple Onion (read-only) | P2 |
Final Recommendation
Primary Architecture: Onion Architecture
CQRS Level: L1 — Model Separation
Layering Strategy: Domain-centric with Core → Infrastructure dependency
Module Structure:
├── Claims.Domain / Claims.Core / Claims.Application / Claims.Infrastructure
├── Provider.Domain / Provider.Core / Provider.Application / Provider.Infrastructure
├── Member.Domain / Member.Core / Member.Application / Member.Infrastructure
└── Payment.Domain / Payment.Core / Payment.Application / Payment.Infrastructure
Shared: Common.Domain (base types, domain primitives)Rationale
1. Onion Architecture aligns naturally with .NET's dependency inversion (interface-based DI) 2. Domain Model as innermost circle matches claims adjudication rule complexity 3. EF Core Repositories map to Onion's Repository pattern at Infrastructure layer 4. CQRS L1 handles the moderate read/write disparity without Event Sourcing overhead 5. Separate modules per domain allows 2 squads to work in parallel 6. DB migration support — Onion's domain isolation means swapping SQL Server → Cosmos DB requires only Infrastructure changes
Next Steps
1. Proceed to ddd-architecture-onion for implementation 2. Claims Adjudication as first implementation module (highest domain complexity) 3. Set up dependency validation (NetArchTest) to enforce Onion layering 4. Revisit CQRS L2 evaluation when claims volume exceeds 1M/month
Case Study: SaaS Startup Architecture Evolution
Project Context
| Dimension | Phase 1 (0-6 months) | Phase 2 (6-18 months) | Phase 3 (18+ months) |
|---|---|---|---|
| Team | 3 developers (2 backend, 1 frontend) | 8 developers | 20 developers |
| DDD Experience | None | 2 members trained in DDD | 5 members with DDD exp |
| Business Complexity | Simple CRUD + basic workflows | Moderate: subscription logic, usage metering | High: multi-product billing, partner integrations |
| Tech Stack | Python + Django + PostgreSQL | Python + FastAPI + PostgreSQL + Redis | Python + Go hybrid + PostgreSQL + Kafka |
| Entry Points | Single REST API | REST API + basic webhooks | REST API + webhooks + MQ events + scheduled jobs |
| Funding Stage | Pre-seed | Series A | Series B+ |
Decision Process (Evolutionary)
Phase 1: Pre-seed — Get to Market Fast
Context: MVP needs to ship in 3 months. Team of 3, no DDD knowledge.
Decision: Standard Layered Architecture (Django MVC)
No DDD, no architecture over-engineering priority.
Controller (Django views) → Service → Model (ORM)CQRS Level: L0 — Single model, single DB
Phase 2: Series A — Introduce Structure
Context: Product validated, team growing, subscription logic getting complex. Must refactor.
Migration: Layered → Hexagonal Architecture (progressive)
Phase 2a: Identify aggregates (Subscription, Usage, Invoice)
Phase 2b: Extract Repository interfaces to Domain layer
Phase 2c: Extract UseCase interfaces (Ports)
Phase 2d: Migrate controllers → Adapters (keep backward compat)CQRS Level: L1 — Model Separation (Usage tracking is write-heavy, reporting is read-heavy)
Architecture Decision: Hexagonal chosen because:
- Python's FastAPI has good adapter pattern support
- Webhook entry points are growing
- Testability needed for subscription billing logic
Phase 3: Series B+ — Scale and Standardize
Context: 20-person team, event-driven integrations, partner ecosystem.
Evolution: Hexagonal per microservice + CQRS L2 for high-volume paths
Microservices split by bounded context:
├── Subscription Service (Hexagonal + CQRS L1)
├── Usage Metering Service (Hexagonal + CQRS L2 — high write volume)
├── Billing Service (Hexagonal + Event Sourcing)
├── Invoice Service (Hexagonal)
├── Partner Integration Service (Hexagonal)
└── Notification Service (Layered — Generic domain)Lessons Learned
1. Right-time architecture: Phase 1 Layered was the correct choice for MVP speed. Introducing Hexagonal in Phase 1 would have wasted 2-3 months. 2. Progressive migration works: The Strangler Fig approach (new features in Hexagonal, old in Layered) allowed continuous delivery during refactoring. 3. CQRS upgrade driven by data: Usage metering reached 50K writes/second → L2 DB separation was a data-driven decision, not an architectural preference. 4. Team DDD maturity matters: Hexagonal adoption only succeeded because 2 developers completed DDD training before Phase 2 migration.
Final Recommendation Summary
| Phase | Architecture | CQRS | Key Decision Driver |
|---|---|---|---|
| Pre-seed | Layered | L0 | Speed to market |
| Series A | Hexagonal | L1 | Testability + multi-entry |
| Series B+ | Hexagonal (per service) | L1-L3 | Scalability + event-driven |
Next Steps
1. Phase 1 → No architecture skill needed (standard Django MVC) 2. Phase 2 migration → ddd-architecture-hexagonal for Port/Adapter pattern 3. Phase 2 CQRS → ddd-cqrs-architecture for L1 model separation 4. Phase 3 scaling → ddd-architecture-evaluator for migration readiness assessment
DDD Strategic Patterns
Sources:
- Domain-Driven Design: The Blue Book — Eric Evans (2003)
- DDD Resources — Domain Language (Eric Evans)
- Bounded Context — Martin Fowler
- Domain Driven Design — Martin Fowler
- Anti-Corruption Layer — AWS
- Domain Analysis for Microservices — Microsoft
Overview
Strategic DDD patterns help decompose large systems into manageable parts with clear boundaries. They answer: "How do we divide a complex domain?"
DDD is fundamentally collaborative. The patterns below emerge from conversations, whiteboarding, and modeling sessions with domain experts—not from coding alone.
---
Domain Discovery Techniques
Event Storming
A workshop technique for discovering domain events, aggregates, and bounded contexts.
Orange sticky: Domain Event (past tense: "OrderPlaced")
Blue sticky: Command (imperative: "Place Order")
Yellow sticky: Aggregate (noun: "Order")
Pink sticky: External System / Policy
Purple sticky: Problem / QuestionWorkshop flow: 1. Chaotic exploration — Everyone adds events they know about 2. Timeline ordering — Arrange events chronologically 3. Identify aggregates — Group related events 4. Find boundaries — Where language changes = bounded context boundary 5. Surface problems — Mark unclear areas for follow-up
Context Mapping Workshop
For existing systems, map how bounded contexts currently interact: 1. List all systems/services 2. Identify which team owns each 3. Draw relationships (upstream/downstream) 4. Label relationship types (ACL, Conformist, etc.) 5. Identify pain points in current integrations
---
Ubiquitous Language
The foundation of DDD. A shared vocabulary between developers and domain experts that appears in:
- Code (class names, method names)
- Documentation
- Conversations
- UI labels
Principles
1. One language per bounded context - Different contexts may use the same word differently 2. Code reflects the language - Order.confirm() not Order.setStatus("confirmed") 3. Evolve together - When language changes, code changes
Example
❌ Technical language:
"Set the order entity's status field to 2 and insert a record"
✅ Ubiquitous language:
"Confirm the order and record that it was confirmed"// ❌ Technical, not ubiquitous
class Order {
setStatus(status: number): void { this.status = status; }
}
// ✅ Ubiquitous language
class Order {
confirm(): void {
if (this.status !== OrderStatus.Pending) {
throw new OrderCannotBeConfirmedException(this.id);
}
this.status = OrderStatus.Confirmed;
this.confirmedAt = new Date();
this.addDomainEvent(new OrderConfirmed(this.id));
}
}---
Bounded Contexts
A semantic boundary where a particular domain model applies. Within a bounded context, terms have precise, unambiguous meaning.
Key insight: Polysemy (same word, different meanings) across departments is natural, not a problem. The same term meaning different things in different contexts is expected—"the dominant boundary factor is human culture and language variation." — Martin Fowler
Key Concepts
- Each bounded context has its own ubiquitous language
- Each bounded context has its own model
- The same real-world concept may have different representations in different contexts
Example: E-Commerce System
flowchart TB
subgraph ECommerce["E-Commerce System"]
subgraph Sales["Sales Context"]
SC1["Customer: id, email, preferences"]
SC2["Order: items, total, status"]
end
subgraph Shipping["Shipping Context"]
SH1["Recipient: name, address, phone"]
SH2["Shipment: packages, carrier, trackingNo"]
end
subgraph Billing["Billing Context"]
BC1["Payer: name, billingAddress, paymentMethod"]
BC2["Invoice: lineItems, total, dueDate"]
end
subgraph Catalog["Catalog Context"]
CC1["Product: name, description, price"]
CC2["(no customer concept)"]
end
end
style Sales fill:#3b82f6,stroke:#2563eb,color:white
style Shipping fill:#10b981,stroke:#059669,color:white
style Billing fill:#f59e0b,stroke:#d97706,color:white
style Catalog fill:#8b5cf6,stroke:#7c3aed,color:white"Customer" means different things:
- Sales: Email, preferences, order history
- Shipping: Delivery address, phone number
- Billing: Payment methods, billing address
Bounded Context = Microservice Boundary
In microservices, each bounded context typically becomes a separate service:
flowchart LR
subgraph Sales["Sales Service"]
S1["Orders DB"]
S2["Order API"]
end
subgraph Shipping["Shipping Service"]
SH1["Shipments DB"]
SH2["Shipping API"]
end
subgraph Billing["Billing Service"]
B1["Invoices DB"]
B2["Billing API"]
end
Sales -->|events| Shipping
Shipping -->|events| Billing
Sales -.->|Integration Events| Events[("Event Bus")]
Shipping -.-> Events
Billing -.-> Events
style Sales fill:#3b82f6,stroke:#2563eb,color:white
style Shipping fill:#10b981,stroke:#059669,color:white
style Billing fill:#f59e0b,stroke:#d97706,color:white---
Subdomains
Areas of business expertise. Subdomains are discovered, not designed.
Types
| Type | Description | Investment | Example |
|---|---|---|---|
| Core | Competitive advantage | High | Product recommendation engine |
| Supporting | Necessary but not unique | Medium | Order management |
| Generic | Commodity, buy/outsource | Low | Email sending, payments |
Identification Questions
1. What makes us different from competitors? → Core 2. What do we need but isn't our specialty? → Supporting 3. What does everyone need the same way? → Generic
Example: E-Commerce
flowchart TB
subgraph Subdomains["Subdomains"]
subgraph Core["CORE"]
C1["Product search & recommendations"]
C2["Pricing engine"]
C3["Personalization"]
end
subgraph Supporting["SUPPORTING"]
S1["Order management"]
S2["Inventory"]
S3["Customer support"]
S4["Reporting"]
end
subgraph Generic["GENERIC"]
G1["Authentication (Auth0)"]
G2["Payments (Stripe)"]
G3["Email (SendGrid)"]
G4["File storage (S3)"]
end
end
Core --> CoreStrat["Build in-house\nBest developers"]
Supporting --> SuppStrat["Build or buy\nSolid but simple"]
Generic --> GenStrat["Use third-party\nDon't reinvent"]
style Core fill:#ef4444,stroke:#dc2626,color:white
style Supporting fill:#f59e0b,stroke:#d97706,color:white
style Generic fill:#6b7280,stroke:#4b5563,color:white---
Context Mapping
Describes relationships between bounded contexts.
Relationship Patterns
Partnership
Two contexts succeed or fail together. Teams coordinate closely.
flowchart LR
A["Context A"] <-->|"Partnership\nJoint planning\nShared success"| B["Context B"]
style A fill:#3b82f6,stroke:#2563eb,color:white
style B fill:#3b82f6,stroke:#2563eb,color:whiteShared Kernel
Two contexts share a subset of the domain model.
flowchart LR
subgraph A["Context A"]
SK["Shared Kernel"]
end
subgraph B["Context B"]
B1[" "]
end
SK <-->|shared| B
style A fill:#3b82f6,stroke:#2563eb,color:white
style B fill:#10b981,stroke:#059669,color:white
style SK fill:#f59e0b,stroke:#d97706,color:whiteWarning: Shared kernels create coupling. Use sparingly.
Customer-Supplier
Upstream context provides what downstream needs.
flowchart LR
U["Upstream\n(Supplier)"] -->|"Provides API"| D["Downstream\n(Customer)"]
style U fill:#3b82f6,stroke:#2563eb,color:white
style D fill:#10b981,stroke:#059669,color:whiteConformist
Downstream conforms to upstream's model with no negotiation power.
flowchart LR
U["Upstream\n(Dictator)"] -->|"Take it or leave it"| D["Downstream\n(Conformist)\nUses their model"]
style U fill:#ef4444,stroke:#dc2626,color:white
style D fill:#6b7280,stroke:#4b5563,color:whiteExample: Integrating with a third-party API (Stripe, AWS).
Anti-Corruption Layer (ACL)
Translation layer protecting your model from external models.
flowchart LR
Ext["External\nContext"] --> ACL["ACL\nTranslator + Adapter"]
ACL --> Your["Your\nContext"]
ACL -.->|"Translates external\nmodel to your model"| Note[" "]
style Ext fill:#ef4444,stroke:#dc2626,color:white
style ACL fill:#f59e0b,stroke:#d97706,color:white
style Your fill:#10b981,stroke:#059669,color:white
style Note fill:none,stroke:noneUse when:
- Integrating with legacy systems
- Integrating with third-party APIs
- External model is messy or poorly designed
// Anti-Corruption Layer Example
// infrastructure/external/stripe/stripe_payment_acl.ts
import Stripe from 'stripe';
import { Payment, PaymentStatus } from '@/domain/payment/payment';
import { Money } from '@/domain/shared/money';
export class StripePaymentACL {
constructor(private readonly stripe: Stripe) {}
async createPayment(payment: Payment): Promise<string> {
const paymentIntent = await this.stripe.paymentIntents.create({
amount: payment.amount.cents,
currency: payment.amount.currency.toLowerCase(),
metadata: {
orderId: payment.orderId.value,
customerId: payment.customerId.value,
},
});
return paymentIntent.id;
}
translateStatus(stripeStatus: string): PaymentStatus {
const mapping: Record<string, PaymentStatus> = {
'requires_payment_method': PaymentStatus.Pending,
'requires_confirmation': PaymentStatus.Pending,
'requires_action': PaymentStatus.Pending,
'processing': PaymentStatus.Processing,
'succeeded': PaymentStatus.Completed,
'canceled': PaymentStatus.Cancelled,
'requires_capture': PaymentStatus.Authorized,
};
return mapping[stripeStatus] ?? PaymentStatus.Unknown;
}
translateWebhook(event: Stripe.Event): DomainEvent | null {
switch (event.type) {
case 'payment_intent.succeeded':
const intent = event.data.object as Stripe.PaymentIntent;
return new PaymentCompleted(
PaymentId.from(intent.metadata.orderId),
Money.fromCents(intent.amount, intent.currency.toUpperCase())
);
case 'payment_intent.payment_failed':
return null;
default:
return null;
}
}
}Open Host Service / Published Language
Expose a well-defined protocol for integration.
flowchart TB
subgraph OHS["Open Host Service"]
PL["Published Language\n(REST API, gRPC, Events Schema)"]
BC["Your Bounded Context"]
end
PL --> A["Consumer A"]
PL --> B["Consumer B"]
PL --> C["Consumer C"]
style OHS fill:#3b82f6,stroke:#2563eb,color:white
style PL fill:#10b981,stroke:#059669,color:white
style A fill:#6b7280,stroke:#4b5563,color:white
style B fill:#6b7280,stroke:#4b5563,color:white
style C fill:#6b7280,stroke:#4b5563,color:white---
Context Map Diagram
Visual representation of all bounded contexts and their relationships:
flowchart TB
Identity["Identity Context\n(Generic - Auth0)"]
Legacy["Legacy Catalog\n(Legacy)"]
Sales["Sales Context\n(Core)"]
Shipping["Shipping Context\n(Supporting)"]
Billing["Billing Context\n(Supporting)"]
Stripe["Stripe Gateway\n(Generic)"]
Identity -->|Conformist| Sales
Legacy -->|ACL| Sales
Sales <-->|Customer-Supplier| Shipping
Sales -->|Open Host Service| Billing
Billing -->|Conformist| Stripe
style Identity fill:#6b7280,stroke:#4b5563,color:white
style Legacy fill:#9ca3af,stroke:#6b7280,color:white
style Sales fill:#ef4444,stroke:#dc2626,color:white
style Shipping fill:#f59e0b,stroke:#d97706,color:white
style Billing fill:#f59e0b,stroke:#d97706,color:white
style Stripe fill:#6b7280,stroke:#4b5563,color:white---
Integration Patterns
Domain Events for Context Integration
interface OrderPlaced {
eventType: 'sales.order.placed';
orderId: string;
customerId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
total: number;
shippingAddress: Address;
occurredAt: string;
}
class ShippingOrderPlacedHandler {
async handle(event: OrderPlaced): Promise<void> {
const shipment = Shipment.create({
orderId: ShipmentOrderId.from(event.orderId),
recipient: Recipient.fromAddress(event.shippingAddress),
packages: this.calculatePackages(event.items),
});
await this.shipmentRepository.save(shipment);
}
}
class BillingOrderPlacedHandler {
async handle(event: OrderPlaced): Promise<void> {
const invoice = Invoice.create({
orderId: InvoiceOrderId.from(event.orderId),
customerId: BillingCustomerId.from(event.customerId),
lineItems: event.items.map(item => ({
description: `Product ${item.productId}`,
quantity: item.quantity,
unitPrice: Money.fromNumber(item.price),
})),
total: Money.fromNumber(event.total),
});
await this.invoiceRepository.save(invoice);
}
}Event Schema Registry
Define and version integration event schemas:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://api.company.com/events/sales/order-placed/v1.json",
"title": "OrderPlaced",
"description": "Published when an order is successfully placed",
"type": "object",
"required": ["eventType", "eventId", "orderId", "occurredAt"],
"properties": {
"eventType": { "const": "sales.order.placed" },
"eventId": { "type": "string", "format": "uuid" },
"orderId": { "type": "string", "format": "uuid" },
"customerId": { "type": "string", "format": "uuid" },
"total": { "type": "number", "minimum": 0 },
"occurredAt": { "type": "string", "format": "date-time" }
}
}---
Strategic Design Checklist
- [ ] Identify ubiquitous language terms with domain experts
- [ ] Map subdomains (core, supporting, generic)
- [ ] Define bounded context boundaries
- [ ] Document context map with relationships
- [ ] Design anti-corruption layers for external systems
- [ ] Define integration event schemas
- [ ] Ensure each context has its own data store
在上一讲中我重点介绍了 DDD 分层架构,同时我也提到了微服务架构模型其实还有好多种,不知道你注意到了没?这些架构模型在我们的实际应用中都具有很高的借鉴价值。
那么今天我们就把 DDD 分层架构(详情介绍如有遗忘可回看
)、整洁架构、六边形架构这三种架构模型放到一起,对比分析,看看如何利用好它们,帮助我们设计出高内聚低耦合的中台以及微服务架构。
整洁架构又名“洋葱架构”。为什么叫它洋葱架构?看看下面这张图你就明白了。整洁架构的层就像洋葱片一样,它体现了分层的设计思想。
在整洁架构里,同心圆代表应用软件的不同部分,从里到外依次是领域模型、领域服务、应用服务和最外围的容易变化的内容,比如用户界面和基础设施。
整洁架构最主要的原则是依赖原则,它定义了各层的依赖关系,
越往里依赖越低,代码级别越高,越是核心能力。
外圆代码依赖只能指向内圆,内圆不需要知道外圆的任何情况。
在洋葱架构中,各层的职能是这样划分的:
领域模型实现领域内核心业务逻辑,它封装了企业级的业务规则。领域模型的主体是实体,一个实体可以是一个带方法的对象,也可以是一个数据结构和方法集合。
领域服务实现涉及多个实体的复杂业务逻辑。
应用服务实现与用户操作相关的服务组合与编排,它包含了应用特有的业务流程规则,封装和实现了系统所有用例。
最外层主要提供适配的能力,适配能力分为主动适配和被动适配。主动适配主要实现外部用户、网页、批处理和自动化测试等对内层业务逻辑访问适配。被动适配主要是实现核心业务逻辑对基础资源访问的适配,比如数据库、缓存、文件系统和消息中间件等。
红圈内的领域模型、领域服务和应用服务一起组成软件核心业务能力。
六边形架构又名“端口适配器架构”。追溯微服务架构的渊源,一般都会涉及到六边形架构。
六边形架构的核心理念是:
应用是通过端口与外部进行交互的。
我想这也是微服务架构下 API 网关盛行的主要原因吧。
也就是说,在下图的六边形架构中,红圈内的核心业务逻辑(应用程序和领域模型)与外部资源(包括 APP、Web 应用以及数据库资源等)完全隔离,仅通过适配器进行交互。它解决了业务逻辑与用户界面的代码交错问题,很好地实现了前后端分离。六边形架构各层的依赖关系与整洁架构一样,都是由外向内依赖。
六边形架构将系统分为内六边形和外六边形两层,这两层的职能划分如下:
红圈内的六边形实现应用的核心业务逻辑;
外六边形完成外部应用、驱动和基础资源等的交互和访问,对前端应用以 API 主动适配的方式提供服务,对基础资源以依赖倒置被动适配的方式实现资源访问。
六边形架构的一个端口可能对应多个外部系统,不同的外部系统也可能会使用不同的适配器,由适配器负责协议转换。这就使得应用程序能够以一致的方式被用户、程序、自动化测试和批处理脚本使用。
三种微服务架构模型的对比和分析
虽然 DDD 分层架构、整洁架构、六边形架构的架构模型表现形式不一样,但你不要被它们的表象所迷惑,这三种架构模型的设计思想正是微服务架构高内聚低耦合原则的完美体现,而它们身上闪耀的正是以领域模型为中心的设计思想。
我们看下上面这张图,结合图示对这三种架构模型做一个分析。
请你重点关注图中的红色线框,它们是非常重要的分界线,这三种架构里面都有,它的作用就是将核心业务逻辑与外部应用、基础资源进行隔离。
红色框内部主要实现核心业务逻辑,但核心业务逻辑也是有差异的,有的业务逻辑属于领域模型的能力,有的则属于面向用户的用例和流程编排能力。按照这种功能的差异,我们在这三种架构中划分了应用层和领域层,来承担不同的业务逻辑。
领域层实现面向领域模型,实现领域模型的核心业务逻辑,属于原子模型,它需要保持领域模型和业务逻辑的稳定,对外提供稳定的细粒度的领域服务,所以它处于架构的核心位置。
应用层实现面向用户操作相关的用例和流程,对外提供粗粒度的 API 服务。它就像一个齿轮一样进行前台应用和领域层的适配,接收前台需求,随时做出响应和调整,尽量避免将前台需求传导到领域层。应用层作为配速齿轮则位于前台应用和领域层之间。
可以说,这三种架构都考虑了前端需求的变与领域模型的不变。
需求变幻无穷,但变化总是有矩可循的,用户体验、操作习惯、市场环境以及管理流程的变化,往往会导致界面逻辑和流程的多变。但总体来说,不管前端如何变化,在企业没有大的变革的情况下,核心领域逻辑基本不会大变,所以领域模型相对稳定,而用例和流程则会随着外部应用需求而随时调整。把握好这个规律,我们就知道该如何设计应用层和领域层了。
架构模型通过分层的方式来控制需求变化从外到里对系统的影响,从外向里受需求影响逐步减小。面向用户的前端可以快速响应外部需求进行调整和发布,灵活多变,应用层通过服务组合和编排来实现业务流程的快速适配上线,减少传导到领域层的需求,使领域层保持长期稳定。
这样设计的好处很明显了,就是可以保证领域层的核心业务逻辑不会因为外部需求和流程的变动而调整,对于建立前台灵活、中台稳固的架构很有帮助。
看到这里,你是不是已经猜出中台和微服务设计的关键了呢?我给出的答案是:领域模型和微服务的合理分层设计。那么你的答案呢?
从三种架构模型看中台和微服务设计
结合这三种微服务架构模型的共性,下面我来谈谈中台和微服务设计的一些心得体会。
中台本质上是领域的子域,它可能是核心域,也可能是通用域或支撑域。通常大家认为阿里的中台对应 DDD 的通用域,将通用的公共能力沉淀为中台,对外提供通用共享服务。
中台作为子域还可以继续分解为子子域,在子域分解到合适大小,通过事件风暴划分限界上下文以后,就可以定义微服务了,微服务用来实现中台的能力。表面上看,DDD、中台、微服务这三者之间似乎没什么关联,实际上它们的关系是非常紧密的,组合在一起可以作为一个理论体系用于你的中台和微服务设计。
1. 中台建设要聚焦领域模型
中台需要站在全企业的高度考虑能力的共享和复用。
中台设计时,我们需要建立中台内所有限界上下文的领域模型,DDD 建模过程中会考虑架构演进和功能的重新组合。领域模型建立的过程会对业务和应用进行清晰的逻辑和物理边界(微服务)划分。领域模型的结果会影响到后续的系统模型、架构模型和代码模型,最终影响到微服务的拆分和项目落地。
因此,在中台设计中我们首先要聚焦领域模型,将它放在核心位置。
2. 微服务要有合理的架构分层
微服务设计要有分层的设计思想,让各层各司其职,建立松耦合的层间关系。
不要把与领域无关的逻辑放在领域层实现,保证领域层的纯洁和领域逻辑的稳定,避免污染领域模型。也不要把领域模型的业务逻辑放在应用层,这样会导致应用层过于庞大,最终领域模型会失焦。如果实在无法避免,我们可以引入防腐层,进行新老系统的适配和转换,过渡期完成后,可以直接将防腐层代码抛弃。
微服务内部的分层方式我们已经清楚了,那微服务之间是否也有层次依赖关系呢?如何实现微服务之间的服务集成?
有的微服务可以与前端应用集成,一起完成特定的业务,这是项目级微服务。而有的则是某个职责单一的中台微服务,企业级的业务流程需要将多个这样的微服务组合起来才能完成,这是企业级中台微服务。
两类微服务由于复杂度不一样,集成方式也会有差异。
项目级微服务
项目级微服务的内部遵循分层架构模型就可以了。领域模型的核心逻辑在领域层实现,服务的组合和编排在应用层实现,通过 API 网关为前台应用提供服务,实现前后端分离。但项目级的微服务可能会调用其它微服务,你看在下面这张图中,比如某个项目级微服务 B 调用认证微服务 A,完成登录和权限认证。
通常项目级微服务之间的集成,发生在微服务的应用层,由应用服务调用其它微服务发布在 API 网关上的应用服务。你看下图中微服务 B 中红色框内的应用服务 B,它除了可以组合和编排自己的领域服务外,还可以组合和编排外部微服务的应用服务。它只要将编排后的服务发布到 API 网关供前端调用,这样前端就可以直接访问自己的微服务了。
企业级中台微服务
企业级的业务流程往往是多个中台微服务一起协作完成的,那跨中台的微服务如何实现集成呢?
企业级中台微服务的集成不能像项目级微服务一样,在某一个微服务内完成跨微服务的服务组合和编排。
我们可以在中台微服务之上增加一层,你看下面这张图,增加的这一层就位于红色框内,它的主要职能就是处理跨中台微服务的服务组合和编排,以及微服务之间的协调,它还可以完成前端不同渠道应用的适配。如果再将它的业务范围扩大一些,我可以将它做成一个面向不同行业和渠道的服务平台。
我们不妨借用 BFF(服务于前端的后端,Backend for Frontends)这个词,暂且称它为 BFF 微服务。BFF 微服务与其它微服务存在较大的差异,就是它没有领域模型,因此这个微服务内也不会有领域层。BFF 微服务可以承担应用层和用户接口层的主要职能,完成各个中台微服务的服务组合和编排,可以适配不同前端和渠道的要求。
3. 应用和资源的解耦与适配
传统以数据为中心的设计模式,应用会对数据库、缓存、文件系统等基础资源产生严重依赖。
正是由于它们之间的这种强依赖的关系,我们一旦更换基础资源就会对应用产生很大的影响,因此需要为应用和资源解耦。
在微服务架构中,应用层、领域层和基础层解耦是通过仓储模式,采用依赖倒置的设计方法来实现的。在应用设计中,我们会同步考虑和基础资源的代码适配,那么一旦基础设施资源出现变更(比如换数据库),就可以屏蔽资源变更对业务代码的影响,切断业务逻辑对基础资源的依赖,最终降低资源变更对应用的影响。
今天我们详细讲解了整洁架构和六边形架构,并对包括 DDD 分层架构在内的三种微服务架构模进行对比分析,总结出了它们的共同特征,并从共性出发,梳理出了中台建模和微服务架构设计的几个要点,我们后面还会有更加详细的有关设计落地的讲述。
那从今天的内容中我们不难看出:
DDD 分层架构、整洁架构、六边形架构都是以领域模型为核心,实行分层架构,内部核心业务逻辑与外部应用、资源隔离并解耦。请务必记好这个设计思想,今后会有大用处。
DDD 和微服务来源于西方,而中台诞生于中国的阿里巴巴。DDD 在二十多年前提出后一直默默前行,中台和微服务的理念近几年才出现,提出后就非常火爆。这三者看似风马牛不相及,实则缘分匪浅。中台是抽象出来的业务模型,微服务是业务模型的系统实现,DDD 作为方法论可以同时指导中台业务建模和微服务建设,三者相辅相成,完美结合。
你可能会问:凭什么 DDD 可以指导中台和微服务建设,究竟起到了什么作用呢?
DDD 有两把利器,那就是它的战略设计和战术设计方法。
中台在企业架构上更多偏向业务模型,形成中台的过程实际上也是业务领域不断细分的过程。在这个过程中我们会将同类通用的业务能力进行聚合和业务重构,再根据限界上下文和业务内聚的原则建立领域模型。而 DDD 的战略设计最擅长的就是领域建模。
那在中台完成领域建模后,我们就需要通过微服务来完成系统建设。此时,DDD 的战术设计又恰好可以与微服务的设计完美结合。可以说,中台和微服务正是 DDD 实战的最佳场景。
DDD 的本质
我们先简单回顾一下 DDD 领域、子域、核心域、通用域和支撑域等概念,后面会用到。
在研究和解决业务问题时,DDD 会按照一定的规则将业务领域进行细分,领域细分到一定的程度后,DDD 会将问题范围限定在特定的边界内,并在这个边界内建立领域模型,进而用代码实现该领域模型,解决相应的业务问题。领域可分解为子域,子域可继续分为子子域,一直到你认为适合建立领域模型为止。
子域还会根据自身重要性和功能属性划分为三类子域,它们分别是核心域、支撑域和通用域。关于这三类子域更为详细的讲解,你可以回看
接下来我们一起看下上面这张图,我选择了保险的几个重要领域,进行了高阶的领域划分。当然每个企业的领域定位和职责会有些不一样,那在核心域的划分上肯定会有一定差异。因此,当你去做领域划分的时候,请务必结合企业战略,这恰恰也体现了 DDD 领域建模的重要性。
通过领域划分和进一步的子域划分,我们就可以区分不同子域在企业内的功能属性和重要性,进而采取不同的资源投入和建设策略,这在企业 IT 系统的建设过程中十分重要,并且这样的划分还可以帮助企业进行中台设计。
中台来源于阿里的中台战略(详见《企业 IT 架构转型之道:阿里巴巴中台战略思想与架构实战》钟华编著)。2015 年年底,阿里巴巴集团对外宣布全面启动中台战略,构建符合数字时代的更具创新性、灵活性的“大中台、小前台”组织机制和业务机制,即作为前台的一线业务会更敏捷、更快速地适应瞬息万变的市场,而中台将集合整个集团的运营数据能力、产品技术能力,对各前台业务形成强力支撑。
中台的本质其实就是提炼各个业务板块的共同需求,进行业务和系统抽象,形成通用的可复用的业务模型,打造成组件化产品,供前台部门使用。
前台要做什么业务,需要什么资源,可以直接找中台,不需要每次都去改动自己的底层。
DDD、中台和微服务的协作模式
已经说过了传统企业和阿里中台战略的差异,那实际上更多的企业还是会聚焦在传统企业中台建设的模式,也就是将通用能力与核心能力全部中台化,以满足不同渠道核心业务能力的复用,那么接下来我们就还是把重点放在传统企业上。
传统企业可以将需要共享的公共能力进行领域建模,建设可共享的
。除此之外,传统企业还会将核心能力进行领域建模,建设面向不同渠道的可复用的
而这里的通用中台和核心中台都属于我们上一讲讲到的业务中台的范畴。
DDD 的子域分为核心域、通用域和支撑域。划分这几个子域的主要目的是为了确定战略资源的投入,一般来说战略投入的重点是核心域,因此后面我们就可以暂时不严格区分支撑域和通用域了。
领域、中台以及微服务虽然属于不同层面的东西,但我们还是可以将他们分解对照,整理出来它们之间的关系。你看下面这张图,我是从 DDD 领域建模和中台建设这两个不同的视角对同一个企业的业务架构进行分析。
如果将企业内整个业务域作为一个问题域的话,企业内的所有业务就是一个领域。在进行领域细分时,从 DDD 视角来看,子域可分为核心域、通用域和支撑域。从中台建设的视角来看,业务域细分后的业务中台,可分为核心中台和通用中台。
从领域功能属性和重要性对照来看,通用中台对应 DDD 的通用域和支撑域,核心中台对应 DDD 的核心域。从领域的功能范围来看,子域与中台是一致的。领域模型所在的限界上下文对应微服务。建立了这个映射关系,我们就可以用 DDD 来进行中台业务建模了。
我们这里还是以保险领域为例。
保险域的业务中台分为两类:第一类是提供保险核心业务能力的核心中台(比如营销、承保和理赔等业务);第二类是支撑核心业务流程完成保险全流程的通用中台(比如订单、支付、客户和用户等)。
这里我要提醒你一下:根据 DDD 首先要建立通用语言的原则,在将 DDD 的方法引入中台设计时,我们要先建立中台和 DDD 的通用语言。这里的子域与中台是一致的,那我们就可以将子域统一为中台。
中台通过事件风暴可以进一步细分,最终完成业务领域建模。中台业务领域的功能不同,限界上下文的数量和大小就会不一样,领域模型也会不一样。
当完成业务建模后,我们就可以采用 DDD 战术设计,设计出聚合、实体、领域事件、领域服务以及应用服务等领域对象,再利用分层架构模型完成微服务的设计。
以上就是 DDD、中台和微服务在应用过程中的协作模式。
中台如何建模?
看完了三者的协作模式,我们就顺着上面的话题,接着来聊聊中台如何建模。
中台业务抽象的过程就是业务建模的过程,对应 DDD 的战略设计。系统抽象的过程就是微服务的建设过程,对应 DDD 的战术设计。下面我们就结合 DDD 领域建模的方法,讲一下中台业务建模的过程。
按照业务流程(通常适用于核心域)或者功能属性、集合(通常适用于通用域或支撑域),将业务域细分为多个中台,再根据功能属性或重要性归类到核心中台或通用中台。核心中台设计时要考虑核心竞争力,通用中台要站在企业高度考虑共享和复用能力。
选取中台,根据用例、业务场景或用户旅程完成事件风暴,找出实体、聚合和限界上下文。依次进行领域分解,建立领域模型。
由于不同中台独立建模,某些领域对象或功能可能会重复出现在其它领域模型中,也有可能本该是同一个聚合的领域对象或功能,却分散在其它的中台里,这样会导致领域模型不完整或者业务不内聚。这里先不要着急,这一步我们只需要初步确定主领域模型就可以了,在第三步中我们还会提炼并重组这些领域对象。
以主领域模型为基础,扫描其它中台领域模型,检查并确定是否存在重复或者需要重组的领域对象、功能,提炼并重构主领域模型,完成最终的领域模型设计。
选择其它主领域模型重复第三步,直到所有主领域模型完成比对和重构。
基于领域模型完成微服务设计,完成系统落地。
结合上面这张图,你可以大致了解到 DDD 中台设计的过程。DDD 战略设计包括上述的第一步到第四步,主要为:业务域分解为中台,对中台归类,完成领域建模,建立中台业务模型。DDD 战术设计是第五步,领域模型映射为微服务,完成中台建设。
那么如果还是以保险领域为例的话,完成领域建模后,里面的数据我们就可以填上了。这里我选取了通用中台的用户、客户和订单三个中台来做示例。客户中台提炼出了两个领域模型:客户信息和客户视图模型。用户中台提炼出了三个领域模型:用户管理、登录认证和权限模型。订单中台提炼出了订单模型。
这就是中台建模的全流程,当然看似简单的背后,若是遇上复杂的业务总会出现各种各样的问题,不然应用起来也不会有那么多的困难。如果你在按照以上流程实施的过程中遇到什么问题,欢迎在留言区和我讨论。
今天我们主要讨论了传统企业中台建设的一些思路,梳理了 DDD、中台和微服务的关系。DDD 的战略设计可用于中台业务建模,战术设计可指导中台微服务设计。相信 DDD 与中台的完美结合,可以让你的中台建设如虎添翼!
另外,这一讲只是开一个头,在下一讲中我还会以一个传统核心业务的中台建设案例,详细讲解中台的设计过程。
微服务设计原则与演进策略
一、演进策略对比
| 策略 | 做法 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 绞杀者 | 逐步剥离,新功能独立为微服务 | 风险可控,渐进式 | 时间较长 | 核心单体系统改造 |
| 修缮者 | 局部优化,重建问题功能 | 成本低,影响小 | 整体架构不变 | 部分模块有性能/质量瓶颈 |
| 另起炉灶 | 推倒重来,新系统替换 | 彻底解决 | 风险高,周期长 | 不建议核心系统使用 |
二、不同场景的领域建模策略
场景 → 复杂度 → 建模方法
├── 新建系统
│ ├── 简单领域 → 事件风暴直接建模
│ └── 复杂领域 → 三步法
│ ├── Step 1: 拆分子域 → 逐个子域事件风暴
│ ├── Step 2: 微调模型 → 汇总各子域,调整聚合边界、事件依赖
│ └── Step 3: 拆分微服务 → 基于最终模型拆分
│
└── 遗留系统
├── 部分独立 → 将该功能视为简单子域,事件风暴建模
│ └── 需加防腐层隔离新老系统
└── 整体改造 → 参考绞杀者/修缮者策略三、DDD 使用误区决策清单
| 误区 | 何时用 DDD | 何时不用 DDD |
|---|---|---|
| 所有领域都用 DDD | 核心域(富领域模型) | 通用域/支撑域(CRUD 即可) |
| 全部用战术设计 | 新建/修改数据的聚合操作 | 大数据查询、统计分析 |
| 重战术轻战略 | 先做战略设计(BC/聚合) | 没有战略直接写代码 |
| DDD 只适用微服务 | 微服务 | 单体也需要 DDD 分层设计 |
核心原则:
- 聚焦核心域,不要在 CRUD 场景上花 DDD 建模成本
- 聚合根管理写操作一致性,SQL 直接查询处理读取
- 战略设计确定了微服务边界和质量,优先级高于战术设计
四、微服务设计原则检查表
| [#] | 原则 | 反向指标(违反时触发) |
|---|---|---|
| 1 | 领域驱动,非数据驱动 | 先设计数据库表再拆分微服务 |
| 2 | 边界清晰,非泥球单体 | 聚合间直接互相调用 |
| 3 | 分层清晰,非什么都放 | 领域层有 Controller/SQL/JPA |
| 4 | 能 hold 住的粒度,非过度拆分 | 微服务 > 20 个但团队 < 10 人 |
五、微服务拆分决策矩阵
| 拆分因素 | 权重 | 判断标准 | 拆分建议 |
|---|---|---|---|
| 领域模型边界 | ★★★ | 一个 BC = 一个微服务(理论基准) | 必须满足 |
| 需求变更频率 | ★★★ | 某模块 2 周改一次 vs 其他 2 月改一次 | 拆分,减少发布影响 |
| 性能要求 | ★★★ | 某功能 TPS > 1w,其余 < 100 | 拆分,独立扩缩容 |
| 安全边界 | ★★☆ | 某功能需特殊加密/合规 | 拆分,缩小安全面 |
| 技术异构 | ★★☆ | Java 模块 + .NET 模块 + 大数据 | 拆分,技术边界对齐 |
| 团队组织 | ★★☆ | 一个 BC 团队 > 12 人 | 考虑拆分(遵循康威定律) |
拆分检查清单
对于每个候选微服务:
[ ] 领域边界清晰(独立 BC)?
[ ] 有独立的数据源(不共享表)?
[ ] 团队可以在 10-12 人内维护?
[ ] 变更频率与其他微服务差距大?
[ ] 可以独立部署和扩容?
5 项全部满足 → 独立微服务
3-4 项满足 → 可以独立,但优先考虑代码模块拆分
< 3 项 → 合并到父微服务分布式架构关键设计决策指南
一、分布式数据库选型
| 方案 | 代表产品 | 适用场景 | 一致性 | 实施成本 |
|---|---|---|---|---|
| 一体化 | OceanBase, 高斯DB | 互联网大厂,超大数据量 | Paxos 强一致 | 高 |
| DB+中间件 | MyCat+MySQL, TBase(PostgreSQL) | 中大型企业 | 主从同步 | 中 |
| 分库类库 | ShardingSphere | 简单读写,低并发 | 弱 | 低 |
选型决策:能力+成本+业务需求,大厂用 OceanBase,中小企业用 MyCat+MySQL/ShardingSphere。
二、分库主键设计
关键业务(C端用户) → 客户 ID 为主键 → 同客户数据在同一数据单元
管理后台 → 机构/组织 ID 为主键 → 同机构数据在同一数据单元三、数据同步方式
| 方式 | 技术 | 时效性 | 用途 |
|---|---|---|---|
| CDC (推荐) | Debezium 监听 binlog/WAL | 准实时 | 跨库数据同步、事件驱动 |
| ETL | Kettle/DataX | 分钟-小时 | 数据仓库、离线报表 |
| 领域事件 | Outbox + MQ | 秒级 | 跨微服务业务数据同步 |
四、跨库关联查询解决方案
| 场景 | 方案 | 实现 |
|---|---|---|
| 主题域查询(跨 BC) | 主题数据库 | CDC 汇集各微服务数据 → 宽表 → 查询微服务 |
| 表关联查询(跨库) | 小表广播 | 主表变更 → 事件 → 异步更新各微服务的冗余副表 |
| 高频热点数据 | 缓存 | Redis 缓存代码表、商品数据 |
| 模糊搜索 | 搜索引擎 | ElasticSearch 提供搜索服务 |
五、前后序业务数据传递
场景:订单(前序) → 运单(后序),运单需引用订单数据
处理方式:
1. 订单状态变更 → 领域事件(携带前序数据)
2. 运单微服务订阅事件 → 存储前序数据副本
存储设计:
前序数据只读不写 → 值对象
前序数据需查询/统计 → 实体六、数据中台建设三阶段
Phase 1: 数据汇集 → 统一标准,CDC 汇集各微服务数据
Phase 2: 主题建模 → 客户统一视图、渠道视图等主题域宽表
Phase 3: 业务驱动 → 数据产品化,支持业务创新七、BFF vs 应用服务
| 对比维度 | BFF | 应用服务 |
|---|---|---|
| 编排范围 | 跨微服务之间的协调 | 微服务内聚合的编排 |
| 所在层 | 微服务之上,面向前端 | 微服务内部 |
| 主要职责 | 适配不同前端 + 跨服务编排 | 单微服务内用例实现 |
| 版本发布 | 与前端版本协同 | 与微服务版本协同 |
| 是否有领域模型 | 无 | 无(领域模型在 Domain 层) |
八、一致性策略选择
| 场景 | 方案 | 代价 |
|---|---|---|
| 实时强一致性 | 分布式事务(Seata/XA) | 性能损失大 |
| 非实时最终一致性 | 领域事件 + MQ | 需处理幂等、补偿 |
| 读写分离 | CQRS + 事件驱动 Read Model | 数据延迟 |
推荐:默认用领域事件(最终一致性),仅资金类场景用分布式事务。
九、多活设计关键点
1. 数据库:选支持多数据中心的分布式数据库 2. 单元化:业务单元自包含,故障隔离,跨单元调用最小化 3. 路由:接入层 → 应用层 → 数据层,三层路由确保请求正确到达 4. 配置管理:全局配置多中心实时同步
Quick Reference Cheatsheet
See SKILL.md for full source list.
This cheatsheet summarizes an opinionated synthesis, not a single canonical architecture. Use DDD, Hexagonal, Clean Architecture, Onion Architecture, CQRS, and Event Sourcing independently when only one pattern fits the problem.
Layer Summary
flowchart TB
subgraph Infra["INFRASTRUCTURE (Adapters)"]
I1["REST/gRPC controllers"]
I2["CLI handlers"]
I3["Framework code"]
I4["Database repositories"]
I5["Message publishers"]
I6["External service clients"]
end
subgraph App["APPLICATION (Use Cases)"]
A1["Command/Query handlers"]
A2["DTOs"]
A3["Transaction management"]
A4["Port interfaces"]
A5["Application services"]
A6["Event dispatching"]
end
subgraph Domain["DOMAIN (Business Logic)"]
D1["Entities"]
D2["Aggregates"]
D3["Repository interfaces"]
D4["Business rules"]
D5["Value Objects"]
D6["Domain Events"]
D7["Domain Services"]
D8["Specifications"]
end
Infra -->|depends on| App
App -->|depends on| Domain
style Infra fill:#6366f1,stroke:#4f46e5,color:white
style App fill:#3b82f6,stroke:#2563eb,color:white
style Domain fill:#10b981,stroke:#059669,color:whiteDependencies point inward
---
Pattern Boundaries
| Pattern | Use For | Avoid Assuming |
|---|---|---|
| DDD | Ubiquitous language, bounded contexts, aggregates | It requires a specific folder layout |
| Hexagonal | Ports/adapters around an application core | Every port must be a separate interface |
| Clean Architecture | Inward dependency rule and use-case boundaries | Every project needs four layers |
| Onion Architecture | Domain-centered dependency inversion | It is mandatory in addition to Clean/Hexagonal |
| CQRS | Divergent read/write models in a bounded context | It should be system-wide by default |
| Event Sourcing | Audit trails, temporal queries, replayable workflows | It is a normal CRUD persistence choice |
---
Quick Decision Trees
"Where does this code go?"
Is it a business rule or constraint?
├── YES → Domain layer
└── NO ↓
Is it orchestrating a use case?
├── YES → Application layer
└── NO ↓
Is it dealing with external systems (DB, API, UI)?
├── YES → Infrastructure layer
└── NO → Reconsider; probably domain"Entity or Value Object?"
Does it have a unique identity that persists?
├── YES → Entity
└── NO ↓
Is it defined entirely by its attributes?
├── YES → Value Object
└── NO → Probably an Entity"Aggregate boundary?"
Must these objects change together atomically?
├── YES → Same aggregate
└── NO ↓
Can one exist without the other?
├── YES → Different aggregates (reference by ID)
└── NO → Probably same aggregate"Domain Service or Entity method?"
Does it naturally belong to one entity?
├── YES → Entity method
└── NO ↓
Does it require multiple aggregates?
├── YES → Domain Service
└── NO ↓
Is it stateless business logic?
├── YES → Domain Service
└── NO → Reconsider placement---
Common Patterns Quick Reference
Value Object Template
export class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: string,
) {}
static create(amount: number, currency: string): Money {
if (amount < 0) throw new Error('Negative');
return new Money(amount, currency);
}
add(other: Money): Money {
return Money.create(this._amount + other._amount, this._currency);
}
get amount(): number { return this._amount; }
get currency(): string { return this._currency; }
equals(other: Money): boolean {
return this._amount === other._amount && this._currency === other._currency;
}
}Entity Template
export class OrderItem extends Entity<OrderItemId> {
private _quantity: Quantity;
private constructor(id: OrderItemId, private readonly _productId: ProductId, quantity: Quantity) {
super(id);
this._quantity = quantity;
}
static create(productId: ProductId, quantity: Quantity): OrderItem {
return new OrderItem(OrderItemId.generate(), productId, quantity);
}
increaseQuantity(amount: number): void {
this._quantity = this._quantity.add(amount);
}
get productId(): ProductId { return this._productId; }
get quantity(): Quantity { return this._quantity; }
}Aggregate Root Template
export class Order extends AggregateRoot<OrderId> {
private _items: OrderItem[] = [];
private _status: OrderStatus;
private constructor(id: OrderId, customerId: CustomerId) {
super(id);
this._customerId = customerId;
this._status = OrderStatus.Draft;
}
static create(customerId: CustomerId): Order {
const order = new Order(OrderId.generate(), customerId);
order.addDomainEvent(new OrderCreated(order.id, customerId));
return order;
}
addItem(productId: ProductId, quantity: Quantity, price: Money): void {
this.assertCanModify();
this._items.push(OrderItem.create(productId, quantity, price));
}
confirm(): void {
this.assertCanModify();
if (this._items.length === 0) throw new EmptyOrderError();
this._status = OrderStatus.Confirmed;
this.addDomainEvent(new OrderConfirmed(this.id, this.total));
}
private assertCanModify(): void {
if (this._status === OrderStatus.Cancelled) {
throw new InvalidOrderStateError('Order is cancelled');
}
}
get total(): Money { /* ... */ }
}Repository Interface Template
export interface IOrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(order: Order): Promise<void>;
}Use Case Handler Template
export class PlaceOrderHandler {
constructor(
private readonly orderRepo: IOrderRepository,
private readonly productRepo: IProductRepository,
private readonly eventPublisher: IEventPublisher,
) {}
async execute(command: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(CustomerId.from(command.customerId));
for (const item of command.items) {
const product = await this.productRepo.findById(item.productId);
order.addItem(product.id, Quantity.create(item.quantity), product.price);
}
await this.orderRepo.save(order);
await this.eventPublisher.publishAll(order.domainEvents);
return order.id;
}
}---
Port Naming Conventions
Repository port placement varies by school: DDD-centered code often keeps aggregate repositories in domain/{aggregate}/repository; stricter Hexagonal layouts often group them under application/ports/driven/. Pick one convention per codebase.
| Type | Pattern | Examples |
|---|---|---|
| Driver Port | I{Action}UseCase | IPlaceOrderUseCase, IGetOrderUseCase |
| Driven Port | I{Resource}Repository | IOrderRepository, IProductRepository |
| Driven Port | I{Action}Service | IPaymentService, INotificationService |
| Driven Port | I{Resource}Gateway | IPaymentGateway, IShippingGateway |
---
Common Anti-Patterns
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Anemic Domain | Entities are just data bags | Put behavior in entities |
| Repository per table | One repo per DB table | One repo per aggregate |
| Fat Use Cases | Business logic in handlers | Move to domain |
| Leaky Abstraction | Domain depends on ORM | Keep domain pure |
| God Aggregate | One massive aggregate | Split into smaller ones |
| Cross-Aggregate TX | Modifying multiple in one TX | Use domain events |
| Direct Layer Skip | Controller -> Repository in this architecture style | Route through application use case |
| Premature CQRS | Adding complexity early | Start simple, evolve |
| Event Proliferation | Too many fine-grained events | May signal context boundary |
---
Dependency Rules Matrix
| Domain | Application | Infrastructure | |
|---|---|---|---|
| Domain | ✅ | ❌ | ❌ |
| Application | ✅ | ✅ | ❌ |
| Infrastructure | ✅ | ✅ | ✅ |
✅ = Can depend on ❌ = Cannot depend on
---
Hexagonal Quick Reference
flowchart LR
subgraph Driver["DRIVER (Left/Primary/Inbound)"]
direction TB
D1["REST Controller"]
D2["gRPC Service"]
D3["CLI Command"]
D4["Message Consumer"]
DP["Port (Interface)"]
D1 & D2 & D3 & D4 -->|calls| DP
end
subgraph App["Application"]
Core[" "]
end
subgraph Driven["DRIVEN (Right/Secondary/Outbound)"]
direction TB
DRP["Port (Interface)"]
DR1["Database Repository"]
DR2["Message Publisher"]
DR3["External API Client"]
DR4["Cache Adapter"]
DR1 & DR2 & DR3 & DR4 -->|implements| DRP
end
Driver -->|"How world\nuses app"| App
App -->|"How app\nuses world"| Driven
style Driver fill:#3b82f6,stroke:#2563eb,color:white
style App fill:#10b981,stroke:#059669,color:white
style Driven fill:#f59e0b,stroke:#d97706,color:white---
When to Use / Skip
Use Clean + DDD + Hexagonal When:
- ✅ Complex business domain with many rules
- ✅ Long-lived system (years of maintenance)
- ✅ Large team (5+ developers)
- ✅ Need to swap infrastructure (DB, broker, etc.)
- ✅ High test coverage required
- ✅ Multiple entry points (API, CLI, events, scheduled jobs)
Skip When:
- ❌ Simple CRUD application (most applications)
- ❌ Prototype / MVP / throwaway code
- ❌ Small team (1-2 devs)
- ❌ Short-lived project
- ❌ Trivial business logic
Complexity Ladder (Start Simple)
Level 1: Simple layered (Controller → Service → Repository)
↓ When business rules grow complex
Level 2: Domain model (Entities with behavior)
↓ When need multiple entry points
Level 3: Hexagonal (Ports & Adapters)
↓ When read/write patterns diverge significantly
Level 4: CQRS (Separate read/write models)
↓ When need complete audit trail / temporal queries
Level 5: Event Sourcing (Store events, derive state)Don't skip levels. Each level adds complexity. Move up only when you've proven the current level insufficient.
---
File Naming Conventions
domain/
├── order/
│ ├── order.ts # Aggregate root
│ ├── order_item.ts # Entity
│ ├── value_objects.ts # OrderId, Money, etc.
│ ├── events.ts # OrderCreated, etc.
│ ├── repository.ts # IOrderRepository
│ ├── services.ts # Domain services
│ └── errors.ts # OrderError, etc.
application/
├── place_order/
│ ├── command.ts # PlaceOrderCommand
│ ├── handler.ts # PlaceOrderHandler
│ └── port.ts # IPlaceOrderUseCase
infrastructure/
├── postgres/
│ ├── order_repository.ts # PostgresOrderRepository
│ └── mappers/
│ └── order_mapper.ts # Domain <-> DB mapping---
Resources
Books & Primary Articles
- Clean Architecture (Robert C. Martin, 2017)
- Domain-Driven Design (Eric Evans, 2003)
- Implementing Domain-Driven Design (Vaughn Vernon, 2013)
- Onion Architecture (Jeffrey Palermo, 2008 article series)
- Hexagonal Architecture Explained (Alistair Cockburn, 2024)
- Get Your Hands Dirty on Clean Architecture (Tom Hombergs, 2019)
Supplemental Syntheses
- Herberto Graça, Clean Architecture comparison and Explicit Architecture articles (opinionated synthesis, not canonical source)
- Tom Hombergs, practical Clean Architecture examples
Reference Implementations
- Go: bxcodec/go-clean-arch
- Rust: flosse/clean-architecture-with-rust
- Python: cdddg/py-clean-arch
- TypeScript: jbuget/nodejs-clean-architecture-app
- .NET: jasontaylordev/CleanArchitecture
- Java: thombergs/buckpal
Official Documentation
- https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- https://alistair.cockburn.us/hexagonal-architecture/
- https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/
- https://www.domainlanguage.com/ddd/
- https://martinfowler.com/tags/domain%20driven%20design.html
DDD Architecture Comparison Reference
Quick Decision Matrix
| Dimension | Layered | Onion | Hexagonal | Clean | COLA |
|---|---|---|---|---|---|
| Learning Cost | ★☆☆ | ★★★ | ★★★ | ★★★ | ★★☆ |
| Business Complexity Fit | ★☆☆ | ★★★ | ★★★ | ★★★ | ★★★ |
| CRUD Efficiency | ★★★ | ★☆☆ | ★☆☆ | ★☆☆ | ★★☆ |
| Infrastructure Replaceability | ★☆☆ | ★★★ | ★★★ | ★★★ | ★★☆ |
| Test Friendliness | ★☆☆ | ★★★ | ★★★ | ★★★ | ★★★ |
| Chinese Community | ★★★ | ★☆☆ | ★☆☆ | ★☆☆ | ★★★ |
| Code Generation Support | Good | Poor | Poor | Poor | Excellent |
| Team Size Fit | 1-5 | 5-15 | 5-15 | 15-50 | 5-50 |
| Origin | Martin Fowler | Jeffrey Palermo 2008 | Alistair Cockburn 2005 | Robert C. Martin 2012 | Alibaba 2018 |
Architecture Selection Flowchart
What is the team size?
├── 1-5 people
│ └── Business complexity?
│ ├── Simple CRUD → LAYERED
│ ├── Moderate → COLA Simplified
│ └── Complex → COLA (single module)
│
├── 5-15 people
│ └── Technology stack?
│ ├── Spring Boot + MyBatis/Chinese ecosystem → COLA
│ ├── Domain purity is top priority → HEXAGONAL
│ └── Infrastructure needs frequent swapping → HEXAGONAL / ONION
│
└── 15-50 people
└── Organization type?
├── Chinese enterprise → COLA (multi-module)
├── International/English-first → CLEAN
└── Mixed → HEXAGONAL per service + COLA per moduleArchitecture Selection by Scenario
| Scenario | Recommended | Why |
|---|---|---|
| REST API + Simple CRUD | Layered | Minimal overhead |
| Core domain logic heavy | Hexagonal / Onion | Best domain isolation |
| Chinese enterprise backend | COLA | Ecosystem + Chinese docs |
| Multi-entry (REST+CLI+MQ+gRPC) | Hexagonal | Cleanest adapter isolation |
| Strict module boundaries needed | Clean | Physical module isolation |
| Frequent DB/MQ changes | Hexagonal / Onion | Easy adapter swap |
| Rapid prototyping → Production | Layered → COLA | Progressive evolution |
| Microservice internal standard | Hexagonal + COLA | Best of both worlds |
FAQ
Q1: 团队5人和50人的架构选择逻辑有什么本质区别? A: 5人团队核心痛点是"快速交付",推荐 Layered 或 COLA 简化版。50人团队核心痛点是"协调成本",推荐 Clean 或多模块 COLA 实现物理隔离。
Q2: CQRS 一定要和 Hexagonal/Clean 搭配吗? A: 理论上可以独立使用,但实践中 CQRS L2+(DB 分离)涉及同步机制(领域事件 → MQ → Query DB),只有 Hexagonal 或 Clean 的端口隔离能干净处理这种复杂度。Layered 搭配 CQRS 容易导致架构混乱。
Q3: 同一个项目里多个模块可以用不同架构吗? A: 可以。微服务架构下每个服务独立选型完全合理。COLA 官方也支持"Domain + App + Adapter + Infra"四层的模块级差异化。关键是定义好模块间的通信契约。
Q4: 有没有推荐的从 Layered 到 Hexagonal 的渐进迁移路径? A: 推荐三步:Phase 1 在现有 Layered 中识别聚合边界,Phase 2 引入 Repository 接口(依赖倒置),Phase 3 逐步将 Service 层重构为 UseCase + Port。整个迁移使用 Strangler Fig 模式,不需要重写。
Q5: COLA 和 Clean Architecture 的核心区别是什么? A: COLA 更工程化(有脚手架、代码生成、ArchUnit 自动化检查),Clean 更学术化(强调 Entity → UseCase 的层次纯度)。COLA 适合国内 Spring Boot 团队,Clean 适合国际化大型团队。
Q6: 微服务拆分粒度太细或太粗怎么办? A: 太细 → 合并事务边界(2个服务需要强一致性 → 合并)。太粗 → 按部署节奏和团队归属拆分。基本原则:一个 BC 一个服务,BC 大小由领域事件流自然界定。
Q7: 我该先选架构还是先做领域建模? A: 先选架构再建模。架构类别(Layered/Hexagonal)决定了代码组织方式,领域建模结果(聚合/限界上下文)填入架构框架中。推荐路径:selector → domain-designer → 对应架构 Skill。
Q8: Onion 和 Hexagonal 到底有什么区别?实际选哪个? A: 两者本质上是同一思想的不同表述。Onion 更强调"层次嵌套"(可视化直观),Hexagonal 更强调"端口/适配器"(接口契约清晰)。建议:如果是教学/团队展示 → Onion;如果是代码落地 → Hexagonal。
Q9: 我的团队全是新手,能用 Hexagonal 吗? A: 不建议。Hexagonal 需要团队理解 Port/Adapter、UseCase 接口、依赖倒置等概念。新手团队从 Layered 或 COLA 简化版开始,待团队 DDD 成熟后再升级。至少需要有 1-2 名有 DDD 经验的成员才能成功落地 Hexagonal。
Q10: 选型时团队规模和业务复杂度冲突怎么办?(比如小团队做复杂业务) A: 优先处理业务复杂度。小团队做复杂业务时推荐 COLA(有脚手架减少样板代码)或 Hexagonal(领域层纯净度保证复杂逻辑不被基础设施污染)。不推荐 Layered(容易退化为"大泥球")和 Clean(模块太多)。
Q11: 非 Java 技术栈怎么选? A: COLA 是 Java 专有,其他 4 种架构语言无关。推荐:Python/Django → Layered 或 Hexagonal;Go → Hexagonal(接口友好);.NET → Onion(.NET 社区 Onion 生态好);Node.js → Hexagonal 或 Clean。
Q12: 我在做微服务架构,每个服务内部的架构需要统一吗? A: 不需要统一,但建议标准化 2-3 种模式。典型策略:Core 域服务用 Hexagonal 或 Clean,Generic 域服务用 Layered,制定"架构选择手册"确保团队遵循。
Q13: 有没有"绝对不要选"的架构? A: 对于新项目,没有绝对错误的架构,只有不匹配的架构。但在以下情况要特别谨慎:团队 < 3 人选 Clean(过度设计);非 Java 人选 COLA(框架依赖);简单 CRUD 选 Hexagonal(性价比低);团队无 DDD 经验选 Onion(抽象门槛高)。
Q14: ArchUnit 必须用吗?有没有轻量替代? A: 大型项目 (> 10人) → 必须用 ArchUnit 或类似工具(.NET 用 NetArchTest,Go 用 go-arch-lint)。小型项目 → 可以用目录命名约定 + PR Review Checklist 替代。但建议从 Day 1 开启,后期补充的修复成本更高。
Q15: 选型完成后,多久应该重新评估一次? A: 建议两种触发条件:(1) 时间触发 — 每 6 个月做一次架构适配度评估;(2) 事件触发 — 团队规模翻倍、业务复杂度跃升、基础设施大换血时立即重新评估。使用 ddd-architecture-evaluator 进行评估。
Gotchas — Common Pitfalls
| # | Trap | Risk | Why It Happens | How to Avoid |
|---|---|---|---|---|
| 1 | Trend-chasing: COLA/Hecagonal just because "everyone uses it" | High | Community hype overrides objective evaluation | Always run the decision matrix first; don't pick architecture by popularity |
| 2 | CQRS forgotten: Architecture selected without CQRS assessment | High | CQRS treated as an afterthought, not a design dimension | Include CQRS assessment as mandatory step in every selection |
| 3 | Domain classification skipped: Same architecture applied to all domains | High | Team doesn't distinguish Core/Generic/Supporting | Always classify domains before picking architectures |
| 4 | Team capability mismatch: Picking architecture the team can't implement | High | Evaluator assumes "ideal team" exists | Be honest about team DDD maturity; pick what they can actually deliver |
| 5 | Single selection forever: Assuming architecture is fixed once chosen | Medium | Architecture seen as a one-time decision | Plan evolution: Layered → Hexagonal → CQRS as complexity grows |
| 6 | Dependency rule violations: Domain importing framework libraries | High | Team not trained on dependency direction | Enforce via ArchUnit in CI; make dependency checks non-optional |
| 7 | Over-engineering for small teams: Hexagonal with 2-person team | Medium | "Best practice" applied without context | Match architecture to team size; small team = simpler architecture |
| 8 | Ignoring tech ecosystem: COLA recommended for non-Java stack | High | Focus only on architecture, not language/tooling | COLA is Java-specific; Onion works better for .NET, Hexagonal for Go |
| 9 | One architecture for all microservices: Monolithic thinking in distributed system | Medium | Each service gets same architecture template | Per-service selection based on domain type: Core→Complex, Generic→Simple |
| 10 | CQRS L2+ with Layered architecture | High | Layered lacks port isolation for clean read/write separation | Only adopt L2+ with Hexagonal or Clean architectures |
| 11 | Architecture without ArchUnit: No automated dependency enforcement | Medium | Manual review misses violations as codebase grows | Add ArchUnit checks from day one, not after violations pile up |
| 12 | Ignoring infrastructure change frequency: Picking Layered for high-change infra | Medium | Infra changes considered "rare" when they're not | If you plan to swap DB/MQ within 12 months, pick Hexagonal or Onion |
| 13 | Starting with microservices: Splitting before proving the domain model | High | Team thinks microservices solve everything | Start monolith (Layered/COLA), split only when deployment/scaling demands it |
| 14 | Missing Anti-Corruption Layer: External systems leak into domain | Medium | No ACL between bounded contexts | Always add ACL for external systems; use Hexagonal's Adapter pattern |
| 15 | Copying big-tech architecture: Adopting Netflix/Stripe patterns for startup | High | "What works for Netflix works for us" fallacy | Match architecture to current scale, not aspirational scale |
三种架构对比深度解析
执行摘要
| 架构 | 核心思想 | 适用场景 |
|---|---|---|
| 六边形 | 端口与适配器,核心不依赖外部 | 多入口系统、高可测性 |
| 整洁 | 依赖只指向内圈(实体→用例→适配器→框架) | 大型企业、严格隔离 |
| COLA | 菱形四层 + Command/DTO/Assembler/SPI | 国内工程化落地 |
选型 CheckList
| 场景 | 推荐 |
|---|---|
| 纯 CRUD/报表/管理后台 | 传统分层或轻量 DDD |
| 核心域/复杂规则/多集成 | 六边形或整洁优先 |
| 阿里系/工程化落地 | COLA(菱形) |
| 强变化的外设(DB/UI/队列/三方) | 六边形最"抗变化" |
六边形架构关键点
结构:入站适配器(HTTP/CLI/GRPC) → 入站端口 → 应用服务 → 领域模型 → 出站端口 → 出站适配器(DB/MQ/三方)
优点:高可测试、抗框架变化、天然契合 DDD
代价:端口过多导致过度抽象、DTO/Assembler 映射成本
整洁架构关键点
圈层对照 DDD:
- Entities ≈ DDD 聚合/实体/值对象
- Use Cases ≈ 应用服务
- Interface Adapters ≈ Controller/Presenter/Gateway
- Frameworks ≈ Web 框架/DB/消息
常见陷阱:用例贫血化(只当"转发器")、圈层滥用
COLA 关键点
工程约定:adapter.web / app / domain / infra 四层
Command/Query:明确用例输入,可观测、可审计
Assembler:DTO ⇆ DO/Entity 映射集中管理
SPI 扩展点:方便替换/灰度
迁移路径(从 MVC/贫血到 DDD/充血)
1. 识别限界上下文与聚合 2. 提取值对象(Money → Email → PhoneNumber) 3. 把业务规则从 Service 搬到 Entity(充血) 4. 引入 Repository 接口(Domain 定义,Infra 实现) 5. 用领域事件替代直接 Service 调用(跨聚合) 6. 把 Controller 变成薄适配器
DTO/VO/DO 的边界
| 对象 | 全称 | 位置 | 职责 |
|---|---|---|---|
| DO | Domain Object | Domain 层 | 充血模型,含业务行为 |
| DTO | Data Transfer Object | Application/Adapter | 跨层/跨服务传输 |
| VO | View Object | Adapter 层 | 面向页面展示 |
| PO | Persistent Object | Infrastructure | 数据库映射 |
源代码
CQRS & Domain Events
Sources:
- CQRS — Martin Fowler
- Event Sourcing — Martin Fowler
- CQRS Pattern — Microsoft Azure
- Transactional Outbox — microservices.io
- Domain Events – Salvation — Udi Dahan
- Strengthening Your Domain: Domain Events — Jimmy Bogard
- Domain Events: Design and Implementation — Microsoft
CQRS Overview
Command Query Responsibility Segregation separates read and write operations into different models.
flowchart TB
API["API Layer"]
API --> Commands
API --> Queries
subgraph WriteSide["Write Side"]
Commands["Commands"]
CmdHandler["Command Handler\n(Use Case)"]
DomainModel["Domain Model\n(Aggregates)"]
WriteDB[("Write Database")]
Commands --> CmdHandler
CmdHandler --> DomainModel
DomainModel --> WriteDB
end
subgraph ReadSide["Read Side"]
Queries["Queries"]
QryHandler["Query Handler\n(Read Model)"]
ReadDB[("Read Database\n(Optimized)")]
Queries --> QryHandler
QryHandler --> ReadDB
end
WriteDB -->|Domain Events| EventHandler["Event Handler"]
EventHandler -->|Updates| ReadDB
style WriteSide fill:#3b82f6,stroke:#2563eb,color:white
style ReadSide fill:#10b981,stroke:#059669,color:white
style EventHandler fill:#f59e0b,stroke:#d97706,color:white---
Commands vs Queries
Commands (Write Side)
Commands represent intent to change state. They mutate data.
// application/commands/place_order_command.ts
export interface PlaceOrderCommand {
type: 'PlaceOrder';
customerId: string;
items: Array<{
productId: string;
quantity: number;
}>;
}
export interface ConfirmOrderCommand {
type: 'ConfirmOrder';
orderId: string;
}
export interface CancelOrderCommand {
type: 'CancelOrder';
orderId: string;
reason: string;
}
export class PlaceOrderHandler {
async handle(command: PlaceOrderCommand): Promise<OrderId> {
const order = Order.create(CustomerId.from(command.customerId));
for (const item of command.items) {
const product = await this.productRepo.findById(item.productId);
order.addItem(product.id, item.quantity, product.price);
}
await this.orderRepo.save(order);
await this.eventPublisher.publishAll(order.domainEvents);
return order.id;
}
}Queries (Read Side)
Queries retrieve data without side effects. They never mutate state.
// application/queries/get_order_query.ts
export interface GetOrderQuery {
orderId: string;
}
export interface GetOrdersByCustomerQuery {
customerId: string;
status?: OrderStatus;
page?: number;
pageSize?: number;
}
export interface OrderDTO {
id: string;
customerId: string;
customerName: string;
status: string;
items: Array<{
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}>;
total: number;
createdAt: string;
confirmedAt?: string;
}
export class GetOrderHandler {
constructor(private readonly readDb: IOrderReadModel) {}
async handle(query: GetOrderQuery): Promise<OrderDTO | null> {
return this.readDb.findById(query.orderId);
}
}
export class GetOrdersByCustomerHandler {
constructor(private readonly readDb: IOrderReadModel) {}
async handle(query: GetOrdersByCustomerQuery): Promise<PaginatedResult<OrderDTO>> {
return this.readDb.findByCustomer(
query.customerId,
query.status,
query.page ?? 1,
query.pageSize ?? 20
);
}
}---
Read Model (Projection)
Optimized database structure for queries. Can denormalize data for performance.
interface IOrderReadModel:
findById(orderId: string) -> OrderDTO | null
findByCustomer(customerId, status?, page?, pageSize?) -> PaginatedResult<OrderDTO>
search(criteria: OrderSearchCriteria) -> List<OrderDTO>
class PostgresOrderReadModel implements IOrderReadModel:
db: Database
findById(orderId: string) -> OrderDTO | null:
row = db.ordersRead
.where(id: orderId)
.join("customer")
.withRelated("items.product")
.first()
return row ? this.mapToDTO(row) : nullSeparate write and read databases (optional): write is normalized for transactions, read is denormalized for queries.
---
Domain Events
Notifications that something happened in the domain. Used for:
- Updating read models
- Cross-aggregate communication
- Integration with other bounded contexts
Event Structure
// domain/shared/domain_event.ts
export abstract class DomainEvent {
readonly eventId: string;
readonly occurredAt: Date;
readonly aggregateId: string;
abstract readonly eventType: string;
constructor(aggregateId: string) {
this.eventId = crypto.randomUUID();
this.occurredAt = new Date();
this.aggregateId = aggregateId;
}
abstract toPayload(): Record<string, unknown>;
}
// domain/order/events.ts
export class OrderCreated extends DomainEvent {
readonly eventType = 'order.created';
constructor(
readonly orderId: OrderId,
readonly customerId: CustomerId,
) {
super(orderId.value);
}
toPayload() {
return {
orderId: this.orderId.value,
customerId: this.customerId.value,
};
}
}
export class OrderConfirmed extends DomainEvent {
readonly eventType = 'order.confirmed';
constructor(
readonly orderId: OrderId,
readonly total: Money,
readonly items: ReadonlyArray<{ productId: string; quantity: number }>,
) {
super(orderId.value);
}
toPayload() {
return {
orderId: this.orderId.value,
total: { amount: this.total.amount, currency: this.total.currency },
items: this.items,
};
}
}
export class OrderShipped extends DomainEvent {
readonly eventType = 'order.shipped';
constructor(
readonly orderId: OrderId,
readonly trackingNumber: string,
readonly carrier: string,
) {
super(orderId.value);
}
toPayload() {
return {
orderId: this.orderId.value,
trackingNumber: this.trackingNumber,
carrier: this.carrier,
};
}
}Event Handlers
class OrderCreatedHandler:
db: Database
handle(event: OrderCreated):
db.ordersRead.insert({
id: event.orderId.value,
customerId: event.customerId.value,
status: "draft",
createdAt: event.occurredAt
})
class OrderConfirmedHandler:
db: Database
handle(event: OrderConfirmed):
db.ordersRead
.where(id: event.orderId.value)
.update({
status: "confirmed",
total: event.total.amount,
confirmedAt: event.occurredAt
})
export class SendShippingNotificationHandler {
constructor(
private readonly orderRepo: IOrderRepository,
private readonly notifier: INotificationService,
) {}
async handle(event: OrderShipped): Promise<void> {
const order = await this.orderRepo.findById(OrderId.from(event.orderId.value));
if (!order) return;
await this.notifier.sendEmail(order.customerEmail, {
template: 'order-shipped',
data: {
orderId: event.orderId.value,
trackingNumber: event.trackingNumber,
carrier: event.carrier,
},
});
}
}---
Domain Events vs Integration Events
Domain Events
- Stay within bounded context
- Fine-grained, low-level
- Trigger internal processes
- Named in domain language
class OrderItemQuantityIncreased extends DomainEvent {
constructor(
readonly orderId: OrderId,
readonly productId: ProductId,
readonly oldQuantity: number,
readonly newQuantity: number,
) { super(orderId.value); }
}Integration Events
- Cross bounded context boundaries
- Coarser-grained
- Published to message broker
- Versioned schema
interface OrderConfirmedIntegrationEvent {
eventType: 'sales.order.confirmed';
eventId: string;
version: '1.0';
occurredAt: string;
payload: {
orderId: string;
customerId: string;
total: { amount: number; currency: string };
items: Array<{
productId: string;
quantity: number;
unitPrice: number;
}>;
shippingAddress: {
street: string;
city: string;
postalCode: string;
country: string;
};
};
}Publishing Integration Events
// application/event_handlers/publish_integration_events.ts
export class PublishOrderConfirmedIntegrationEvent {
constructor(
private readonly messageBroker: IMessageBroker,
private readonly orderRepo: IOrderRepository,
) {}
async handle(domainEvent: OrderConfirmed): Promise<void> {
const order = await this.orderRepo.findById(domainEvent.orderId);
if (!order) return;
const integrationEvent: OrderConfirmedIntegrationEvent = {
eventType: 'sales.order.confirmed',
eventId: crypto.randomUUID(),
version: '1.0',
occurredAt: new Date().toISOString(),
payload: {
orderId: order.id.value,
customerId: order.customerId.value,
total: {
amount: order.total.amount,
currency: order.total.currency,
},
items: order.items.map(item => ({
productId: item.productId.value,
quantity: item.quantity.value,
unitPrice: item.unitPrice.amount,
})),
shippingAddress: order.shippingAddress
? {
street: order.shippingAddress.street,
city: order.shippingAddress.city,
postalCode: order.shippingAddress.postalCode,
country: order.shippingAddress.country,
}
: null,
},
};
await this.messageBroker.publish('order-events', integrationEvent);
}
}---
Event Dispatcher Pattern
// infrastructure/events/event_dispatcher.ts
export interface IEventHandler<T extends DomainEvent> {
handle(event: T): Promise<void>;
}
export class EventDispatcher {
private handlers: Map<string, IEventHandler<any>[]> = new Map();
register<T extends DomainEvent>(
eventType: string,
handler: IEventHandler<T>,
): void {
const existing = this.handlers.get(eventType) ?? [];
existing.push(handler);
this.handlers.set(eventType, existing);
}
async dispatch(event: DomainEvent): Promise<void> {
const handlers = this.handlers.get(event.eventType) ?? [];
await Promise.all(handlers.map(h => h.handle(event)));
}
async dispatchAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.dispatch(event);
}
}
}
const dispatcher = new EventDispatcher();
dispatcher.register('order.created', new OrderCreatedHandler(readDb));
dispatcher.register('order.confirmed', new OrderConfirmedHandler(readDb));
dispatcher.register('order.confirmed', new PublishOrderConfirmedIntegrationEvent(broker, orderRepo));
dispatcher.register('order.shipped', new SendShippingNotificationHandler(orderRepo, notifier));---
Outbox Pattern
Ensures events are published reliably (exactly-once semantics).
interface OutboxMessage:
id: string
eventType: string
payload: string
createdAt: DateTime
processedAt: DateTime | null
class OutboxRepository:
db: Database
save(event: DomainEvent, tx: Transaction):
tx.outbox.insert({
id: event.eventId,
eventType: event.eventType,
payload: serialize(event.toPayload()),
createdAt: event.occurredAt
})
getUnprocessed(limit: int = 100) -> List<OutboxMessage>:
return db.outbox
.where(processedAt: null)
.orderBy("createdAt")
.limit(limit)
.lockForUpdate()
markProcessed(id: string):
db.outbox.where(id: id).update({processedAt: now()})
class PlaceOrderHandler:
orderRepo: IOrderRepository
outbox: OutboxRepository
db: Database
handle(command: PlaceOrderCommand) -> OrderId:
order = Order.create(CustomerId.from(command.customerId))
for item in command.items:
product = productRepo.findById(item.productId)
order.addItem(product.id, item.quantity, product.price)
db.transaction(tx => {
orderRepo.save(order, tx)
for event in order.domainEvents:
outbox.save(event, tx)
})
return order.id---
Event Sourcing (Brief Overview)
Store state changes as a sequence of events rather than current state.
Event Store:
Stream: order-123
Events:
1. OrderCreated{customerId: "c1", items: [...]}
2. OrderItemAdded{productId: "p1", quantity: 2}
3. OrderConfirmed{total: $40}
4. OrderShipped{trackingNumber: "TN123"}
Current State = fold over all events (left fold)Use when:
- Complete audit trail is required
- Temporal queries needed ("what was state on June 1?")
- Event replay for debugging/testing
Skip when:
- Simple CRUD operations
- No audit requirements
- Team unfamiliar with pattern
Hexagonal Architecture (Ports & Adapters)
Sources:
Primary:
- Hexagonal Architecture — Alistair Cockburn (2005)
- Hexagonal Architecture Explained — Alistair Cockburn & Juan Manuel Garrido de Paz (2024)
- Interview with Alistair Cockburn — Juan Manuel Garrido de Paz
Implementation guide:
- Hexagonal Architecture Pattern — AWS
Core Concept
"Allow an application to equally be driven by users, programs, automated tests, or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."
— Alistair Cockburn
Design validation technique: The pattern was designed with FIT testing in mind—business experts can write test cases before any GUI exists. If you can run your entire application from test fixtures, your hexagonal boundaries are correct.
The hexagon is conceptual. Most applications have 2-4 ports, not six. The shape emphasizes that all external interactions go through ports, regardless of direction.
This file uses a Hexagonal-focused layout where driven ports live under application/ports/driven/. In a DDD-centered layout, aggregate repository interfaces often live beside the aggregate in domain/. The important rule is ownership: the application/domain defines the abstractions it needs, and technology adapters implement them from the outside.
flowchart TB
subgraph DriverSide["DRIVER SIDE (Primary / Inbound / Left)"]
REST["REST API Adapter"]
CLI["CLI Adapter"]
DriverPorts["DRIVER PORTS\n(Use Case Interfaces)"]
REST --> DriverPorts
CLI --> DriverPorts
end
subgraph Hexagon["THE HEXAGON"]
subgraph AppCore["APPLICATION CORE"]
subgraph Domain["DOMAIN\n(Business Logic)"]
BL[" "]
end
end
end
subgraph DrivenSide["DRIVEN SIDE (Secondary / Outbound / Right)"]
DrivenPorts["DRIVEN PORTS\n(Repository Interfaces)"]
Postgres["Postgres Adapter"]
RabbitMQ["RabbitMQ Adapter"]
DrivenPorts --> Postgres
DrivenPorts --> RabbitMQ
end
DriverPorts --> AppCore
AppCore --> DrivenPorts
style DriverSide fill:#3b82f6,stroke:#2563eb,color:white
style Hexagon fill:#10b981,stroke:#059669,color:white
style DrivenSide fill:#f59e0b,stroke:#d97706,color:white
style Domain fill:#059669,stroke:#047857,color:white---
Ports
Interfaces defining how the application communicates with the outside world.
Explicit port interfaces are useful when multiple adapters, testing seams, or team boundaries justify them. For small codebases, a public use-case handler method can be enough as the driver port.
Driver Ports (Primary / Inbound)
Define how the world uses your application.
- Entry points to the application
- Called by adapters
- Represent use cases
// application/ports/driver/place_order_port.ts
export interface IPlaceOrderPort {
execute(command: PlaceOrderCommand): Promise<OrderId>;
}
// application/ports/driver/get_order_port.ts
export interface IGetOrderPort {
execute(query: GetOrderQuery): Promise<OrderDTO | null>;
}
// application/ports/driver/cancel_order_port.ts
export interface ICancelOrderPort {
execute(command: CancelOrderCommand): Promise<void>;
}Driven Ports (Secondary / Outbound)
Define how your application uses external systems.
- Dependencies the application needs
- Implemented by adapters
- Application calls these interfaces
// application/ports/driven/order_repository_port.ts
export interface IOrderRepositoryPort {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(order: Order): Promise<void>;
}
// application/ports/driven/event_publisher_port.ts
export interface IEventPublisherPort {
publish(event: DomainEvent): Promise<void>;
publishAll(events: DomainEvent[]): Promise<void>;
}
// application/ports/driven/payment_gateway_port.ts
export interface IPaymentGatewayPort {
charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>;
refund(paymentId: PaymentId, amount: Money): Promise<RefundResult>;
}
// application/ports/driven/notification_port.ts
export interface INotificationPort {
sendEmail(to: Email, template: EmailTemplate): Promise<void>;
sendSMS(to: PhoneNumber, message: string): Promise<void>;
}---
Adapters
Concrete implementations that connect ports to external technologies.
Driver Adapters (Primary / Inbound)
Convert external inputs to port calls.
// infrastructure/adapters/driver/rest/order_controller.ts
import { Router, Request, Response } from 'express';
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
import { IGetOrderPort } from '@/application/ports/driver/get_order_port';
export class OrderController {
constructor(
private readonly placeOrder: IPlaceOrderPort,
private readonly getOrder: IGetOrderPort,
) {}
async create(req: Request, res: Response): Promise<void> {
const command: PlaceOrderCommand = {
customerId: req.user.id,
items: req.body.items.map((item: any) => ({
productId: item.product_id,
quantity: item.quantity,
})),
};
const orderId = await this.placeOrder.execute(command);
res.status(201).json({ id: orderId.value });
}
async show(req: Request, res: Response): Promise<void> {
const order = await this.getOrder.execute({ orderId: req.params.id });
if (!order) {
res.status(404).json({ error: 'Order not found' });
return;
}
res.json(order);
}
}
// infrastructure/adapters/driver/grpc/order_service.ts
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
import { OrderServiceServer, PlaceOrderRequest, PlaceOrderResponse } from './generated/order_pb';
export class GrpcOrderService implements OrderServiceServer {
constructor(private readonly placeOrder: IPlaceOrderPort) {}
async placeOrder(
request: PlaceOrderRequest,
): Promise<PlaceOrderResponse> {
const command: PlaceOrderCommand = {
customerId: request.getCustomerId(),
items: request.getItemsList().map(item => ({
productId: item.getProductId(),
quantity: item.getQuantity(),
})),
};
const orderId = await this.placeOrder.execute(command);
const response = new PlaceOrderResponse();
response.setOrderId(orderId.value);
return response;
}
}
// infrastructure/adapters/driver/cli/place_order_command.ts
import { Command } from 'commander';
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
export function createPlaceOrderCommand(placeOrder: IPlaceOrderPort): Command {
return new Command('place-order')
.description('Place a new order')
.requiredOption('-c, --customer <id>', 'Customer ID')
.requiredOption('-p, --product <id>', 'Product ID')
.requiredOption('-q, --quantity <number>', 'Quantity', parseInt)
.action(async (options) => {
const orderId = await placeOrder.execute({
customerId: options.customer,
items: [{ productId: options.product, quantity: options.quantity }],
});
console.log(`Order created: ${orderId.value}`);
});
}
// infrastructure/adapters/driver/message/order_message_handler.ts
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
export class OrderMessageHandler {
constructor(private readonly placeOrder: IPlaceOrderPort) {}
async handlePlaceOrderMessage(message: PlaceOrderMessage): Promise<void> {
await this.placeOrder.execute({
customerId: message.customerId,
items: message.items,
});
}
}Driven Adapters (Secondary / Outbound)
Implement port interfaces using specific technologies.
class PostgresOrderRepository implements IOrderRepositoryPort:
db: Database
findById(id: OrderId) -> Order | null:
row = db.orders.where(id: id.value).first()
if not row:
return null
return OrderMapper.toDomain(row)
save(order: Order):
data = OrderMapper.toPersistence(order)
db.orders.upsert(data)
delete(order: Order):
db.orders.where(id: order.id.value).delete()In-Memory (for tests):
class InMemoryOrderRepository implements IOrderRepositoryPort:
orders: Map<string, Order> = {}
findById(id: OrderId) -> Order | null:
return orders.get(id.value) or null
save(order: Order):
orders.set(order.id.value, order)
delete(order: Order):
orders.delete(order.id.value)
clear():
orders.clear()Payment Gateway:
class StripePaymentGateway implements IPaymentGatewayPort:
stripe: StripeClient
charge(amount: Money, paymentMethod: PaymentMethod) -> PaymentResult:
try:
intent = stripe.paymentIntents.create({
amount: amount.cents,
currency: amount.currency,
paymentMethod: paymentMethod.stripeId,
confirm: true
})
return PaymentResult.success(PaymentId.from(intent.id))
catch CardError as error:
return PaymentResult.failed(error.message)
refund(paymentId: PaymentId, amount: Money) -> RefundResult:
refund = stripe.refunds.create({paymentIntent: paymentId.value, amount: amount.cents})
return RefundResult.success(RefundId.from(refund.id))Event Publisher:
class RabbitMQEventPublisher implements IEventPublisherPort:
channel: Channel
publish(event: DomainEvent):
channel.publish("domain_events", event.eventType, serialize({
eventId: event.eventId,
eventType: event.eventType,
occurredAt: event.occurredAt,
payload: event.toPayload()
}))
publishAll(events: List<DomainEvent>):
for event in events:
publish(event)---
Naming Conventions
Alistair Cockburn's Recommended Pattern
Ports: For[Doing][Something]
- Driver:
ForPlacingOrders,ForConfiguringSettings - Driven:
ForStoringUsers,ForNotifyingAlerts
Adapters: Reference the technology
CliCommandForPlacingOrdersMysqlDatabaseForStoringUsersSlackNotifierForAlerts
Alternative Patterns
| Pattern | Port | Adapter |
|---|---|---|
| Interface/Impl | IOrderRepository | PostgresOrderRepository |
| Port suffix | OrderRepositoryPort | PostgresOrderAdapter |
| Using prefix | IOrderStorage | OrderStorageUsingPostgres |
Project Structure
Use this structure when you want all Hexagonal ports grouped by direction. If the codebase follows the DDD-centered default from SKILL.md, keep aggregate repositories in domain/{aggregate}/repository and reserve application/ports/driven/ for application-owned dependencies such as payment gateways, notification gateways, clocks, or event publishers.
src/
├── application/
│ ├── ports/
│ │ ├── driver/ # Inbound ports
│ │ │ ├── place_order_port.ts
│ │ │ ├── get_order_port.ts
│ │ │ └── cancel_order_port.ts
│ │ └── driven/ # Outbound ports
│ │ ├── order_repository_port.ts
│ │ ├── event_publisher_port.ts
│ │ └── payment_gateway_port.ts
│ └── use_cases/
│ ├── place_order/
│ │ └── handler.ts # Implements driver port
│ └── get_order/
│ └── handler.ts
├── infrastructure/
│ └── adapters/
│ ├── driver/ # Inbound adapters
│ │ ├── rest/
│ │ │ └── order_controller.ts
│ │ ├── grpc/
│ │ │ └── order_service.ts
│ │ └── cli/
│ │ └── commands.ts
│ └── driven/ # Outbound adapters
│ ├── postgres/
│ │ └── order_repository.ts
│ ├── rabbitmq/
│ │ └── event_publisher.ts
│ ├── stripe/
│ │ └── payment_gateway.ts
│ └── in_memory/ # Test adapters
│ ├── order_repository.ts
│ └── event_publisher.ts
└── domain/
└── ...---
Key Asymmetry
flowchart TB
subgraph Driver["DRIVER (Left)"]
direction TB
DA["Adapter\n(Controller)"]
DP["Port\n(Interface)"]
DA -->|calls| DP
end
subgraph Driven["DRIVEN (Right)"]
direction TB
DRP["Port\n(Interface)"]
DRA["Adapter\n(Postgres)"]
DRA -->|implements| DRP
end
Driver -.->|"Application defines\nwhat it OFFERS"| Note1[" "]
Driven -.->|"Application defines\nwhat it NEEDS"| Note2[" "]
style Driver fill:#3b82f6,stroke:#2563eb,color:white
style Driven fill:#f59e0b,stroke:#d97706,color:white
style Note1 fill:none,stroke:none
style Note2 fill:none,stroke:none---
Configurability via Adapters
The power of hexagonal architecture: swap adapters without changing the core.
// infrastructure/config/container.ts
function configureDevelopment(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(InMemoryOrderRepository);
container.bind<IEventPublisherPort>('IEventPublisherPort')
.to(InMemoryEventPublisher);
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
.to(FakePaymentGateway);
}
function configureTest(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(InMemoryOrderRepository);
container.bind<IEventPublisherPort>('IEventPublisherPort')
.to(SpyEventPublisher);
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
.to(MockPaymentGateway);
}
function configureProduction(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(PostgresOrderRepository);
container.bind<IEventPublisherPort>('IEventPublisherPort')
.to(RabbitMQEventPublisher);
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
.to(StripePaymentGateway);
}
function configureWithMongoDB(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(MongoDBOrderRepository);
}---
Strong vs Weak Hexagonal
Weak Implementation
Port is technology-aware (not truly abstract):
// ❌ Weak: Leaks SQL concepts
interface IOrderRepository {
findByQuery(sql: string, params: any[]): Promise<Order[]>;
}Strong Implementation
Port is fully technology-agnostic:
// ✅ Strong: Pure domain concepts
interface IOrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
save(order: Order): Promise<void>;
}---
Benefits
1. Testability - Swap real adapters for test doubles 2. Flexibility - Change technologies without changing core 3. Independence - Develop core without external systems 4. Clear boundaries - Explicit interfaces between layers 5. Parallel development - Teams work on different adapters