
Ddd Code Reviewer
- 17 installs
- 1 repo stars
- Updated July 29, 2026
- full-statck-skills/ddd-skills
Reviews DDD code for anti-patterns like anemic models, checks layered compliance and aggregate design, and outputs a 5-dimension scored report.
About
Performs DDD code review with anti-pattern detection, layered-compliance checks, and five-dimension scoring plus ArchUnit validation. A developer uses it to verify that code follows DDD best practices.
- Anemic vs rich model detection
- Structured report with scores and fix suggestions
Ddd Code Reviewer by the numbers
- 17 all-time installs (skills.sh)
- Ranked #760 of 1,352 Code Review & Quality 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-code-reviewerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 17 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 29, 2026 |
| Repository | full-statck-skills/ddd-skills ↗ |
What it does
Reviews DDD code for anti-patterns like anemic models, checks layered compliance and aggregate design, and outputs a 5-dimension scored report.
Files
DDD Code Reviewer
DDD 代码审查 + 反模式检测 + 5 维度合规评分。验证代码是否遵循 DDD 最佳实践,输出结构化审查报告。
Workflow
DDD 代码审查三步走:
Step 1: 代码扫描 — 反模式检测清单逐项检查 + 源代码扫描
Step 2: 评分计算 — 5 维度逐项评分(分层/领域/命名/结构/测试)
Step 3: 报告输出 — 结构化审查报告(评分 + 反模式列表 + 修复建议)每次审查必须走完三步,输出完整报告。只口述问题不输出文档,团队无法追踪改进。
When to Use
触发词(触发即用)
代码审查 DDD review 反模式检测 anti-pattern 贫血模型 充血模型检查 架构审查 分层合规 代码质量评分 layering compliance rich domain check review my DDD code
适用前提
| 条件 | 说明 |
|---|---|
| 项目已采用 DDD 思想 | 非 DDD 项目无审查基础 |
| 有可审查的源代码 | 需提供代码路径或片段 |
| 期望发现反模式 | 不只是格式检查,更关注领域模型健康度 |
不适用场景
| 跳过 | 改用 |
|---|---|
| 非 DDD 项目(纯 CRUD) | 标准代码审查(SonarQube / Checkstyle) |
| 项目尚未引入 DDD | ddd-architecture-awesome(先学习 DDD 概念) |
| 刚写完第一个 DDD 代码 | 先写完再审查 |
| 需要架构选型 | ddd-architecture-selector |
| 需要架构健康度评估 | ddd-architecture-evaluator |
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. Every code review must include a 5-dimension scoring report. 2. P0 anti-patterns (anemia model, layer violation, cross-aggregate ref) block merge. 3. Domain layer must have zero framework dependencies — verified via ArchUnit. 4. Review reports must include fix suggestions ordered by priority (P0→P1→P2).
Anti-Pattern Checklist
12 种 DDD 反模式,按 P0(阻塞合并)/P1(下个版本前修复)/P2(持续改进)分级。
- P0 — 必须修复: 贫血模型、上帝 Service、跨聚合直接引用、领域层框架依赖、循环依赖、Repository 返回非聚合根
- P1 — 应该修复: Controller 业务逻辑、Application Service 有 SQL、值对象可变
- P2 — 持续改进: 聚合过大、缺少领域事件、跨聚合事务
完整反模式速查表见 references/checklist.md,含 Java 代码示例和修复路径。
Layered Compliance Matrix
分层依赖检查规则(基于 ArchUnit):
┌─────────────────┬─────┬─────┬─────┬─────┐
│ 层 / 可依赖 │ Infra│ Dom │ App │ Adap│
├─────────────────┼─────┼─────┼─────┼─────┤
│ Infrastructure │ ✓ │ ✗ │ ✗ │ ✗ │
│ Domain │ ✗ │ ✓ │ ✗ │ ✗ │
│ Application │ ✓ │ ✓ │ ✓ │ ✗ │
│ Adapter │ ✓ │ ✓ │ ✓ │ ✓ │
└─────────────────┴─────┴─────┴─────┴─────┘Domain 层零依赖规则(P0):
- ✗
import org.springframework.stereotype.Service - ✗
import javax.persistence.Entity - ✗
import org.apache.ibatis.annotations.Mapper - ✓
import java.util.Optional - ✓
import java.math.BigDecimal
ArchUnit 完整配置见 references/archunit-config.md。
Rich Domain Model Validation
Rich models encapsulate behavior in entities (pass); anemic models expose state via getters/setters (fail). Key: behavior in Entity vs behavior in Service.
完整代码示例和重构对比见 examples/rich-model-refactoring.md,含 3 个实战案例。
Scoring System
5 维度评分
| 维度 | 权重 | 检查项 | 满分 |
|---|---|---|---|
| 1. 分层合规 | 30% | Domain 零依赖、依赖方向、App 无 SQL、Controller 无业务逻辑 | 30 |
| 2. 领域模型质量 | 30% | 充血模型覆盖率、值对象使用率、聚合设计、领域事件 | 30 |
| 3. 命名规范 | 15% | 聚合根 = 业务名称、Repository = 标准命名、事件 = 过去式 | 15 |
| 4. 代码结构 | 15% | 包按聚合组织、类大小、圈复杂度 | 15 |
| 5. 测试覆盖 | 10% | Domain 层测试覆盖、聚合根行为测试、事件验证 | 10 |
总分 = Σ(维度得分 × 权重)
分数等级
| 范围 | 等级 | 含义 | 行动 |
|---|---|---|---|
| ≥ 85 | 🟢 A | 优秀 DDD 实践 | 可上生产 |
| 70-84 | 🟡 B | 基本合规,有改进空间 | 修 P1 问题 |
| 50-69 | 🟠 C | 存在明显反模式 | 规划重构 Sprint |
| < 50 | 🔴 D | 需要大面积重构 | 阻塞合并,必须先重构 |
评分细则和计算示例见 references/scoring-criteria.md、examples/scoring-example.md。
Review Report Template
每次审查输出结构化报告,包含:
# DDD Code Review Report
## Overall Score: 78/100 (🟡 B)
### 分层合规 (24/30)
| 检查项 | 结果 | 说明 |
|--------|:----:|------|
| Domain 零依赖 | ✅ | 无框架依赖 |
| App 层无 SQL | ❌ | OrderAppService L45 直接调用 Mapper |
| 依赖方向 | ✅ | 无反向依赖 |
### 领域模型质量 (22/30)
| 检查项 | 结果 | 说明 |
|--------|:----:|------|
| 充血模型 | ⚠️ | User 实体仍为贫血模型 |
| 值对象 | ⚠️ | Money/Email 已用,Phone 仍为 String |
### 反模式清单
| 严重级别 | 反模式 | 位置 | 修复建议 |
|:------:|--------|------|---------|
| P0 | 上帝 Service | OrderService.java:45-320 | 拆分为 OrderPricingService + OrderFulfillmentService |
### 改进建议(按优先级)
1. [P0] 将 OrderAppService 中的 SQL 移到 Repository 实现
2. [P1] 将 User 实体改为充血模型
3. [P2] 为关键业务操作补充领域事件完整模板见 references/report-template.md、示例见 examples/review-report-example.md。
Gotchas
常见审查陷阱见 references/gotchas.md。
FAQ
| 问题 | 回答 |
|---|---|
| 一次审查要多久? | 小项目(1-2 聚合)约 30-60 分钟;大项目按模块分次审查 |
| 评分是主观的吗? | 每个检查项有明确检测标准(见 references/scoring-criteria.md),可重复、可验证 |
| ArchUnit 检查必须自己写吗? | 直接使用 references/archunit-config.md 中的配置,复制到项目即可 |
| 找到了反模式,改不动怎么办? | 以 P0 优先修复。P1/P2 记入技术债务,制定偿还计划 |
| 审查频率建议? | 代码审查推荐 PR 合并前做。架构级审查推荐每季度一次 |
| 与非 DDD 架构的审查区别? | DDD 审查关注领域模型健康度,非 DDD 审查关注代码规范/性能/安全 |
Security & Safety
This skill is pure documentation. It contains no executable scripts, collects no user data, accesses no external services or networks.
Keywords
DDD 代码审查、反模式检测、贫血模型、充血模型、上帝 Service、分层合规、依赖方向、ArchUnit、领域事件、值对象不可变、聚合设计、5 维度评分、代码质量门禁
References
| 文件 | 用途 |
|---|---|
| references/checklist.md | 反模式速查表 — P0/P1/P2 分级 + 修复路径 |
| references/scoring-criteria.md | 5 维度评分细则 — 每项检查的权重和检测方法 |
| references/archunit-config.md | ArchUnit 完整配置 — Maven/Gradle 依赖 + 全套检查规则 |
| references/report-template.md | 审查报告模板 — Markdown + 快速摘要格式 |
| references/clean-ddd-hexagonal-layers.md | 四层架构结构详解 — Domain/App/Infra/Presentation |
| references/clean-ddd-hexagonal-tactical.md | DDD 战术模式参考 — Entity/VO/Aggregate/Repository |
| references/clean-ddd-hexagonal-testing.md | 测试模式 — 单元测试/集成测试/架构测试 |
| references/partme-15-boundaries.md | 微服务边界理论 — 逻辑边界/物理边界/代码边界 |
| references/gotchas.md | 审查常见陷阱 — 跨聚合引用、PO/DTO 混用等 |
Examples
| 文件 | 用途 |
|---|---|
| examples/review-report-example.md | 完整审查报告示例(62/100 🟠 C 级) |
| examples/scoring-example.md | 评分计算全过程 + 修复 ROI 分析 |
| examples/rich-model-refactoring.md | 贫血→充血模型重构:3 个实战案例 |
| examples/anti-pattern-fix-guide.md | 反模式修复路径速查 |
| examples/archunit-compliance-test.md | ArchUnit 合规测试 — P0/P1/P2 门禁实现 |
Next Steps
审查完成后,根据结果进入后续步骤:
- 架构级别评估 →
ddd-architecture-evaluator - 修复架构目录结构 → 对应架构 Skill(
ddd-architecture-layered/ddd-architecture-hexagonal/ddd-architecture-cola等) - 提升测试覆盖率 →
ddd-testing-strategist - 输出架构文档 →
ddd-architecture-doc
DDD 反模式速查 — 含修复路径
P0 反模式(必须修复)
贫血模型
// ❌ 反模式
@Entity public class Order { private Long id; private String status; /* only getter/setter */ }
@Service public class OrderService { public void pay(Long id) { /* all logic here */ } }
// ✅ 修复
@Entity public class Order {
private OrderStatus status;
public void pay() { if (!status.canPay()) throw new OrderException(...); this.status = OrderStatus.PAID; }
}领域层框架依赖
// ❌ Domain 层 import org.springframework.stereotype.Service
// ✅ Domain 层只用 Java 标准库跨聚合直接引用
// ❌ class Order { Customer customer; }
// ✅ class Order { CustomerId customerId; }循环依赖
// ❌ Module A → Module B → Module A
// ✅ 依赖方向: Interface → App → Domain ← InfrastructureP1 反模式(应该修复)
Repository 返回 DTO
// ❌ public OrderDTO findById(Long id); // Repository 返回 DTO
// ✅ public Optional<Order> findById(OrderId id); // 返回聚合根Controller 业务逻辑
// ❌ Controller 中有 if/else 业务判断
// ✅ Controller 只做协议转换,业务在 Domain + ApplicationApplication Service 中有 SQL
// ❌ AppService 中调用 jdbcTemplate.query()
// ✅ SQL 操作在 Infrastructure Repository修复顺序建议
Phase 1: 修复 P0(阻止合并)
1. 领域层去框架化
2. 切断循环依赖
3. 修复跨聚合引用
Phase 2: 修复 P1(下个版本前)
4. Repository 返回聚合根
5. Controller 去业务化
6. App 层去 SQL
Phase 3: 优化 P2(持续改进)
7. 聚合瘦身
8. 补充领域事件
9. 值对象替换原始类型ArchUnit Compliance Test Suite
P0/P1/P2 分级门禁的 ArchUnit 测试实现。
P0 — Block Merge(阻断合并)
@ArchTest
static final ArchRule domain_should_not_depend_on_framework =
classes().that().resideInAPackage("..domain..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("..domain..", "java..", "org.slf4j..");P1 — Warning(告警)
@ArchTest
static final ArchRule no_cyclic_dependencies =
slices().matching("..domain.(*)..")
.should().beFreeOfCycles();P2 — Report(报告)
@ArchTest
static final ArchRule naming_convention =
classes().that().areAnnotatedWith(Service.class)
.should().haveSimpleNameEndingWith("Service");运行方式
将上述规则放在 src/test/java/architecture/ 下,JUnit 5 + ArchUnit Runner 自动执行。
DDD Code Review Report — Order Service
Project: partme-order-service Review Date: 2026-05-29 Scope: Order aggregate, Payment aggregate, Application services
Overall Score: 62/100 (🟠 C)
Dimension Breakdown
| Dimension | Score | Weight | Weighted | Status |
|---|---|---|---|---|
| Layering Compliance | 55/100 | 30% | 16.5/30 | ⚠️ |
| Domain Model Quality | 50/100 | 30% | 15.0/30 | ⚠️ |
| Naming Conventions | 80/100 | 15% | 12.0/15 | ✅ |
| Code Structure | 70/100 | 15% | 10.5/15 | ⚠️ |
| Test Coverage | 80/100 | 10% | 8.0/10 | ✅ |
| Total | 100% | 62/100 | 🟠 C |
---
1. Layering Compliance (16.5/30)
| Check Item | Result | Note |
|---|---|---|
| Domain zero framework dependency | ❌ | Order.java:1 imports javax.persistence.Entity |
| No reverse dependency | ✅ | Domain does not import infrastructure |
| App layer no SQL | ❌ | OrderAppService.java:45 calls orderMapper.selectByStatus() |
| Controller no business logic | ✅ | Controller only does protocol conversion |
| Repository returns aggregate root | ⚠️ | OrderSummaryRepository returns DTO instead of Aggregate |
| No circular dependencies | ✅ | Modules layering is clean |
2. Domain Model Quality (15.0/30)
| Check Item | Result | Note |
|---|---|---|
| Rich model coverage | ⚠️ | 3/5 entities have business methods (60%) |
| Value Object usage | ❌ | Only Money used. status is String, email is String |
| Aggregate design | ⚠️ | OrderAggregate has 6 entities (exceeds 5 limit) |
| Domain events | ❌ | No domain events found. pay() does not emit OrderPaidEvent |
| VO immutability | ⚠️ | Address has setters |
3. Naming Conventions (12.0/15)
| Check Item | Result | Note |
|---|---|---|
| Aggregate Root naming | ✅ | Order, Payment, Product |
| Repository naming | ✅ | OrderRepository, PaymentRepository |
| Domain Service naming | ⚠️ | OrderUtilService → should be OrderPricingService |
| Domain Event naming (past tense) | ❌ | OrderPay → should be OrderPaid |
| Package by aggregate | ✅ | Organized as domain/order/, domain/payment/ |
4. Code Structure (10.5/15)
| Check Item | Result | Note |
|---|---|---|
| Aggregate Root < 200 lines | ✅ | All under 200 lines |
| Service < 100 lines | ❌ | OrderService.java is 487 lines |
| Cyclomatic complexity < 10 | ⚠️ | OrderService.calculateDiscount() has complexity 15 |
| Package organization | ✅ | Aggregates have clear boundaries |
| Exception handling | ⚠️ | Mixed domain exceptions and runtime exceptions |
5. Test Coverage (8.0/10)
| Check Item | Result | Note |
|---|---|---|
| Domain unit test coverage | ✅ | ~85% methods tested |
| Aggregate root behavior tests | ⚠️ | Order.confirm() edge cases not tested |
| Domain event assertion | ✅ | Events verified in Order.create() test |
---
Anti-Pattern List
| Severity | Anti-Pattern | File:Line | Fix Suggestion |
|---|---|---|---|
| P0 | Domain layer framework dependency | Order.java:1 | Remove @Entity, use POJO + separate JPA entity |
| P0 | God Service | OrderService.java:45-320 | Split into OrderPricingService + OrderFulfillmentService |
| P0 | Missing domain events | Order.java:55-70 | Add OrderPaidEvent to pay() method |
| P1 | App layer has SQL | OrderAppService.java:45 | Move orderMapper call to Repository implementation |
| P1 | Controller business logic | OrderController.java:88-92 | Move status check into Order.pay() |
| P1 | Mutable Value Object | Address.java | Make fields final, remove setters |
| P2 | Oversized aggregate | OrderAggregate | Split off OrderLog into separate aggregate |
Improvement Suggestions
Immediate (P0): 1. Remove javax.persistence.Entity from Order.java — 0.5h 2. Extract God Service OrderService → 2 domain services — 2h 3. Add OrderPaidEvent to Order.pay() — 1h
Short-term (P1): 1. Move SQL from OrderAppService.java to JpaOrderRepository — 1h 2. Replace String status with OrderStatus Value Object — 1.5h 3. Make Address immutable (final fields, no setters) — 0.5h
Long-term (P2): 1. Split OrderLog from OrderAggregate — 3h 2. Add integration tests for repository implementations — 4h 3. Add ArchUnit tests to CI pipeline — 2h
---
Summary
The project has a solid structural foundation (correct package organization, good naming conventions) but suffers from critical DDD violations: the domain layer is contaminated with JPA annotations, business logic is centralized in a God Service, and key domain events are missing. A focused 2-day refactoring sprint can bring this to a B grade.
Rich Domain Model Refactoring Examples
Example 1: Order Payment — From Anemic to Rich
Before: Anemic Model ❌
// Order.java — Pure data carrier
@Entity
@Table(name = "orders")
public class Order {
private Long id;
private String status; // Bare string: "DRAFT", "PAID", etc.
private BigDecimal totalAmount;
private Long customerId;
// 50+ lines of getters/setters only
}
// OrderService.java — God Service, 487 lines
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private PaymentMapper paymentMapper;
@Transactional
public void pay(Long orderId) { // ALL logic in Service
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new RuntimeException("Order not found");
}
if (!"DRAFT".equals(order.getStatus())) { // Raw string comparison
throw new RuntimeException("Invalid status");
}
// ... 30 more lines of business logic
order.setStatus("PAID"); // Public setter
orderMapper.update(order);
}
}After: Rich Domain Model ✅
// Order.java — Rich aggregate root
public class Order extends AggregateRoot<OrderId> {
private OrderStatus status; // Value Object, not String
private Money totalAmount; // Value Object, not BigDecimal
private CustomerId customerId; // ID reference, not Long
private List<OrderItem> items;
// No public constructor — use factory method
private Order(OrderId id, CustomerId customerId) {
super(id);
this.customerId = customerId;
this.status = OrderStatus.DRAFT;
this.items = new ArrayList<>();
}
// Factory method with domain event
public static Order create(OrderId id, CustomerId customerId) {
Order order = new Order(id, customerId);
order.addDomainEvent(new OrderCreated(id, customerId));
return order;
}
// Rich behavior — not getter/setter
public void pay() {
if (!status.canTransitionTo(OrderStatus.PAID)) {
throw new OrderDomainException(
"Cannot pay order in status: " + status
);
}
this.status = OrderStatus.PAID;
addDomainEvent(new OrderPaid(this.id, this.totalAmount));
}
public void cancel(String reason) {
if (status.isFinalState()) {
throw new OrderDomainException(
"Cannot cancel order in final state: " + status
);
}
this.status = OrderStatus.CANCELLED;
addDomainEvent(new OrderCancelled(this.id, reason));
}
}
// OrderStatus.java — Immutable Value Object
public final class OrderStatus {
public static final OrderStatus DRAFT = new OrderStatus("DRAFT", 0);
public static final OrderStatus CONFIRMED = new OrderStatus("CONFIRMED", 1);
public static final OrderStatus PAID = new OrderStatus("PAID", 2);
public static final OrderStatus SHIPPED = new OrderStatus("SHIPPED", 3);
public static final OrderStatus DELIVERED = new OrderStatus("DELIVERED", 4);
public static final OrderStatus CANCELLED = new OrderStatus("CANCELLED", -1);
private final String name;
private final int order; // For state transition validation
private OrderStatus(String name, int order) {
this.name = name;
this.order = order;
}
public boolean canTransitionTo(OrderStatus target) {
// DRAFT → CONFIRMED → PAID → SHIPPED → DELIVERED (forward)
// Any → CANCELLED (except DELIVERED)
return (target == CANCELLED && this != DELIVERED)
|| (target.order > this.order && target.order - this.order == 1);
}
// No setters — immutability
}---
Example 2: Customer Registration — Guard Conditions
Before: Anemic ❌
// CustomerService.java — Business logic leaked into service
@Service
public class CustomerService {
@Autowired
private CustomerMapper customerMapper;
public void register(String email, String name) {
// Validation in Service
if (email == null || !email.contains("@")) {
throw new RuntimeException("Invalid email");
}
if (name == null || name.length() < 2) {
throw new RuntimeException("Name too short");
}
// Business rule in Service
Customer existing = customerMapper.findByEmail(email);
if (existing != null) {
throw new RuntimeException("Email already registered");
}
customerMapper.insert(email, name, "ACTIVE");
}
}After: Rich ✅
// Customer.java — Rich aggregate root with self-validation
public class Customer extends AggregateRoot<CustomerId> {
private Email email; // Value Object
private PersonName name; // Value Object
private CustomerStatus status;
private final List<CustomerDomainEvent> events = new ArrayList<>();
private Customer(CustomerId id, Email email, PersonName name) {
super(id);
this.email = email;
this.name = name;
this.status = CustomerStatus.ACTIVE;
}
public static Customer register(CustomerId id, Email email, PersonName name) {
// Domain logic encapsulated in entity
Customer customer = new Customer(id, email, name);
customer.addDomainEvent(new CustomerRegistered(id, email));
return customer;
}
}
// Email.java — Self-validating Value Object
public final class Email {
private final String value;
public Email(String value) {
if (value == null || !value.matches("^[\\w.-]+@[\\w.-]+\\.\\w{2,}$")) {
throw new InvalidEmailException(value);
}
this.value = value.toLowerCase();
}
public String getValue() { return value; }
// No setter — immutability
}
// CustomerRepository.java — Interface in domain
public interface CustomerRepository {
boolean existsByEmail(Email email);
void save(Customer customer);
Optional<Customer> findById(CustomerId id);
}---
Example 3: Value Object Introduction
Before: Primitive Obsession ❌
public class Order {
private Long id;
private String receiverName; // Raw string
private String receiverPhone; // Raw string — no validation
private String province; // Raw string
private String city; // Raw string
private String street; // Raw string
private String zipCode; // Raw string — no format check
private BigDecimal price; // Raw BigDecimal — no currency
}After: Value Objects ✅
public class Order extends AggregateRoot<OrderId> {
private ShippingAddress shippingAddress; // Value Object
private Money total; // Value Object
// When setting shipping address:
public void setShippingAddress(ShippingAddress address) {
if (this.status != OrderStatus.DRAFT) {
throw new OrderDomainException("Can only set address in DRAFT status");
}
this.shippingAddress = address;
}
}
// ShippingAddress.java — Immutable Value Object
public final class ShippingAddress {
private final String receiverName;
private final PhoneNumber phone; // Nested Value Object
private final String province;
private final String city;
private final String street;
private final ZipCode zipCode; // Nested Value Object
public ShippingAddress(
String receiverName,
PhoneNumber phone,
String province,
String city,
String street,
ZipCode zipCode) {
// Guard conditions
if (receiverName == null || receiverName.isBlank()) {
throw new IllegalArgumentException("receiverName required");
}
this.receiverName = receiverName;
this.phone = phone;
this.province = province;
this.city = city;
this.street = street;
this.zipCode = zipCode;
}
// No setters — immutability
}
// ZipCode.java — Self-validating Value Object
public final class ZipCode {
private final String value;
public ZipCode(String value) {
if (value == null || !value.matches("\\d{5}(-\\d{4})?")) {
throw new InvalidZipCodeException(value);
}
this.value = value;
}
public String getValue() { return value; }
}
// Money.java — Value Object with behavior
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidMoneyException("Amount cannot be negative");
}
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(this.currency, other.currency);
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int factor) {
return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency);
}
}Refactoring Impact Summary
| Metric | Before | After | Improvement |
|---|---|---|---|
| Business logic in entity | 0% | 100% | Core domain encapsulated |
| Value Object coverage | 0/7 fields | 6/7 fields | 86% primitive reduction |
| Service line count | 487 lines | 42 lines | 91% reduction (pure orchestration) |
| Testability | Mock-heavy, fragile | Pure unit tests, no mocks | Faster, more reliable tests |
| Domain events emitted | 0 | 4 | Observable domain operations |
| JPA dependency in domain | Yes | No | Clean domain, DIP satisfied |
Scoring Calculation Example
This example demonstrates the full scoring calculation for a fictional project partme-inventory-service.
Input Data
Dimension 1: Layering Compliance (weight 30%)
| Check Item | Pass? | Weight | Score |
|---|---|---|---|
| Domain zero framework dependency | ❌ | 25% | 0 |
| No reverse dependency | ✅ | 20% | 20 |
| App layer no SQL | ✅ | 20% | 20 |
| Controller no business logic | ✅ | 15% | 15 |
| Repository returns aggregate root | ⚠️ (partial) | 10% | 5 |
| No circular dependencies | ✅ | 10% | 10 |
Raw score: (0 + 20 + 20 + 15 + 5 + 10) = 70/100 Weighted: 70 × 30% = 21.0/30
Dimension 2: Domain Model Quality (weight 30%)
| Check Item | Pass? | Weight | Score |
|---|---|---|---|
| Rich model coverage (6/10 entities) | ⚠️ | 30% | 18 |
| Value Object usage (4/12 fields) | ⚠️ | 25% | 8 |
| Aggregate design | ✅ | 20% | 18 |
| Domain events for key operations (2/5) | ⚠️ | 15% | 6 |
| Immutability of value objects (3/4 VOs) | ✅ | 10% | 8 |
Raw score: (18 + 8 + 18 + 6 + 8) = 58/100 Weighted: 58 × 30% = 17.4/30
Dimension 3: Naming Conventions (weight 15%)
| Check Item | Pass? | Weight | Score |
|---|---|---|---|
| Aggregate Root naming | ✅ | 20% | 20 |
| Repository naming | ✅ | 20% | 20 |
| Domain Service naming | ✅ | 15% | 15 |
| Domain Event naming | ⚠️ | 15% | 10 |
| Package by aggregate | ✅ | 15% | 15 |
| Method naming | ✅ | 15% | 15 |
Raw score: (20 + 20 + 15 + 10 + 15 + 15) = 95/100 Weighted: 95 × 15% = 14.25/15
Dimension 4: Code Structure (weight 15%)
| Check Item | Pass? | Weight | Score |
|---|---|---|---|
| Aggregate Root < 200 lines | ✅ | 20% | 20 |
| Service < 100 lines | ❌ | 20% | 0 |
| Cyclomatic complexity < 10 | ⚠️ | 20% | 10 |
| Package by aggregate | ✅ | 20% | 20 |
| Exception handling | ✅ | 20% | 18 |
Raw score: (20 + 0 + 10 + 20 + 18) = 68/100 Weighted: 68 × 15% = 10.2/15
Dimension 5: Test Coverage (weight 10%)
| Check Item | Pass? | Weight | Score |
|---|---|---|---|
| Domain layer unit coverage (~70%) | ⚠️ | 40% | 28 |
| Aggregate root behavior tests | ✅ | 30% | 28 |
| Domain event verification | ❌ | 30% | 0 |
Raw score: (28 + 28 + 0) = 56/100 Weighted: 56 × 10% = 5.6/10
Final Calculation
Total = 21.0 + 17.4 + 14.25 + 10.2 + 5.6 = 68.45/100Result
| Dimension | Raw Score | Weight | Weighted |
|---|---|---|---|
| Layering Compliance | 70/100 | 30% | 21.0/30 |
| Domain Model Quality | 58/100 | 30% | 17.4/30 |
| Naming Conventions | 95/100 | 15% | 14.25/15 |
| Code Structure | 68/100 | 15% | 10.2/15 |
| Test Coverage | 56/100 | 10% | 5.6/10 |
| Total | 100% | 68.45 → 68/100 |
Grade: 🟠 C (Obvious anti-patterns present — plan refactoring sprint)
What-If Analysis
Scenario A: Fix Domain Framework Dependency Only
Fix 1 P0 issue: javax.persistence.Entity removed from domain.
| Old | New | Improvement |
|---|---|---|
| Layering 70 → 21.0 | Layering 90 → 27.0 | +6.0 |
| Total: 68/100 | Total: 74/100 | Grade 🟡 B |
Scenario B: Fix P0 Issues Only
Fix domain framework dependency + add domain events.
| Old | New | Improvement |
|---|---|---|
| Layering 70 → 21.0 | Layering 90 → 27.0 | +6.0 |
| Domain Model 58 → 17.4 | Domain Model 76 → 22.8 | +5.4 |
| Total: 68/100 | Total: 79.4/100 | Grade 🟡 B |
Scenario C: Fix All Issues
Address all P0 + P1 issues.
| Old | New | Improvement |
|---|---|---|
| Layering 70 → 21.0 | Layering 100 → 30.0 | +9.0 |
| Domain Model 58 → 17.4 | Domain Model 85 → 25.5 | +8.1 |
| Naming 95 → 14.25 | Naming 100 → 15.0 | +0.75 |
| Structure 68 → 10.2 | Structure 85 → 12.75 | +2.55 |
| Test 56 → 5.6 | Test 80 → 8.0 | +2.4 |
| Total: 68/100 | Total: 91.25/100 | Grade 🟢 A |
ROI Estimation
| Scenario | Effort | Score Gain | ROI |
|---|---|---|---|
| A (1 P0 fix) | 0.5h | +6.0 | 12.0 pts/h |
| B (2 P0 fixes) | 3h | +11.4 | 3.8 pts/h |
| C (all issues) | 20h | +23.25 | 1.16 pts/h |
Recommendation: Start with Scenario B (max ROI). Plan Scenario C for next sprint.
ArchUnit Configuration for DDD Compliance
ArchUnit provides automated architecture testing for Java projects. Below are ready-to-use configurations for enforcing DDD layered architecture rules.
Maven Dependency
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>Gradle Dependency
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'Complete Compliance Test Suite
package com.example.architecture;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
@AnalyzeClasses(packages = "com.example")
public class DDDArchitectureComplianceTest {
// ==========================================
// P0 Rules — Must Pass (Block Merge)
// ==========================================
@ArchTest
static final ArchRule domain_must_not_depend_on_infrastructure =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..")
.because("Domain layer must not depend on infrastructure (DIP)");
@ArchTest
static final ArchRule domain_must_not_depend_on_spring =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"javax.persistence..",
"jakarta.persistence..",
"org.apache.ibatis.."
)
.because("Domain layer must have zero framework dependencies");
@ArchTest
static final ArchRule domain_must_not_use_jpa_annotations =
noClasses()
.that().resideInAPackage("..domain..")
.should().beAnnotatedWith(javax.persistence.Entity.class)
.orShould().beAnnotatedWith(jakarta.persistence.Entity.class)
.orShould().beAnnotatedWith(javax.persistence.Table.class)
.because("Domain entities must be pure POJOs, not JPA entities");
@ArchTest
static final ArchRule domain_must_not_depend_on_app =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..application..")
.because("Domain layer must not depend on Application layer");
// ==========================================
// P1 Rules — Should Pass
// ==========================================
@ArchTest
static final ArchRule repository_interfaces_in_domain =
classes()
.that().haveSimpleNameEndingWith("Repository")
.and().resideInAPackage("..infrastructure..")
.should().implement(
com.tngtech.archunit.core.domain.JavaClass.Predicates
.resideInAPackage("..domain..")
)
.because("Repository interfaces should be defined in Domain layer");
@ArchTest
static final ArchRule application_must_not_use_sql =
noClasses()
.that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"java.sql..",
"javax.sql..",
"org.mybatis..",
"org.springframework.jdbc.."
)
.because("Application layer must not directly access SQL/DB");
@ArchTest
static final ArchRule application_must_not_depend_on_adapter =
noClasses()
.that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAPackage("..adapter..")
.because("Application layer must not depend on Adapter layer");
@ArchTest
static final ArchRule controller_must_not_depend_on_repository =
noClasses()
.that().resideInAPackage("..adapter..controller..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..repository..")
.because("Controllers should not directly access repositories");
// ==========================================
// Naming Conventions — P2
// ==========================================
@ArchTest
static final ArchRule repositories_should_be_named_correctly =
classes()
.that().resideInAPackage("..domain..")
.and().haveSimpleNameEndingWith("Repository")
.should().haveSimpleNameStartingWith("I")
.orShould().haveSimpleNameContaining("Repository")
.because("Repository interfaces should follow naming conventions");
@ArchTest
static final ArchRule domain_events_should_be_past_tense =
classes()
.that().resideInAPackage("..domain..event..")
.should().haveSimpleNameEndingWith("ed")
.orShould().haveSimpleNameEndingWith("Event")
.because("Domain events should be named in past tense (e.g., OrderPaid)");
// ==========================================
// Cyclic Dependency Prevention — P0
// ==========================================
@ArchTest
static final ArchRule no_cycles_between_modules =
slices().matching("..(domain|application|infrastructure|adapter)..")
.should().beFreeOfCycles()
.because("Modules must not have circular dependencies");
// ==========================================
// Class Size Checks — P2
// ==========================================
@ArchTest
static final ArchRule aggregate_roots_should_not_be_too_large =
classes()
.that().resideInAPackage("..domain..entity..")
.should().containNumberOfLinesLessThan(300)
.because("Aggregate roots should be focused (< 300 lines)");
}CI/CD Integration
GitHub Actions
name: Architecture Compliance
on: [pull_request]
jobs:
arch-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
- name: Run Architecture Tests
run: mvn test -pl :arch-test -Dtest=DDDArchitectureComplianceTestMaven Profile
<profiles>
<profile>
<id>architecture-check</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes>
<include>**/*ArchitectureComplianceTest.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>Custom ArchUnit Extension: Domain Purity Checker
import com.tngtech.archunit.junit.ArchUnitExtension;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
public class DomainPurityChecker {
public static final ArchCondition<JavaClass> BE_PURE_DOMAIN_OBJECT =
new ArchCondition<>("be a pure domain object (no framework annotations)") {
@Override
public void check(JavaClass item, ConditionEvents events) {
boolean hasJpaAnnotation = item.isAnnotatedWith(javax.persistence.Entity.class)
|| item.isAnnotatedWith(jakarta.persistence.Entity.class)
|| item.isAnnotatedWith(javax.persistence.Table.class);
boolean hasSpringAnnotation = item.isAnnotatedWith(org.springframework.stereotype.Service.class)
|| item.isAnnotatedWith(org.springframework.stereotype.Component.class);
if (hasJpaAnnotation || hasSpringAnnotation) {
events.add(SimpleConditionEvent.violated(item,
item.getName() + " has framework annotation in domain layer"));
}
}
};
}Running Tests Individually
# Run all architecture tests
mvn test -Dtest=DDDArchitectureComplianceTest
# Run only P0 rules
mvn test -Dtest=DDDArchitectureComplianceTest#domain_must_not_depend_on_infrastructure
# Run with verbose output
mvn test -Dtest=DDDArchitectureComplianceTest -Darchunit.freeze.storeDir=target/freezeFreezing Violations (Temporary Exemptions)
@ArchTest
@ArchIgnore // Temporarily ignore until refactoring complete
static final ArchRule domain_must_not_depend_on_spring =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("org.springframework..");Use @ArchIgnore for known violations with a plan to fix. Track in your technical debt backlog.
DDD Code Review Checklist
Quick reference checklist for DDD code review sessions.
P0 — Must Fix (Block Merge)
Domain Purity
- [ ] Domain layer has ZERO Spring Framework imports
- [ ] Domain layer has ZERO JPA/Hibernate imports (
javax.persistence,jakarta.persistence) - [ ] Domain layer has ZERO MyBatis imports
- [ ] Domain layer has ZERO JDBC imports (
java.sql)
Dependency Direction
- [ ] Domain does not depend on Infrastructure
- [ ] Domain does not depend on Application
- [ ] Domain does not depend on Adapter
- [ ] No circular dependencies between modules
- [ ] Application layer does not depend on Adapter layer
Anti-Patterns
- [ ] No God Service (> 500 lines, all business logic in one class)
- [ ] No Anemic Model (Entity with only getters/setters, no behavior)
- [ ] No Cross-Aggregate Direct References (use ID references only)
- [ ] No business logic in Controller layer
- [ ] Repository returns Aggregate Root, NOT DTO
P1 — Should Fix (Before Next Release)
Domain Model Quality
- [ ] Core aggregates have rich behavior methods
- [ ] Value Objects used instead of primitive types (Money, Email, Phone)
- [ ] Value Objects are immutable (final fields, no setters)
- [ ] Aggregate boundaries are reasonable (≤ 5 entities per aggregate)
- [ ] Domain Services only for cross-entity operations within same aggregate
Naming Conventions
- [ ] Aggregate Root named after business concept (Order, not OrderEntity)
- [ ] Repository named
{Aggregate}Repository - [ ] Domain Service named
{BusinessConcept}Service(OrderPricingService) - [ ] Domain Events named in past tense (OrderPaid, not OrderPay)
- [ ] Package structure organized by aggregate, not by layer
Layer Responsibility
- [ ] Application Service only orchestrates (no business if/else)
- [ ] Application Service has NO SQL/Database access
- [ ] Adapter layer has NO business logic
- [ ] Infrastructure Repository correctly maps PO ↔ Domain
P2 — Nice to Have
Domain Events
- [ ] Key business actions publish domain events
- [ ] Domain events carry complete context (not just IDs)
- [ ] Event handlers are idempotent
Code Quality
- [ ] Aggregate Root class < 200 lines
- [ ] Service class < 100 lines
- [ ] Method cyclomatic complexity < 10
- [ ] No magic strings — use enums/constants
Testing
- [ ] Aggregate Root has unit tests for all behavior methods
- [ ] Domain Service has unit tests with mocked repositories
- [ ] Repository has integration test with real database
- [ ] Domain events are verified in aggregate tests
Common Fix Patterns
| Anti-Pattern | Fix |
|---|---|
order.setStatus("PAID") in Service | order.pay() in Aggregate Root |
OrderService.pay(orderId) 500 lines | Split into OrderPricingService, OrderFulfillmentService |
order.customer = customerObject | order.customerId = customer.getId() |
import org.springframework.stereotype.Service in Domain | Remove Spring dependency, use plain Java |
JdbcTemplate in AppService | Move to Repository implementation |
if(status == "DRAFT") in Controller | Move to Aggregate Root method |
Layer Structure - Complete Reference
Sources:
Primary:
- The Clean Architecture — Robert C. Martin
- Onion Architecture — Jeffrey Palermo
Implementation guide:
- Designing a DDD-oriented Microservice — Microsoft
Supplemental synthesis:
- Clean Architecture: Standing on the Shoulders of Giants — Herberto Graça
The Four Layers
| Layer | Responsibility | Dependencies |
|---|---|---|
| Domain | Business logic, entities, rules | None (pure) |
| Application | Use cases, orchestration | Domain |
| Infrastructure | External systems, frameworks | Application, Domain |
| Presentation | API/UI entry points | Application |
This reference uses a DDD-centered variant: aggregate repository interfaces live in the Domain layer, while use-case ports and application-owned outbound ports live in the Application layer. A stricter Hexagonal layout may put all driven ports under application/ports/driven/ instead. Both are acceptable when dependencies still point inward and infrastructure implements, rather than owns, the abstractions.
---
Domain Layer (Innermost)
The heart of the system. Contains business logic and rules with zero external dependencies.
Contents
domain/
├── order/ # Aggregate folder
│ ├── order.ts # Aggregate root entity
│ ├── order_item.ts # Child entity
│ ├── value_objects.ts # Money, Address, OrderStatus
│ ├── events.ts # OrderPlaced, OrderShipped
│ ├── repository.ts # IOrderRepository interface (DDD repository port)
│ ├── services.ts # PricingService, DiscountService
│ └── errors.ts # InsufficientStockError
├── customer/
│ └── ...
├── product/
│ └── ...
└── shared/
├── entity.ts # Base Entity class
├── aggregate_root.ts # Base AggregateRoot class
├── value_object.ts # Base ValueObject class
├── domain_event.ts # Base DomainEvent class
└── errors.ts # DomainError baseRules
1. No framework imports - No ORM decorators, no HTTP libraries 2. No infrastructure concerns - No database, no message queues 3. Pure business logic - Only language primitives and domain types 4. Rich behavior - Methods that enforce business rules
Example: Domain Entity
// domain/order/order.ts
import { AggregateRoot } from '../shared/aggregate_root';
import { OrderItem } from './order_item';
import { Money } from './value_objects';
import { OrderPlaced, OrderShipped } from './events';
import { InsufficientStockError } from './errors';
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(id: OrderId, customerId: CustomerId): Order {
const order = new Order(id, customerId);
order.addDomainEvent(new OrderPlaced(id, customerId));
return order;
}
addItem(product: Product, quantity: number): void {
if (quantity <= 0) {
throw new InvalidQuantityError(quantity);
}
if (!product.hasStock(quantity)) {
throw new InsufficientStockError(product.id, quantity);
}
const existingItem = this.items.find(i => i.productId.equals(product.id));
if (existingItem) {
existingItem.increaseQuantity(quantity);
} else {
this.items.push(OrderItem.create(product.id, product.price, quantity));
}
}
ship(): void {
if (this.status !== OrderStatus.Confirmed) {
throw new InvalidOrderStateError('Cannot ship unconfirmed order');
}
this.status = OrderStatus.Shipped;
this.addDomainEvent(new OrderShipped(this.id));
}
get total(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero()
);
}
}---
Application Layer
Orchestrates use cases by coordinating domain objects. Contains application-specific business rules.
Contents
application/
├── orders/
│ ├── place_order/
│ │ ├── command.ts # PlaceOrderCommand DTO
│ │ ├── handler.ts # PlaceOrderHandler
│ │ └── port.ts # IPlaceOrderUseCase interface
│ ├── ship_order/
│ │ └── ...
│ └── get_order/
│ ├── query.ts # GetOrderQuery DTO
│ ├── handler.ts # GetOrderHandler
│ └── result.ts # OrderDTO response
├── shared/
│ ├── unit_of_work.ts # IUnitOfWork interface
│ ├── event_publisher.ts # IEventPublisher interface
│ └── errors.ts # ApplicationError base
└── index.ts # Public API exportsRules
1. Depends only on Domain - No infrastructure imports 2. Defines application ports - Use-case interfaces and application-owned outbound dependencies 3. Orchestrates, doesn't implement - Calls domain methods 4. Transaction boundary - Manages unit of work
Example: Use Case Handler
// application/orders/place_order/handler.ts
import { Order } from '@/domain/order/order';
import { IOrderRepository } from '@/domain/order/repository';
import { IProductRepository } from '@/domain/product/repository';
import { IUnitOfWork } from '@/application/shared/unit_of_work';
import { IEventPublisher } from '@/application/shared/event_publisher';
import { PlaceOrderCommand } from './command';
import { OrderNotFoundError, ProductNotFoundError } from '@/application/shared/errors';
export interface IPlaceOrderUseCase {
execute(command: PlaceOrderCommand): Promise<OrderId>;
}
export class PlaceOrderHandler implements IPlaceOrderUseCase {
constructor(
private readonly orderRepo: IOrderRepository,
private readonly productRepo: IProductRepository,
private readonly uow: IUnitOfWork,
private readonly eventPublisher: IEventPublisher,
) {}
async execute(command: PlaceOrderCommand): Promise<OrderId> {
await this.uow.begin();
try {
const orderId = OrderId.generate();
const order = Order.create(orderId, command.customerId);
for (const item of command.items) {
const product = await this.productRepo.findById(item.productId);
if (!product) {
throw new ProductNotFoundError(item.productId);
}
order.addItem(product, item.quantity);
}
await this.orderRepo.save(order);
await this.uow.commit();
await this.eventPublisher.publishAll(order.domainEvents);
return orderId;
} catch (error) {
await this.uow.rollback();
throw error;
}
}
}Command/Query DTOs
// application/orders/place_order/command.ts
export interface PlaceOrderCommand {
customerId: string;
items: Array<{
productId: string;
quantity: number;
}>;
}
// application/orders/get_order/query.ts
export interface GetOrderQuery {
orderId: string;
}
// application/orders/get_order/result.ts
export interface OrderDTO {
id: string;
customerId: string;
status: string;
items: Array<{
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}>;
total: number;
createdAt: string;
}---
Infrastructure Layer
Implements interfaces defined in Domain and Application layers. Contains all external concerns.
Contents
infrastructure/
├── persistence/
│ ├── postgres/
│ │ ├── order_repository.ts # PostgresOrderRepository
│ │ ├── product_repository.ts
│ │ ├── unit_of_work.ts # PostgresUnitOfWork
│ │ ├── migrations/
│ │ └── mappers/
│ │ └── order_mapper.ts # Domain <-> DB mapping
│ └── in_memory/
│ ├── order_repository.ts # InMemoryOrderRepository (tests)
│ └── unit_of_work.ts
├── messaging/
│ ├── rabbitmq/
│ │ └── event_publisher.ts # RabbitMQEventPublisher
│ └── in_memory/
│ └── event_publisher.ts # InMemoryEventPublisher (tests)
├── external/
│ ├── payment/
│ │ └── stripe_gateway.ts # StripePaymentGateway
│ └── shipping/
│ └── fedex_service.ts # FedExShippingService
├── http/
│ ├── rest/
│ │ ├── controllers/
│ │ │ └── order_controller.ts # REST API adapter
│ │ ├── middleware/
│ │ └── routes.ts
│ └── graphql/
│ └── resolvers/
├── grpc/
│ └── order_service.ts # gRPC adapter
└── config/
├── container.ts # DI container setup
└── env.ts # Environment configRules
1. Implements ports - Concrete classes for domain/application interfaces 2. Contains framework code - ORM, HTTP frameworks, etc. 3. Maps between layers - Domain ↔ Database/DTO mapping 4. Easily replaceable - Can swap Postgres for MongoDB
Example: Repository Implementation
class PostgresOrderRepository implements IOrderRepository:
db: Database
findById(id: OrderId) -> Order | null:
row = db.orders
.where(id: id.value)
.withRelated("items")
.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()---
Presentation Layer
Entry points to the application. Adapts external requests to application commands/queries.
Contents
presentation/
├── rest/
│ ├── controllers/
│ │ ├── order_controller.ts
│ │ └── product_controller.ts
│ ├── middleware/
│ │ ├── auth.ts
│ │ ├── error_handler.ts
│ │ └── validation.ts
│ ├── dto/
│ │ ├── requests/
│ │ └── responses/
│ └── routes.ts
├── grpc/
│ └── ...
├── graphql/
│ └── ...
└── cli/
└── ...Example: REST Controller
// presentation/rest/controllers/order_controller.ts
import { Request, Response, NextFunction } from 'express';
import { IPlaceOrderUseCase } from '@/application/orders/place_order/port';
import { IGetOrderUseCase } from '@/application/orders/get_order/port';
import { PlaceOrderRequest } from '../dto/requests/place_order_request';
export class OrderController {
constructor(
private readonly placeOrder: IPlaceOrderUseCase,
private readonly getOrder: IGetOrderUseCase,
) {}
async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const request = req.body as PlaceOrderRequest;
const orderId = await this.placeOrder.execute({
customerId: req.user.id,
items: request.items.map(item => ({
productId: item.product_id,
quantity: item.quantity,
})),
});
res.status(201).json({ id: orderId.value });
} catch (error) {
next(error);
}
}
async show(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const order = await this.getOrder.execute({ orderId: req.params.id });
if (!order) {
res.status(404).json({ error: 'Order not found' });
return;
}
res.json(order);
} catch (error) {
next(error);
}
}
}---
Dependency Flow
flowchart TB
subgraph Presentation["Presentation"]
REST["REST Controller"]
end
subgraph Application["Application"]
Handler["PlaceOrderHandler"]
Port1["IPlaceOrderUseCase (port)"]
Port2["IOrderRepository"]
Handler -.->|implements| Port1
Handler -->|uses| Port2
end
subgraph Domain["Domain"]
Aggregate["Order (Aggregate Root)"]
RepoInterface["IOrderRepository (interface)"]
end
subgraph Infrastructure["Infrastructure"]
PgRepo["PostgresOrderRepository"]
RabbitMQ["RabbitMQEventPublisher"]
PgRepo -.->|implements| RepoInterface
RabbitMQ -.->|implements| EventPub["IEventPublisher"]
end
REST -->|calls| Handler
Application -->|defines interfaces| Domain
Infrastructure -->|implements| Domain
style Presentation fill:#f59e0b,stroke:#d97706,color:white
style Application fill:#3b82f6,stroke:#2563eb,color:white
style Domain fill:#10b981,stroke:#059669,color:white
style Infrastructure fill:#6366f1,stroke:#4f46e5,color:white---
Composition Root
All dependencies are wired together at the application entry point.
import { Pool } from 'pg';
import { Container } from 'inversify';
import { IOrderRepository } from '@/domain/order/repository';
import { IProductRepository } from '@/domain/product/repository';
import { IPlaceOrderUseCase } from '@/application/orders/place_order/port';
import { IUnitOfWork } from '@/application/shared/unit_of_work';
import { IEventPublisher } from '@/application/shared/event_publisher';
import { PlaceOrderHandler } from '@/application/orders/place_order/handler';
import { PostgresOrderRepository } from '@/infrastructure/persistence/postgres/order_repository';
import { PostgresProductRepository } from '@/infrastructure/persistence/postgres/product_repository';
import { PostgresUnitOfWork } from '@/infrastructure/persistence/postgres/unit_of_work';
import { RabbitMQEventPublisher } from '@/infrastructure/messaging/rabbitmq/event_publisher';
import { OrderController } from '@/presentation/rest/controllers/order_controller';
export function configureContainer(): Container {
const container = new Container();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
container.bind<Pool>('Pool').toConstantValue(pool);
container.bind<IOrderRepository>('IOrderRepository').to(PostgresOrderRepository);
container.bind<IProductRepository>('IProductRepository').to(PostgresProductRepository);
container.bind<IUnitOfWork>('IUnitOfWork').to(PostgresUnitOfWork);
container.bind<IEventPublisher>('IEventPublisher').to(RabbitMQEventPublisher);
container.bind<IPlaceOrderUseCase>('IPlaceOrderUseCase').to(PlaceOrderHandler);
container.bind<OrderController>(OrderController).toSelf();
return container;
}---
Language-Agnostic Structure
The same layered structure applies to any language:
Go
internal/
├── domain/
├── application/
├── infrastructure/
└── interfaces/ # PresentationRust
src/
├── domain/
├── application/
├── infrastructure/
└── presentation/Python
src/
├── domain/
├── application/
├── infrastructure/
└── presentation/The key is dependency direction: outer layers import inner layers, never the reverse.
DDD Tactical Patterns
Sources:
- Domain-Driven Design: The Blue Book — Eric Evans (2003)
- Implementing Domain-Driven Design — Vaughn Vernon (2013)
- Effective Aggregate Design — Vaughn Vernon
- Repository Pattern — Martin Fowler (PoEAA)
Building Blocks Overview
flowchart TB
subgraph Aggregate["Aggregate"]
subgraph AggRoot["Aggregate Root (Entity)"]
E1["Entity"]
E2["Entity"]
VO1["Value Object"]
VO2["Value Object"]
DE["Domain Event"]
end
end
Aggregate -->|Repository| Persistence[("Persistence")]
style Aggregate fill:#3b82f6,stroke:#2563eb,color:white
style AggRoot fill:#10b981,stroke:#059669,color:white
style Persistence fill:#6b7280,stroke:#4b5563,color:white---
Entity
An object with identity that persists through time. Two entities are equal if they have the same identity, regardless of attribute values.
Characteristics
- Has a unique identifier
- Identity persists through lifecycle
- Can change attributes but remains the same entity
- Contains behavior (not just data)
Pattern
abstract class Entity<ID>:
id: ID
equals(other: Entity<ID>) -> bool:
return this.id == other.id
class OrderItem extends Entity<OrderItemId>:
productId: ProductId
quantity: Quantity
unitPrice: Money
static create(productId, quantity, unitPrice) -> OrderItem:
return new OrderItem(
id: OrderItemId.generate(),
productId: productId,
quantity: quantity,
unitPrice: unitPrice
)
increaseQuantity(amount: int):
this.quantity = this.quantity.add(amount)
subtotal() -> Money:
return this.unitPrice.multiply(this.quantity.value)---
Value Object
An object defined by its attributes, not identity. Two value objects are equal if all their attributes are equal.
Characteristics
- Immutable (no setters)
- No identity
- Equality by value (all attributes)
- Self-validating
- Side-effect-free methods
Common Value Objects
| Value Object | Attributes | Validation |
|---|---|---|
| Money | amount, currency | amount >= 0 |
| address | valid email format | |
| Address | street, city, zip, country | required fields |
| DateRange | start, end | start <= end |
| Quantity | value | value > 0 |
Pattern
abstract class ValueObject<Props>:
props: Props
equals(other: ValueObject<Props>) -> bool:
return deepEqual(this.props, other.props)
class Money extends ValueObject<{amount, currency}>:
static create(amount, currency) -> Money:
guard: amount >= 0
guard: currency in SUPPORTED_CURRENCIES
return new Money({amount, currency})
static zero(currency = "USD") -> Money:
return Money.create(0, currency)
add(other: Money) -> Money:
guard: this.currency == other.currency
return Money.create(this.amount + other.amount, this.currency)
subtract(other: Money) -> Money:
guard: this.currency == other.currency
return Money.create(this.amount - other.amount, this.currency)
multiply(factor: number) -> Money:
return Money.create(this.amount * factor, this.currency)
class Email extends ValueObject<{value}>:
static create(email: string) -> Email:
normalized = email.lowercase().trim()
guard: isValidEmailFormat(normalized)
return new Email({value: normalized})
domain() -> string:
return this.value.split("@")[1]
class OrderId extends ValueObject<{value}>:
static generate() -> OrderId:
return new OrderId({value: generateUUID()})
static from(value: string) -> OrderId:
guard: value is not empty
return new OrderId({value})---
Aggregate
A cluster of entities and value objects treated as a single unit for data changes. Has a consistency boundary.
Rules
1. One aggregate root - Single entry point for all modifications 2. Reference by ID only - Aggregates reference others by identity, never by direct object reference 3. Transaction boundary - One aggregate per transaction (eventual consistency between aggregates) 4. Invariants within boundary - Aggregate ensures its own consistency 5. Small aggregates - Prefer smaller over larger
Aggregate Sizing Heuristics
| Metric | Healthy | Warning | Action |
|---|---|---|---|
| Entities per aggregate | 1-5 | 6-10 | >10: Split |
| Lines of code (root) | <500 | 500-1000 | >1000: Split |
| Transaction lock time | <100ms | 100-500ms | >500ms: Split |
| Concurrent modification conflicts | Rare | Occasional | Frequent: Split |
Questions to ask:
- Can parts be eventually consistent? → Separate aggregates
- Do all parts change together? → Same aggregate
- Are there independent lifecycles? → Separate aggregates
Design Guidelines
Good: Small Aggregates
flowchart LR
subgraph Order["Order Aggregate"]
O["Order"]
OI["OrderItems (embedded)"]
end
subgraph Customer["Customer Aggregate"]
C["Customer (standalone)"]
end
subgraph Product["Product Aggregate"]
P["Product (standalone)"]
end
Order -.->|customerId| Customer
Order -.->|productId| Product
style Order fill:#10b981,stroke:#059669,color:white
style Customer fill:#3b82f6,stroke:#2563eb,color:white
style Product fill:#3b82f6,stroke:#2563eb,color:whiteReference by ID only
Bad: God Aggregate
flowchart TB
subgraph GodOrder["Order (God Aggregate)"]
O2["Order"]
C2["Customer (embedded)"]
P2["Products (embedded)"]
SA["ShippingAddress (embedded)"]
end
style GodOrder fill:#ef4444,stroke:#dc2626,color:whiteToo large, too many reasons to change, contention issues
Pattern
abstract class AggregateRoot<ID> extends Entity<ID>:
domainEvents: List<DomainEvent> = []
version: int = 0
addDomainEvent(event: DomainEvent):
this.domainEvents.append(event)
clearDomainEvents():
this.domainEvents = []
class Order extends AggregateRoot<OrderId>:
customerId: CustomerId
items: List<OrderItem> = []
status: OrderStatus
shippingAddress: Address | null
createdAt: DateTime
static create(customerId: CustomerId) -> Order:
order = new Order(
id: OrderId.generate(),
customerId: customerId,
status: DRAFT,
createdAt: now()
)
order.addDomainEvent(OrderCreated{orderId, customerId})
return order
static reconstitute(id, customerId, items, status, ...) -> Order:
order = new Order(...)
return order
addItem(productId, quantity, unitPrice):
guard: status != CANCELLED
guard: status != SHIPPED
guard: quantity > 0
existingItem = this.items.find(i => i.productId == productId)
if existingItem:
existingItem.increaseQuantity(quantity)
else:
this.items.append(OrderItem.create(productId, quantity, unitPrice))
this.addDomainEvent(OrderItemAdded{orderId, productId, quantity})
removeItem(productId):
guard: status != CANCELLED
guard: status != SHIPPED
guard: item exists
this.items.remove(productId)
this.addDomainEvent(OrderItemRemoved{orderId, productId})
confirm():
guard: status == DRAFT
guard: items.length > 0
guard: shippingAddress != null
this.status = CONFIRMED
this.addDomainEvent(OrderConfirmed{orderId, total})
ship(trackingNumber):
guard: status == CONFIRMED
this.status = SHIPPED
this.addDomainEvent(OrderShipped{orderId, trackingNumber})
cancel(reason: string):
guard: status not in [SHIPPED, DELIVERED]
this.status = CANCELLED
this.addDomainEvent(OrderCancelled{orderId, reason})
total() -> Money:
return this.items.reduce((sum, item) => sum.add(item.subtotal()), Money.zero())
itemCount() -> int:
return this.items.reduce((sum, item) => sum + item.quantity.value, 0)---
Repository
Provides collection-like access to aggregates. Abstracts persistence.
Rules
1. One repository per aggregate - Not per entity or table 2. Domain interface - Interface in domain, implementation in infrastructure 3. Aggregate-focused - Save/load entire aggregates 4. No query logic - Complex queries belong in separate read models
Pattern
interface OrderRepository:
findById(id: OrderId) -> Order | null
findByCustomerId(customerId: CustomerId) -> List<Order>
save(order: Order)
delete(order: Order)
nextId() -> OrderId
interface Repository<T extends AggregateRoot<ID>, ID>:
findById(id: ID) -> T | null
save(aggregate: T)
delete(aggregate: T)Common Mistakes
Wrong: Repository per entity
interface OrderItemRepository:
findByOrderId(orderId) -> List<OrderItem>
save(item: OrderItem)Wrong: Query methods in repository
interface OrderRepository:
findByStatus(status) -> List<Order>
findByDateRange(start, end)
countByCustomer(customerId)Correct: Aggregate-focused + separate read model
interface OrderRepository:
findById(id: OrderId) -> Order | null
save(order: Order)
interface OrderReadModel:
findByStatus(status) -> List<OrderSummaryDTO>
findByDateRange(start, end) -> List<OrderSummaryDTO>
countByCustomer(customerId) -> int---
Domain Event
Records something significant that happened in the domain.
Characteristics
- Immutable
- Past tense naming (
OrderPlaced, notPlaceOrder) - Contains data needed by consumers
- Timestamp when it occurred
Pattern
abstract class DomainEvent:
eventId: string = generateUUID()
occurredAt: DateTime = now()
abstract eventType: string
abstract toPayload() -> Map
class OrderCreated extends DomainEvent:
eventType = "order.created"
orderId: OrderId
customerId: CustomerId
toPayload():
return {orderId: orderId.value, customerId: customerId.value}
class OrderConfirmed extends DomainEvent:
eventType = "order.confirmed"
orderId: OrderId
total: Money
toPayload():
return {orderId: orderId.value, total: {amount, currency}}
class OrderShipped extends DomainEvent:
eventType = "order.shipped"
orderId: OrderId
trackingNumber: TrackingNumber---
Domain Service
Stateless operations that don't naturally fit within an entity or value object.
When to Use
- Operation involves multiple aggregates
- Operation requires external information
- Significant business logic that doesn't belong to one entity
Pattern
interface PricingService:
calculateDiscount(order: Order, customer: Customer) -> Money
class PricingServiceImpl implements PricingService:
calculateDiscount(order, customer) -> Money:
discount = Money.zero()
if order.itemCount() > 10:
discount = discount.add(order.total().multiply(0.05))
if customer.isVIP:
discount = discount.add(order.total().multiply(0.10))
maxDiscount = order.total().multiply(0.20)
return min(discount, maxDiscount)
interface ShippingCostCalculator:
calculate(items: List<OrderItem>, destination: Address) -> Money
class ShippingCostCalculatorImpl implements ShippingCostCalculator:
calculate(items, destination) -> Money:
baseRate = Money.create(5.99, "USD")
perItemRate = Money.create(1.50, "USD")
total = baseRate.add(perItemRate.multiply(items.length))
if destination.country != "US":
total = total.add(Money.create(15.00, "USD"))
return total---
Factory
Encapsulates complex aggregate/entity creation.
When to Use
- Creation logic is complex
- Need to enforce invariants during creation
- Need to create object graphs
Pattern
interface OrderFactory:
createFromCart(cart: Cart, customer: Customer) -> Order
class OrderFactoryImpl implements OrderFactory:
pricingService: PricingService
createFromCart(cart, customer) -> Order:
guard: not cart.isEmpty
order = Order.create(customer.id)
for cartItem in cart.items:
order.addItem(
cartItem.productId,
Quantity.create(cartItem.quantity),
cartItem.unitPrice
)
if customer.defaultAddress:
order.setShippingAddress(customer.defaultAddress)
return order---
Specification Pattern
Encapsulates business rules for querying or validation.
interface Specification<T>:
isSatisfiedBy(candidate: T) -> bool
and(other: Specification<T>) -> Specification<T>
or(other: Specification<T>) -> Specification<T>
not() -> Specification<T>
class OrderIsShippable extends Specification<Order>:
isSatisfiedBy(order: Order) -> bool:
return order.status == CONFIRMED and order.items.length > 0
class CustomerHasGoodStanding extends Specification<Customer>:
isSatisfiedBy(customer: Customer) -> bool:
return customer.paymentHistory.all(p => p.status == COMPLETED)
class CompositeSpecification<T> extends Specification<T>:
rules: List<Specification<T>>
isSatisfiedBy(candidate: T) -> bool:
return this.rules.all(r => r.isSatisfiedBy(candidate))Testing Patterns
Sources:
- The Clean Architecture — Robert C. Martin
- Hexagonal Architecture — Alistair Cockburn
- Unit Testing — Martin Fowler
- Test Pyramid — Martin Fowler
Testing strategies for Clean Architecture + DDD + Hexagonal systems.
Testing Pyramid
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px'}}}%%
flowchart TB
subgraph Pyramid["Testing Pyramid"]
E2E["E2E Tests\nFew, slow, expensive"]
Integration["Integration Tests\nSome, moderate speed"]
Unit["Unit Tests (Domain & Application)\nMany, fast, cheap"]
end
E2E --- Integration
Integration --- Unit
style E2E fill:#ef4444,stroke:#dc2626,color:white
style Integration fill:#f59e0b,stroke:#d97706,color:white
style Unit fill:#10b981,stroke:#059669,color:white---
Unit Tests
Domain Layer Tests
Test business logic in isolation. No mocks needed—domain has no dependencies.
// tests/domain/order/order.test.ts
describe('Order', () => {
describe('create', () => {
it('creates order with draft status', () => {
const customerId = CustomerId.from('cust-123');
const order = Order.create(customerId);
expect(order.status).toBe(OrderStatus.Draft);
expect(order.customerId).toEqual(customerId);
expect(order.items).toHaveLength(0);
});
it('emits OrderCreated event', () => {
const customerId = CustomerId.from('cust-123');
const order = Order.create(customerId);
expect(order.domainEvents).toHaveLength(1);
expect(order.domainEvents[0]).toBeInstanceOf(OrderCreated);
});
});
describe('addItem', () => {
it('adds item to order', () => {
const order = createDraftOrder();
const productId = ProductId.from('prod-123');
const quantity = Quantity.create(2);
const price = Money.create(10.00, 'USD');
order.addItem(productId, quantity, price);
expect(order.items).toHaveLength(1);
expect(order.items[0].productId).toEqual(productId);
expect(order.items[0].quantity).toEqual(quantity);
});
it('increases quantity for existing product', () => {
const order = createDraftOrder();
const productId = ProductId.from('prod-123');
const price = Money.create(10.00, 'USD');
order.addItem(productId, Quantity.create(2), price);
order.addItem(productId, Quantity.create(3), price);
expect(order.items).toHaveLength(1);
expect(order.items[0].quantity.value).toBe(5);
});
it('throws when order is cancelled', () => {
const order = createCancelledOrder();
expect(() => {
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
}).toThrow(InvalidOrderStateError);
});
it('throws when quantity is zero', () => {
const order = createDraftOrder();
expect(() => {
order.addItem(ProductId.from('prod-123'), Quantity.create(0), Money.create(10, 'USD'));
}).toThrow(InvalidQuantityError);
});
});
describe('confirm', () => {
it('changes status to confirmed', () => {
const order = createOrderWithItems();
order.confirm();
expect(order.status).toBe(OrderStatus.Confirmed);
});
it('emits OrderConfirmed event', () => {
const order = createOrderWithItems();
order.confirm();
const events = order.domainEvents.filter(e => e instanceof OrderConfirmed);
expect(events).toHaveLength(1);
});
it('throws when order is empty', () => {
const order = createDraftOrder();
expect(() => order.confirm()).toThrow(EmptyOrderError);
});
it('throws when already confirmed', () => {
const order = createConfirmedOrder();
expect(() => order.confirm()).toThrow(InvalidOrderStateError);
});
});
describe('total', () => {
it('calculates total from all items', () => {
const order = createDraftOrder();
order.addItem(ProductId.from('p1'), Quantity.create(2), Money.create(10, 'USD'));
order.addItem(ProductId.from('p2'), Quantity.create(1), Money.create(25, 'USD'));
expect(order.total.amount).toBe(45); // 2*10 + 1*25
});
it('returns zero for empty order', () => {
const order = createDraftOrder();
expect(order.total.amount).toBe(0);
});
});
});
// Test helpers (builders)
function createDraftOrder(): Order {
return Order.create(CustomerId.from('cust-123'));
}
function createOrderWithItems(): Order {
const order = createDraftOrder();
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
return order;
}
function createConfirmedOrder(): Order {
const order = createOrderWithItems();
order.setShippingAddress(createTestAddress());
order.confirm();
return order;
}
function createCancelledOrder(): Order {
const order = createOrderWithItems();
order.cancel('Test cancellation');
return order;
}Value Object Tests
// tests/domain/shared/money.test.ts
describe('Money', () => {
describe('create', () => {
it('creates money with valid amount', () => {
const money = Money.create(10.50, 'USD');
expect(money.amount).toBe(10.50);
expect(money.currency).toBe('USD');
});
it('throws for negative amount', () => {
expect(() => Money.create(-1, 'USD')).toThrow(InvalidMoneyError);
});
});
describe('add', () => {
it('adds two money values with same currency', () => {
const a = Money.create(10, 'USD');
const b = Money.create(20, 'USD');
const result = a.add(b);
expect(result.amount).toBe(30);
expect(result.currency).toBe('USD');
});
it('throws for different currencies', () => {
const usd = Money.create(10, 'USD');
const eur = Money.create(10, 'EUR');
expect(() => usd.add(eur)).toThrow(CurrencyMismatchError);
});
});
describe('equality', () => {
it('equals money with same amount and currency', () => {
const a = Money.create(10, 'USD');
const b = Money.create(10, 'USD');
expect(a.equals(b)).toBe(true);
});
it('not equal with different amount', () => {
const a = Money.create(10, 'USD');
const b = Money.create(20, 'USD');
expect(a.equals(b)).toBe(false);
});
});
});Application Layer Tests
Test use cases with mocked ports.
// tests/application/place_order/handler.test.ts
describe('PlaceOrderHandler', () => {
let handler: PlaceOrderHandler;
let orderRepo: MockOrderRepository;
let productRepo: MockProductRepository;
let eventPublisher: MockEventPublisher;
beforeEach(() => {
orderRepo = new MockOrderRepository();
productRepo = new MockProductRepository();
eventPublisher = new MockEventPublisher();
handler = new PlaceOrderHandler(orderRepo, productRepo, eventPublisher);
});
it('creates order with items and saves', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
productRepo.addProduct(createTestProduct('prod-2', 20.00));
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [
{ productId: 'prod-1', quantity: 2 },
{ productId: 'prod-2', quantity: 1 },
],
};
const orderId = await handler.handle(command);
expect(orderId).toBeDefined();
const savedOrder = await orderRepo.findById(OrderId.from(orderId));
expect(savedOrder).not.toBeNull();
expect(savedOrder!.items).toHaveLength(2);
expect(savedOrder!.total.amount).toBe(40); // 2*10 + 1*20
});
it('publishes domain events', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
};
await handler.handle(command);
expect(eventPublisher.publishedEvents).toHaveLength(1);
expect(eventPublisher.publishedEvents[0]).toBeInstanceOf(OrderCreated);
});
it('throws when product not found', async () => {
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'nonexistent', quantity: 1 }],
};
await expect(handler.handle(command)).rejects.toThrow(ProductNotFoundError);
});
it('rolls back on error', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
orderRepo.simulateErrorOnSave();
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
};
await expect(handler.handle(command)).rejects.toThrow();
expect(orderRepo.savedOrders).toHaveLength(0);
});
});
// Mock implementations
class MockOrderRepository implements IOrderRepository {
savedOrders: Order[] = [];
private shouldError = false;
async findById(id: OrderId): Promise<Order | null> {
return this.savedOrders.find(o => o.id.equals(id)) ?? null;
}
async save(order: Order): Promise<void> {
if (this.shouldError) {
throw new Error('Simulated save error');
}
this.savedOrders.push(order);
}
async delete(order: Order): Promise<void> {
const index = this.savedOrders.findIndex(o => o.id.equals(order.id));
if (index >= 0) {
this.savedOrders.splice(index, 1);
}
}
simulateErrorOnSave(): void {
this.shouldError = true;
}
}
class MockEventPublisher implements IEventPublisher {
publishedEvents: DomainEvent[] = [];
async publish(event: DomainEvent): Promise<void> {
this.publishedEvents.push(event);
}
async publishAll(events: DomainEvent[]): Promise<void> {
this.publishedEvents.push(...events);
}
}---
Integration Tests
Test adapters with real infrastructure (databases, message brokers).
// tests/integration/postgres/order_repository.test.ts
describe('PostgresOrderRepository', () => {
let pool: Pool;
let repository: PostgresOrderRepository;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
repository = new PostgresOrderRepository(pool);
});
beforeEach(async () => {
await pool.query('TRUNCATE orders, order_items CASCADE');
});
afterAll(async () => {
await pool.end();
});
describe('save and findById', () => {
it('persists and retrieves order', async () => {
const order = Order.create(CustomerId.from('cust-123'));
order.addItem(ProductId.from('prod-1'), Quantity.create(2), Money.create(10, 'USD'));
await repository.save(order);
const retrieved = await repository.findById(order.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.id.value).toBe(order.id.value);
expect(retrieved!.items).toHaveLength(1);
expect(retrieved!.items[0].quantity.value).toBe(2);
});
it('updates existing order', async () => {
const order = Order.create(CustomerId.from('cust-123'));
order.addItem(ProductId.from('prod-1'), Quantity.create(1), Money.create(10, 'USD'));
await repository.save(order);
order.addItem(ProductId.from('prod-2'), Quantity.create(3), Money.create(20, 'USD'));
await repository.save(order);
const retrieved = await repository.findById(order.id);
expect(retrieved!.items).toHaveLength(2);
});
it('returns null for nonexistent order', async () => {
const result = await repository.findById(OrderId.from('nonexistent'));
expect(result).toBeNull();
});
});
describe('delete', () => {
it('removes order from database', async () => {
const order = Order.create(CustomerId.from('cust-123'));
await repository.save(order);
await repository.delete(order);
const retrieved = await repository.findById(order.id);
expect(retrieved).toBeNull();
});
});
});API Integration Tests
// tests/integration/http/orders_api.test.ts
describe('Orders API', () => {
let app: Express;
let pool: Pool;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
app = createApp(pool);
});
beforeEach(async () => {
await db.truncate("orders", "order_items", "products");
await db.products.insertMany([
{ id: "prod-1", name: "Product 1", price: 1000 },
{ id: "prod-2", name: "Product 2", price: 2000 }
]);
});
afterAll(async () => {
await pool.end();
});
describe('POST /orders', () => {
it('creates order and returns 201', async () => {
const response = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [
{ product_id: 'prod-1', quantity: 2 },
{ product_id: 'prod-2', quantity: 1 },
],
});
expect(response.status).toBe(201);
expect(response.body.id).toBeDefined();
});
it('returns 400 for invalid product', async () => {
const response = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [{ product_id: 'nonexistent', quantity: 1 }],
});
expect(response.status).toBe(400);
expect(response.body.error).toContain('Product not found');
});
});
describe('GET /orders/:id', () => {
it('returns order details', async () => {
const createResponse = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [{ product_id: 'prod-1', quantity: 2 }],
});
const orderId = createResponse.body.id;
const response = await request(app).get(`/orders/${orderId}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(orderId);
expect(response.body.items).toHaveLength(1);
});
it('returns 404 for nonexistent order', async () => {
const response = await request(app).get('/orders/nonexistent');
expect(response.status).toBe(404);
});
});
});---
Architecture Tests
Verify dependency rules and naming conventions.
// tests/architecture/dependency_rules.test.ts
import { describe, it } from 'vitest';
describe('Dependency Rules', () => {
it('domain layer has no external dependencies', () => {
// Verify domain/ imports only shared kernel types
const disallowedImports = [
'@nestjs', 'typeorm', 'express', 'axios',
'knex', 'prisma', 'sequelize', 'mongoose'
];
for (const pkg of disallowedImports) {
expect(scanImports('src/domain/**/*.ts')).not.toContain(pkg);
}
});
it('application layer does not import infrastructure', () => {
expect(scanImports('src/application/**/*.ts'))
.not.toMatch(/infrastructure/);
});
});---
Test Naming Convention
describe('[Aggregate/Component]', () => {
describe('[Method/Behavior]', () => {
it('[expected behavior] when [condition]', () => { ... });
});
});
// Examples
describe('Order', () => {
describe('confirm', () => {
it('changes status to confirmed when order has items and address');
it('throws EmptyOrderError when order has no items');
it('throws InvalidOrderStateError when already confirmed');
});
});---
Key Principles
1. Domain tests need no mocks — Domain code is pure, inject dependencies 2. Test business invariants, not CRUD — Don't test getters/setters 3. Mock ports, not implementations — Mock the interface, not the adapter 4. Integration tests for adapters — Verify repository, gateway, API behavior 5. One aggregate per test transaction — Test complete lifecycle 6. Test domain events — Verify events are emitted at correct times 7. Architecture tests in CI — Enforce dependency rules automatically
DDD Code Review Gotchas — 常见审查陷阱与检测方法
1. 漏检跨聚合引用陷阱
问题:只检查了直接字段引用(Order.customer),忽略了方法返回类型中的跨聚合情况。
检测命令:
# 检查方法返回类型中是否直接返回了其他聚合的对象
grep -rn "Customer " --include="*.java" domain/ # 检查是否有 Customer 类型出现在 domain 层
grep -rn "import.*domain\..*\.model\." --include="*.java" domain/ # 检查 domain 内跨包引用
# 检查 Repository 是否返回非自有聚合
grep -rn "interface.*Repository" --include="*.java" domain/ | while read line; do
return_type=$(echo "$line" | grep -oP '(?<=findBy\w+\()\w+')
echo "Check: $line -> returns $return_type"
done区分标记:
| 检查项 | 合法 | 非法 |
|---|---|---|
order.customerId: CustomerId | ✓ | |
order.customer: Customer | ✗ | |
orderRepository.findById(id) 返回 Optional<Order> | ✓ | |
userService.getUser(id) 返回 User 被 Order 聚合持有 | ✗ | |
Order.create(customerId) — 只传 ID | ✓ | |
OrderItem.productId | ✓ |
---
2. PO/DTO 混用
问题:Repository 返回 JPA Entity 或 DTO,Domain 层收到贫血对象。
检测命令:
# 检查 Repository 返回类型是否为 Domain 对象(非 JPA Entity)
grep -rn "import javax.persistence\|import jakarta.persistence" --include="*.java" domain/ | head -20
# 检查 Application Service 中是否有 JPA 查询
grep -rn "JdbcTemplate\|EntityManager\|@Query" --include="*.java" application/
# 检查 Infrastructure Repository 实现是否返回 Domain 对象
grep -rn "return.*Entity\|return.*DTO\|return.*PO" --include="*RepositoryImpl*.java" infrastructure/---
3. 测试反模式被忽略
问题:Mock 了不该 Mock 的,没 Mock 该 Mock 的。
检测方法:
# 检查测试中是否 Mock 了 Domain Service(应该 Mock Repository,不是其他 Domain Service)
grep -rn "@Mock.*Service\|mock(.*Service\.class)" --include="*Test*.java" domain/
# 检查测试类是否有真实的数据库测试
grep -rn "@DataJpaTest\|@SpringBootTest\|@MybatisTest" --include="*Test*.java" infrastructure/
# 检查 Domain 层测试是否引用了 Spring
grep -rn "@SpringBootTest\|@Autowired\|@MockBean" --include="*Test*.java" domain/ | head -10测试 Mock 决策表:
| 层 | 应该真实调用 | Mock |
|---|---|---|
| Domain 单元测试 | 聚合根、Domain Service | Repository 接口 |
| Application Service 测试 | 编排逻辑 | Repository, Domain Service |
| Infrastructure 集成测试 | Repository 实现 + 真实 DB | 无(不 Mock DB) |
| Adapter 测试 | Controller + HTTP | Application Service |
---
4. 把编排厚度当成 God Service
问题:Service > 500 行 不一定都有问题。需区分"编排多" vs "业务逻辑泄露"。
检测命令:
# 量化:统计 Service 中 if/else 分支数(业务逻辑指标)
grep -c "if\|else if\|switch" application/service/*Service.java
# 区分编排 vs 业务逻辑:
# 编排特征:serviceA.doX() → serviceB.doY() → save()
# 业务逻辑:if (order.status == PAID && amount > 100) { applyDiscount() }
# → if/switch 中直接判断领域状态的,是业务逻辑泄露判断表:
| Service 类型 | 行数 | 内容 | 是否反模式 |
|---|---|---|---|
| 编排 Service | 600 | 全是 serviceA.doSth() + repo.save() | 否(厚度) |
| 编排 Service | 600 | 50% if (order.getXxx()) 判断 | 是(毒性) |
| God Service | 800 | 全部业务逻辑在一个类 | 是 |
---
5. 分层检查只查 import
问题:只检查了 import,忽略了方法参数和调用链。
检测命令:
# 检查 Domain 方法参数是否包含框架类型
grep -rn "HttpServletRequest\|HttpServletResponse\|@RequestParam\|@PathVariable" --include="*.java" domain/
# 检查 Application Service 是否接收了 Web 层对象
grep -rn "HttpRequest\|HttpResponse\|@RequestBody" --include="*.java" application/
# 检查 Adapter 层是否有业务逻辑(非纯转换)
grep -rn "if\|else\|switch\|throw new.*Exception" adapter/inbound/controller/
# 检查 ORM 注解是否出现在 Domain 层
grep -rn "@Entity\|@Table\|@Column\|@Id\|@ManyToOne\|@OneToMany" --include="*.java" domain/ 2>/dev/null分层依赖检查矩阵:
| 层 | 允许依赖 | 禁止依赖 |
|---|---|---|
| Domain | 无框架依赖 | spring, javax.persistence, java.sql, HttpServletRequest |
| Application | Domain, DTO, Command | Adapter, Controller, HttpServletRequest |
| Infrastructure | Domain, 框架 | 无(最外层) |
| Adapter | Application, DTO | Domain 聚合根 |
---
6. 忽略贫血模型中的隐式行为
问题:Entity 有 getter/setter 但没有行为方法,所有逻辑在 Service 中。
检测命令:
# 统计 Entity 中 public setter 数量 vs public 行为方法数量
for f in domain/model/*.java; do
setters=$(grep -c "public void set" "$f")
behaviors=$(grep -cP "public (void|bool|Money|\w+) (?!get|set|is)" "$f" || echo 0)
if [ "$setters" -gt "$behaviors" ]; then
echo "ANEMIC: $f (setters=$setters, behaviors=$behaviors)"
fi
done反模式 → 修复:
// 反模式:贫血模型
order.setStatus("PAID"); // Service 中直接 set
order.setPaidAt(LocalDateTime.now());
// 修复:充血模型
order.pay(); // 聚合根方法,内部 set + 发事件 + 校验---
7. 聚合事务边界超出预期
问题:单个 Application Service 方法操作了 2+ 聚合但没有发领域事件。
检测命令:
# 检查 AppService 中是否操作了多个 Repository(跨聚合事务风险)
grep -rn "Repository" application/service/ | awk -F: '{print $1}' | sort | uniq -c | awk '$1 > 1'修复方案:
如果一个 AppService 操作了 OrderRepository + InventoryRepository:
→ 改为:OrderRepository.save() → 发 OrderPlacedEvent → 异步 handler 调用 InventoryRepository---
8. 值对象被用作可变引用
问题:VO 有 setter 或可变集合(List<OrderItem> items 可被外部修改)。
检测命令:
# 检查 VO 类中的 setter
grep -rn "public void set" --include="*.java" domain/model/*/ # 检查极子包
grep -rn "List<.*> get.*()" --include="*.java" domain/ # 返回可变集合修复:返回值使用 Collections.unmodifiableList() 或返回不可变副本。
前几讲我们已经介绍过了,在用 DDD 进行微服务设计时,我们可以通过事件风暴来确定领域模型边界,划定微服务边界,定义业务和系统运行边界,从而保证微服务的单一职责和随需而变的架构演进能力。
那重点落到边界的时候,总结一下就是,
微服务的设计要涉及到逻辑边界、物理边界和代码边界等等。
那么这些边界在微服务架构演进中到底起到什么样的作用?我们又该如何理解这些边界呢?这就是我们今天重点要解决的问题。
在微服务设计和实施的过程中,很多人认为:“将单体拆分成多少个微服务,是微服务的设计重点。”可事实真的是这样吗?其实并非如此!
Martin
Fowler
在提出微服务时,他提到了微服务的一个重要特征——演进式架构。那什么是演进式架构呢?演进式架构就是以支持增量的、非破坏的变更作为第一原则,同时支持在应用程序结构层面的多维度变化。
那如何判断微服务设计是否合理呢?其实很简单,只需要看它是否满足这样的情形就可以了:随着业务的发展或需求的变更,在不断重新拆分或者组合成新的微服务的过程中,不会大幅增加软件开发和维护的成本,并且这个架构演进的过程是非常轻松、简单的。
这也是微服务设计的重点,就是看微服务设计是否能够支持架构长期、轻松的演进。
那用 DDD 方法设计的微服务,不仅可以通过限界上下文和聚合实现微服务内外的解耦,同时也可以很容易地实现业务功能积木式模块的重组和更新,从而实现架构演进。
微服务还是小单体?
有些项目团队在将集中式单体应用拆分为微服务时,首先进行的往往不是建立领域模型,而只是按照业务功能将原来单体应用的一个软件包拆分成多个所谓的“微服务”软件包,而这些“微服务”内的代码仍然是集中式三层架构的模式,“微服务”内的代码高度耦合,逻辑边界不清晰,这里我们暂且称它为“小单体微服务”。
下面这张图也很好地展示了这个过程。
而随着新需求的提出和业务的发展,这些小单体微服务会慢慢膨胀起来。当有一天你发现这些膨胀了的微服务,有一部分业务功能需要拆分出去,或者部分功能需要与其它微服务进行重组时,你会发现原来这些看似清晰的微服务,不知不觉已经摇身一变,变成了臃肿油腻的大单体了,而这个大单体内的代码依然是高度耦合且边界不清的。
“辛辛苦苦好多年,一夜回到解放前啊!”这个时候你就需要一遍又一遍地重复着从大单体向单体微服务重构的过程。想想,这个代价是不是有点高了呢?
其实这个问题已经很明显了,那就是边界。
这种单体式微服务只定义了一个维度的边界,也就是微服务之间的物理边界,本质上还是单体架构模式。微服务设计时要考虑的不仅仅只有这一个边界,别忘了还要定义好微服务内的逻辑边界和代码边界,这样才能得到你想要的结果。
那现在你知道了,我们一定要避免将微服务设计为小单体微服务,那具体该如何避免呢?清晰的边界人人想要,可该如何保证呢?DDD 已然给出了答案。
微服务边界的作用
你应该还记得 DDD 设计方法里的限界上下文和聚合吧?它们就是用来定义领域模型和微服务边界的。
我们再来回顾一下 DDD 的设计过程。
在事件风暴中,我们会梳理出业务过程中的用户操作、事件以及外部依赖关系等,根据这些要素梳理出实体等领域对象。根据实体对象之间的业务关联性,将业务紧密相关的多个实体进行组合形成聚合,聚合之间是第一层边界。根据业务及语义边界等因素将一个或者多个聚合划定在一个限界上下文内,形成领域模型,限界上下文之间的边界是第二层边界。
为了方便理解,我们将这些边界分为:
逻辑边界、物理边界和代码边界
主要定义同一业务领域或应用内紧密关联的对象所组成的不同聚类的组合之间的边界。事件风暴对不同实体对象进行关联和聚类分析后,会产生多个聚合和限界上下文,它们一起组成这个领域的领域模型。微服务内聚合之间的边界就是逻辑边界。一般来说微服务会有一个以上的聚合,在开发过程中不同聚合的代码隔离在不同的聚合代码目录中。
逻辑边界在微服务设计和架构演进中具有非常重要的意义!
微服务的架构演进并不是随心所欲的,需要遵循一定的规则,这个规则就是逻辑边界。微服务架构演进时,在业务端以聚合为单位进行业务能力的重组,在微服务端以聚合的代码目录为单位进行微服务代码的重组。由于按照 DDD 方法设计的微服务逻辑边界清晰,业务高内聚,聚合之间代码松耦合,因此在领域模型和微服务代码重构时,我们就不需要花费太多的时间和精力了。
现在我们来看一个微服务实例,在下面这张图中,我们可以看到微服务里包含了两个聚合的业务逻辑,两个聚合分别内聚了各自不同的业务能力,聚合内的代码分别归到了不同的聚合目录下。
那随着业务的快速发展,如果某一个微服务遇到了高性能挑战,需要将部分业务能力独立出去,我们就可以以聚合为单位,将聚合代码拆分独立为一个新的微服务,这样就可以很容易地实现微服务的拆分。
另外,我们也可以对多个微服务内有相似功能的聚合进行功能和代码重组,组合为新的聚合和微服务,独立为通用微服务。现在你是不是有点做中台的感觉呢?
主要从部署和运行的视角来定义微服务之间的边界。不同微服务部署位置和运行环境是相互物理隔离的,分别运行在不同的进程中。这种边界就是微服务之间的物理边界。
主要用于微服务内的不同职能代码之间的隔离。微服务开发过程中会根据代码模型建立相应的代码目录,实现不同功能代码的隔离。由于领域模型与代码模型的映射关系,代码边界直接体现出业务边界。代码边界可以控制代码重组的影响范围,避免业务和服务之间的相互影响。微服务如果需要进行功能重组,只需要以聚合代码为单位进行重组就可以了。
正确理解微服务的边界
从上述内容中,我们知道了,按照 DDD 设计出来的逻辑边界和代码边界,让微服务架构演进变得不那么费劲了。
微服务的拆分可以参考领域模型,也可以参考聚合,因为聚合是可以拆分为微服务的最小单位的。但实施过程是否一定要做到逻辑边界与物理边界一致性呢?也就是说聚合是否也一定要设计成微服务呢?答案是不一定的,这里就涉及到微服务过度拆分的问题了。
微服务的过度拆分会使软件维护成本上升,比如:集成成本、发布成本、运维成本以及监控和定位问题的成本等。在项目建设初期,如果你不具备较强的微服务管理能力,那就不宜将微服务拆分过细。当我们具备一定的能力以后,且微服务内部的逻辑和代码边界也很清晰,你就可以随时根据需要,拆分出新的微服务,实现微服务的架构演进了。
当然,还要记住一点,微服务内聚合之间的服务调用和数据依赖需要符合高内聚松耦合的设计原则和开发规范,否则你也不能很快完成微服务的架构演进。
今天我们主要讨论了微服务架构设计中的各种边界在架构演进中的作用。
微服务内聚合之间的边界是逻辑边界。它是一个虚拟的边界,强调业务的内聚,可根据需要变成物理边界,也就是说聚合也可以独立为微服务。
微服务之间的边界是物理边界。它强调微服务部署和运行的隔离,关注微服务的服务调用、容错和运行等。
不同层或者聚合之间代码目录的边界是代码边界。它强调的是代码之间的隔离,方便架构演进时代码的重组。
通过以上边界,我们可以让业务能力高内聚、代码松耦合,且清晰的边界,可以快速实现微服务代码的拆分和组合,轻松实现微服务架构演进。但有一点一定要格外注意,边界清晰的微服务,不是大单体向小单体的演进。
DDD Code Review Report Template
Standardized report structure for DDD code review output. Use this template when generating review reports.
Minimal Required Sections
Every DDD code review report MUST include:
1. Overall Score — Total + Grade 2. Per-Dimension Scoring — 5 dimensions with itemized results 3. Anti-Pattern List — Each with location and fix suggestion 4. Improvement Suggestions — Ordered by P0/P1/P2 priority
---
Standard Template
# DDD Code Review Report
**Project**: {project name}
**Review Date**: {YYYY-MM-DD}
**Reviewer**: {reviewer name}
**Scope**: {modules/aggregates reviewed}
## Overall Score: {total}/100 ({grade} {icon})
### Dimension Breakdown
| Dimension | Score | Weight | Weighted | Status |
|-----------|:-----:|:------:|:--------:|:------:|
| Layering Compliance | {score}/100 | 30% | {w}/30 | {icon} |
| Domain Model Quality | {score}/100 | 30% | {w}/30 | {icon} |
| Naming Conventions | {score}/100 | 15% | {w}/15 | {icon} |
| Code Structure | {score}/100 | 15% | {w}/15 | {icon} |
| Test Coverage | {score}/100 | 10% | {w}/10 | {icon} |
| **Total** | | **100%** | **{total}/100** | **{grade}** |
---
### 1. Layering Compliance ({score}/30)
| Check Item | Result | Note |
|------------|:------:|------|
| Domain zero framework dependency | ✅/❌/⚠️ | {detail} |
| No reverse dependency | ✅/❌/⚠️ | {detail} |
| App layer no SQL | ✅/❌/⚠️ | {detail} |
| Controller no business logic | ✅/❌/⚠️ | {detail} |
| Repository returns aggregate root | ✅/❌/⚠️ | {detail} |
| No circular dependencies | ✅/❌/⚠️ | {detail} |
### 2. Domain Model Quality ({score}/30)
| Check Item | Result | Note |
|------------|:------:|------|
| Rich model coverage | ✅/❌/⚠️ | {count} / {total} entities have business methods |
| Value Object usage | ✅/❌/⚠️ | {count} VOs used, {count} fields still primitive |
| Aggregate design | ✅/❌/⚠️ | {aggregate sizes}, {cross-ref issues} |
| Domain events | ✅/❌/⚠️ | {event count} for {operation count} key operations |
| VO immutability | ✅/❌/⚠️ | {count} VOs have setters |
### 3. Naming Conventions ({score}/15)
| Check Item | Result | Note |
|------------|:------:|------|
| Aggregate Root naming | ✅/❌/⚠️ | {example violations} |
| Repository naming | ✅/❌/⚠️ | {example violations} |
| Domain Service naming | ✅/❌/⚠️ | {example violations} |
| Domain Event naming (past tense) | ✅/❌/⚠️ | {example violations} |
| Package by aggregate | ✅/❌/⚠️ | {violation count} |
### 4. Code Structure ({score}/15)
| Check Item | Result | Note |
|------------|:------:|------|
| Aggregate Root < 200 lines | ✅/❌/⚠️ | {count} exceeding |
| Service < 100 lines | ✅/❌/⚠️ | {count} exceeding |
| Cyclomatic complexity < 10 | ✅/❌/⚠️ | {count} methods exceeding |
| Package organization | ✅/❌/⚠️ | {detail} |
| Exception handling | ✅/❌/⚠️ | {detail} |
### 5. Test Coverage ({score}/10)
| Check Item | Result | Note |
|------------|:------:|------|
| Domain unit test coverage | ✅/❌/⚠️ | ~{percentage}% |
| Aggregate root behavior tests | ✅/❌/⚠️ | {count}/{expected} tested |
| Domain event assertion | ✅/❌/⚠️ | {count} events verified |
---
### Anti-Pattern List
| Severity | Anti-Pattern | File:Line | Fix Suggestion |
|:---------:|-------------|-----------|---------------|
| P0 | {name} | {file}:{line} | {fix description} |
| P1 | {name} | {file}:{line} | {fix description} |
| P2 | {name} | {file}:{line} | {fix description} |
### Improvement Suggestions
**Immediate (P0)**:
1. {fix} — {effort estimate}
2. {fix} — {effort estimate}
**Short-term (P1)**:
1. {fix} — {effort estimate}
2. {fix} — {effort estimate}
**Long-term (P2)**:
1. {fix} — {effort estimate}
2. {fix} — {effort estimate}
---
### Change Log
| Date | Rev | Change |
|------|:---:|--------|
| {YYYY-MM-DD} | 1.0 | Initial review |Quick Summary Format (for Slack/Email)
🏗️ DDD Code Review — {project} ({grade})
Score: {total}/100
🔴 P0 Issues ({count}):
- {issue} → {fix}
- {issue} → {fix}
🟡 P1 Issues ({count}):
- {issue} → {fix}
📊 Top Dimension: {best} ({score})
📊 Bottom Dimension: {worst} ({score})
Full report: {link}Per-Aggregate Review Card
For large reviews, break down by aggregate:
### Aggregate: Order
**Status**: ✅ Pass (with minor issues)
| Check | Result |
|-------|:------:|
| Rich model | ✅ pay(), confirm(), cancel(), addItem() — all in entity |
| Value Objects | ⚠️ status still String, should be OrderStatus VO |
| Events | ✅ OrderPlaced, OrderConfirmed, OrderCancelled |
| Size | ✅ 180 lines (entities + VOs), 45 lines (repository interface) |
| Tests | ⚠️ ~60% coverage — missing confirm() edge cases |Aggregating Multiple Reviews
When reviewing multiple modules:
| Module | Score | Grade | P0 | P1 | P2 | Action |
|--------|:-----:|:-----:|:--:|:--:|:--:|--------|
| Order | 85/100 | 🟢 A | 0 | 2 | 1 | Ready |
| Payment | 62/100 | 🟠 C | 2 | 3 | 1 | Refactor needed |
| Inventory | 45/100 | 🔴 D | 4 | 2 | 0 | Block merge |
**Overall Project Health**: 🟠 C — 64/100DDD Code Review Scoring Criteria
Detailed breakdown of the 5-dimension scoring system used in DDD code reviews.
Scoring Formula
Total Score = Σ(dimension_score × weight)
dimension_score = sum of (check_item_passed / check_item_total) × 100Dimension 1: Layering Compliance (weight: 30%)
Check Items
| # | Check | Weight in Dimension | Detection Method |
|---|---|---|---|
| 1 | Domain layer has zero framework imports | 25% | Scan import statements in domain/ |
| 2 | No reverse dependency (domain → infra) | 20% | Scan package references |
| 3 | Application layer has no SQL | 20% | Scan for Mapper/JdbcTemplate in app/ |
| 4 | Controller has no business logic | 15% | Scan for if/else business branching |
| 5 | Repository returns Aggregate Root | 10% | Check return types of Repository methods |
| 6 | No circular dependency between modules | 10% | Dependency graph analysis |
Scoring Example
Check 6/6 items passed → 30/30 points (100%)
Check 5/6 items passed → 25/30 points (83%)
Check 4/6 items passed → 20/30 points (67%)
Check < 4 items passed → 10/30 points (33%)Dimension 2: Domain Model Quality (weight: 30%)
Check Items
| # | Check | Weight in Dimension | Detection Method |
|---|---|---|---|
| 1 | Rich model coverage | 30% | Entities with business methods / total entities |
| 2 | Value object usage rate | 25% | VO fields / total non-collection fields |
| 3 | Aggregate design rationality | 20% | Entity count per aggregate, ID-only cross-refs |
| 4 | Domain events for key operations | 15% | Event classes per aggregate |
| 5 | Immutability of value objects | 10% | Check for setters / non-final fields |
Scoring Scale
| Rich Model Coverage | Score |
|---|---|
| ≥ 80% entities have business methods | 28-30 |
| 50-79% | 20-27 |
| 20-49% | 10-19 |
| < 20% | 0-9 |
| Value Object Usage | Score Contribution |
|---|---|
| ≥ 60% of primitive-type fields replaced by VOs | Full marks |
| 30-59% | Half marks |
| < 30% | Low marks |
Dimension 3: Naming Conventions (weight: 15%)
Check Items
| # | Pattern | Standard | Score if Correct |
|---|---|---|---|
| 1 | Aggregate Root | {BusinessName} — e.g., Order | 20% |
| 2 | Repository | {Aggregate}Repository — e.g., OrderRepository | 20% |
| 3 | Domain Service | {BusinessAction}Service — e.g., OrderPricingService | 15% |
| 4 | Domain Event | Past tense — e.g., OrderPaid | 15% |
| 5 | Package by Aggregate | domain/order/, domain/product/ | 15% |
| 6 | Method Naming | Ubiquitous language — e.g., pay(), not updateStatus() | 15% |
Dimension 4: Code Structure (weight: 15%)
Check Items
| # | Check | Threshold | Score if Met |
|---|---|---|---|
| 1 | Aggregate Root class size | < 200 lines | 20% |
| 2 | Service class size | < 100 lines | 20% |
| 3 | Method cyclomatic complexity | < 10 | 20% |
| 4 | Package organization by aggregate | Yes/No | 20% |
| 5 | Consistent exception handling | Domain exceptions used | 20% |
Dimension 5: Test Coverage (weight: 10%)
Check Items
| # | Check | Target | Scoring |
|---|---|---|---|
| 1 | Domain layer unit test coverage | ≥ 80% methods tested | 40% |
| 2 | Aggregate Root behavior tests | All behavior methods tested | 30% |
| 3 | Domain event verification in tests | Events asserted in tests | 30% |
Score Interpretation
| Range | Grade | Meaning | Action |
|---|---|---|---|
| ≥ 85 | 🟢 A | Excellent DDD practice | Ready for production |
| 70-84 | 🟡 B | Basically compliant | Address minor issues |
| 50-69 | 🟠 C | Obvious anti-patterns | Plan refactoring sprint |
| < 50 | 🔴 D | Needs major refactoring | Block merge, restructure |
Quick Calculation Template
| Dimension | Score | Weight | Weighted |
|-----------|:-----:|:------:|:--------:|
| Layering Compliance | /100 | 30% | /30 |
| Domain Model Quality | /100 | 30% | /30 |
| Naming Conventions | /100 | 15% | /15 |
| Code Structure | /100 | 15% | /15 |
| Test Coverage | /100 | 10% | /10 |
| **Total** | | **100%** | **/100** |