DDD领域驱动设计

一个订单模块的三年重构史

2019年,我们团队开始做一个电商系统。订单模块最初是这样的:

@Entity
class Order {
    private Long id;
    private Long userId;
    private Double amount;
    private String status; // "CREATED", "PAID", "SHIPPED", "COMPLETED"
    private List<OrderItem> items;

    // getter/setter... 200行
}

@Service
class OrderService {
    @Autowired
    private OrderRepository orderRepository;

    public void createOrder(Long userId, List<OrderItemDTO> items) {
        // 500行业务逻辑散落在Service中
    }

    public void payOrder(Long orderId, Double amount) {
        // 300行业务逻辑
    }

    public void shipOrder(Long orderId) {
        // 200行业务逻辑
    }
}

三年后,这个 Service 积累了 8000 行代码,200 个方法。没人敢动它——每次改一个逻辑都要担心影响其他逻辑。

这就是贫血模型的诅咒:数据和行为分离,业务逻辑散落在 Service 的各个角落,最终变成无人敢碰的"意大利面条"。


二、DDD的核心思想

DDD(Domain-Driven Design,领域驱动设计)的核心是:把业务逻辑内聚到领域模型中,让软件反映业务概念

贫血模型:        Entity = 数据 + getter/setter
                   所有行为在 Service 中

DDD 充血模型:     Entity = 数据 + 行为(领域逻辑)
                   Service 只负责编排和协调

三、DDD战术设计:构建块🔴

3.1 实体(Entity)

实体:有唯一标识的对象,标识贯穿整个生命周期,属性可能变化。

// 订单是实体:即使所有属性都一样,order-001 和 order-002 是两个不同的订单
class Order extends BaseEntity {
    private OrderId id; // 唯一标识
    private UserId userId;
    private OrderStatus status;
    private Money totalAmount;
    private List<OrderItem> items;

    // 充血模型:行为内聚
    public void pay(Money amount) {
        // 前置校验:订单状态必须是 CREATED
        if (this.status != OrderStatus.CREATED) {
            throw new OrderCannotPayException(id, status);
        }

        // 业务规则:支付金额必须等于订单金额
        if (!amount.equals(this.totalAmount)) {
            throw new AmountMismatchException(amount, this.totalAmount);
        }

        // 状态转换
        this.status = OrderStatus.PAID;
        this.paidAt = Instant.now();

        // 发布领域事件
        DomainEvents.publish(new OrderPaidEvent(this));
    }

    public void cancel() {
        // 前置校验
        if (this.status != OrderStatus.CREATED) {
            throw new OrderCannotCancelException(id, status);
        }

        // 业务规则:已支付的订单不能取消,只能退款
        if (this.status == OrderStatus.PAID) {
            throw new PaidOrderCannotCancelException(id);
        }

        this.status = OrderStatus.CANCELLED;
        DomainEvents.publish(new OrderCancelledEvent(this));
    }
}

3.2 值对象(Value Object)

值对象:没有唯一标识,通过属性值确定,不可变

// Money 是值对象:100元和100元永远相等
// "100元"和"100元"是无法区分的,除非比较属性
class Money {
    private final BigDecimal amount;
    private final Currency currency;

    private Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public static Money of(BigDecimal amount, Currency currency) {
        return new Money(amount, 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);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.equals(money.amount) && currency.equals(money.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}

值对象的判断标准

  • 不需要唯一标识
  • 不可变(创建后不能修改)
  • 可以用其他值对象替换
  • 相等性由属性决定,不是引用

3.3 聚合(Aggregate)

聚合:一组相关对象的集合,作为数据修改的单元。每个聚合有一个聚合根

// Order 是聚合根,OrderItem 只能通过 Order 访问
class Order extends BaseAggregateRoot {
    private OrderId id;
    private UserId userId;
    private OrderStatus status;
    private List<OrderItem> items; // OrderItem 不能单独修改

    // 聚合根的方法:对外暴露的唯一入口
    public void addItem(Product product, int quantity) {
        // 业务规则:订单状态必须是 CREATED
        if (this.status != OrderStatus.CREATED) {
            throw new OrderCannotModifyException(id, status);
        }

        // 业务规则:检查是否已存在相同商品
        OrderItem existing = findItem(product.getId());
        if (existing != null) {
            existing.increaseQuantity(quantity);
        } else {
            items.add(new OrderItem(product.getId(), product.getPrice(), quantity));
        }

        recalculateTotal();
    }

    public void removeItem(ProductId productId) {
        if (this.status != OrderStatus.CREATED) {
            throw new OrderCannotModifyException(id, status);
        }
        items.removeIf(item -> item.getProductId().equals(productId));
        recalculateTotal();
    }

    private OrderItem findItem(ProductId productId) {
        return items.stream()
            .filter(i -> i.getProductId().equals(productId))
            .findFirst()
            .orElse(null);
    }

    private void recalculateTotal() {
        this.totalAmount = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);
    }
}

// OrderItem 不能单独存在,必须通过 Order
class OrderItem {
    private ProductId productId;
    private Money unitPrice;
    private int quantity;

    // 不能从外部直接创建
    OrderItem(ProductId productId, Money unitPrice, int quantity) {
        this.productId = productId;
        this.unitPrice = unitPrice;
        this.quantity = quantity;
    }

    void increaseQuantity(int delta) {
        this.quantity += delta;
    }

    Money getSubtotal() {
        return unitPrice.multiply(quantity);
    }
}

聚合的规则

  1. 通过聚合根访问所有内部对象
  2. 外部对象不能直接持有聚合内部对象的引用
  3. 聚合内所有对象保持一致性
  4. 聚合是事务边界:一个聚合的修改在一个事务中完成

3.4 仓储(Repository)

仓储:聚合的持久化抽象,只操作聚合根。

// ❌ 错误:暴露了 OrderItem
interface OrderRepository {
    void save(Order order);
    Order findById(OrderId id);
    List<OrderItem> findItemsByOrderId(OrderId id); // 错误:不应该有单独的 Item 查询
}

// ✅ 正确:只操作聚合根
interface OrderRepository {
    void save(Order order);
    Order findById(OrderId id);
    Optional<Order> findByIdForUpdate(OrderId id); // 悲观锁
    List<Order> findByUserId(UserId userId);
}

3.5 领域服务(Domain Service)

当某个业务逻辑不属于任何一个实体或值对象时,使用领域服务。

// 计算订单总价:不属于 Order,但属于业务逻辑
class OrderPricingService {
    public Money calculatePrice(List<OrderItem> items, Coupon coupon) {
        Money subtotal = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);

        Money discount = coupon.calculateDiscount(subtotal);

        return subtotal.subtract(discount);
    }
}

// 跨聚合的业务逻辑:转账
class TransferService {
    public void transfer(Account from, Account to, Money amount) {
        // 这个逻辑涉及两个 Account 聚合,无法放在任何一个聚合中
        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientBalanceException(from.getId(), amount);
        }

        from.withdraw(amount);
        to.deposit(amount);
    }
}

3.6 领域事件(Domain Event)

// 定义领域事件
@DomainEvent
class OrderPaidEvent {
    private final OrderId orderId;
    private final UserId userId;
    private final Money amount;
    private final Instant paidAt;

    public OrderPaidEvent(Order order) {
        this.orderId = order.getId();
        this.userId = order.getUserId();
        this.amount = order.getTotalAmount();
        this.paidAt = order.getPaidAt();
    }
}

// 在聚合根中发布事件
class Order extends BaseAggregateRoot {
    public void pay(Money amount) {
        // ... 支付逻辑
        DomainEvents.publish(new OrderPaidEvent(this));
    }
}

// 订阅事件(异步处理)
@EventListener
class PointsService {
    @Async
    public void handleOrderPaid(OrderPaidEvent event) {
        pointsService.addPoints(event.getUserId(), event.getAmount());
    }
}

四、DDD战略设计:限界上下文🔴

4.1 问题

在大型电商系统中,"用户"在不同上下文中含义不同:

电商上下文:用户 = 买家/卖家 + 购物行为 + 订单关联
鉴权上下文:用户 = 账号 + 密码 + 登录行为
CRM上下文:用户 = 客户 + 联系方式 + 营销标签

同一个"用户"实体,在不同上下文中属性和行为完全不同。

4.2 限界上下文(Bounded Context)

限界上下文是 DDD 的战略设计工具:把整个系统拆分成多个独立的上下文,每个上下文有自己的领域模型和边界

┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
│   电商上下文     │  │   鉴权上下文     │  │   CRM上下文      │
│                 │  │                 │  │                 │
│ User(买家/卖家)  │  │ Account(账号)   │  │ Customer(客户)  │
│ Order           │  │ Session         │  │ Contact         │
│ Product         │  │ Permission      │  │ MarketingTag    │
│                 │  │                 │  │                 │
│ 内部数据:       │  │ 内部数据:       │  │ 内部数据:       │
│ 订单历史、       │  │ 密码哈希、       │  │ 联系方式、       │
│ 购物偏好         │  │ 会话Token        │  │ 营销数据         │
└────────┬────────┘  └────────┬────────┘  └────────┬────────┘
         │                    │                    │
         └────────────────────┼────────────────────┘
                              │ 通过防腐层转换

4.3 上下文映射(Context Mapping)

不同上下文之间通过上下文映射协作:

// 电商上下文通过防腐层访问鉴权上下文
class UserContextAdapter {
    private final AuthService authService; // 鉴权上下文的接口

    public UserInfo getUserInfo(UserId userId) {
        // 适配:鉴权上下文的 Account → 电商上下文的 UserInfo
        Account account = authService.getAccount(userId);
        return new UserInfo(account.getId(), account.getUsername());
    }
}

【架构权衡】 限界上下文的划分没有标准答案。常见的划分方式:

  • 按业务能力(电商 vs 库存 vs 物流)
  • 按团队边界(康威定律)
  • 按数据一致性需求(强一致 vs 最终一致)

划分的粒度太粗会变成"单体架构",太细会增加集成复杂度。DDD 的精髓在于找到业务边界清晰、数据内聚的上下文划分。


五、CQRS 与 DDD🟡

5.1 命令查询分离

CQRS(Command Query Responsibility Segregation)将写操作和读操作分离:

// 命令端:DDD 聚合
@Aggregate
class Order {
    // 充血模型处理写操作
    public void pay(Money amount) { ... }
    public void addItem(Product product, int quantity) { ... }
}

// 查询端:简单 DTO
record OrderDetailDTO(
    Long orderId,
    String userName,
    String status,
    BigDecimal amount,
    List<OrderItemDTO> items,
    LocalDateTime createdAt
) {}

5.2 数据同步

读模型如何获取数据?

// 方式一:通过领域事件同步到读模型
@EventListener
class OrderReadModelUpdater {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Async
    public void handleOrderCreated(OrderCreatedEvent event) {
        jdbcTemplate.update(
            "INSERT INTO order_read_model ...",
            event.getOrderId(), event.getUserId(), event.getAmount()
        );
    }
}

// 方式二:直接查聚合(简单场景)
@Service
class OrderQueryService {
    public OrderDetailDTO getOrderDetail(OrderId id) {
        Order order = orderRepository.findById(id);
        return convertToDTO(order);
    }
}

六、生产避坑清单

6.1 DDD 过度设计

// ❌ 过度使用:简单 CRUD 也用 DDD
@Aggregate
class Product {
    @Id
    private Long id;

    public void setName(String name) { // 充血模型?不需要
        this.name = name;
    }
}

// ✅ 正确:简单场景用 CRUD,复杂业务用 DDD
⚠️

DDD 不是银弹。简单 CRUD 系统用 DDD 会增加不必要的复杂度。DDD 适用于业务复杂、规则多、变化频繁的核心领域。

6.2 聚合过大

// ❌ 错误:把所有对象放进一个大聚合
class Order {
    private List<OrderItem> items;
    private User user; // 关联到 User 聚合
    private Product product; // 关联到 Product 聚合
    private Warehouse warehouse; // 关联到 Warehouse 聚合
    // 一个订单聚合了 10 个其他聚合,性能爆炸
}

// ✅ 正确:用 ID 引用,而不是直接关联
class Order {
    private OrderId id;
    private UserId userId; // 用 ID 引用
    private ProductId productId; // 用 ID 引用
}

6.3 贫血 vs 充血

// ❌ 贫血:所有逻辑在 Service
class OrderService {
    public void pay(Order order, double amount) {
        if (order.getStatus() != "CREATED") throw new Exception();
        if (amount != order.getAmount()) throw new Exception();
        order.setStatus("PAID");
    }
}

// ✅ 充血:逻辑在 Entity
class Order {
    public void pay(double amount) {
        if (this.status != OrderStatus.CREATED) throw new Exception();
        if (amount != this.amount) throw new Exception();
        this.status = OrderStatus.PAID;
    }
}

七、面试总结

7.1 核心追问

  1. "DDD 和传统三层架构的区别?" —— 充血模型 vs 贫血模型
  2. "实体和值对象的区别?" —— 唯一标识 vs 属性相等
  3. "聚合的作用是什么?" —— 事务边界、一致性边界
  4. "限界上下文怎么划分?" —— 业务边界、数据一致性
  5. "DDD 什么场景适用?" —— 复杂业务领域

7.2 级别差异

级别期望回答
P5能说出实体、值对象、聚合的概念
P6能区分贫血和充血模型,知道限界上下文的划分思路
P7有实际 DDD 落地经验,能分析 CQRS、事件溯源等进阶实践