DDD落地必须严守三原则:领域模型不依赖EF Core,仓储接口不返回IQueryable或DTO,应用服务不写业务判断逻辑;否则仅为披皮CRUD。

领域模型不能直接依赖 EF Core,仓储接口不能返回 IQueryable 或 DTO,应用服务里不该写业务判断逻辑——这三点踩错任意一个,DDD 就只是披了层皮的 CRUD。
领域实体里别调用 SaveChanges()
常见错误现象:在 User.ConfirmEmail() 方法里直接注入 _context 并调用 SaveChanges();或者在 Order.AddLineItem() 里更新状态后立刻持久化。
这样做会把基础设施细节泄漏进领域层,导致:
- 换数据库(比如从 SQL Server 切到 Cosmos DB)时,整个领域模型要重写
- 无法做事件溯源或审计日志,因为保存动作和业务动作耦合在一起
- 单元测试必须启动真实数据库,
User类失去可测试性
正确做法是让领域实体只管“能不能做”和“做了之后变什么样”:
public class Order
{
public OrderId Id { get; private set; }
public OrderStatus Status { get; private set; }
private List<OrderItem> _items = new();
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Only draft orders can be confirmed");
Status = OrderStatus.Confirmed;
// 不做任何 Save、不碰 DbContext、不发事件
}
}
保存动作交给应用服务统一协调,由仓储实现落地。
仓储接口只能返回领域对象,不能暴露查询能力
错误示例:IOrderRepository.FindByCustomerId(int id) 返回 IQueryable<order></order>,或更糟——返回 OrderDto。
为什么危险:
-
IQueryable把 LINQ 表达式树泄漏到上层,应用服务可能写出.Where(...).OrderBy(...).Skip(...).Take(...),仓储就不再是“按 ID 加载/保存聚合”的抽象,而退化成数据访问层包装 - 返回
OrderDto意味着领域层开始为展示层妥协,值对象、不变量、业务约束全被绕过 - 后续加缓存、读写分离、CQRS 时,所有地方都要改
推荐做法:仓储只提供明确语义的方法,复杂查询走 Specification:
public interface IOrderRepository
{
Order GetById(OrderId id);
void Add(Order order);
void Update(Order order);
}
// 非仓储接口,专用于查询
public interface IOrderQueryService
{
IEnumerable<Order> Match(Specification<Order> spec);
}
Specification<order></order> 实现类(如 ConfirmedOrdersSinceSpec)负责封装条件,由 IOrderQueryService 翻译成 SQL,领域层完全无感。
应用服务里禁止出现 if (order.Total > 1000) 这类判断
这是业务规则外溢的典型信号。一旦你在 OrderApplicationService.PlaceOrder() 里写分支逻辑,说明领域模型没把规则收住。
真正该发生的是:
- 规则在
Order内部封装,比如order.CanBePlaced()或构造时抛异常 - 或抽成独立的
DomainService,例如ICreditCheckService.Validate(order),它只依赖领域对象,不碰数据库
应用服务只干四件事:
- 从仓储加载聚合
- 调用聚合的业务方法(如
order.Place()) - 调用仓储保存
- 发布领域事件(如
_eventPublisher.Publish(new OrderPlacedEvent(order.Id)))
它不决定“要不要放行”,只决定“谁来决定”。那个“谁”,必须是领域模型自己。
AggregateRoot 的 ID 必须稳定且有意义
常见坑:用数据库自增主键当 OrderId,或用 GUID 当用户标识,结果跨限界上下文无法关联、API 返回 ID 对不上前端预期、审计日志查不到源头。
选 ID 策略要看生命周期和上下文:
- 订单号建议用业务含义强的格式,如
"ORD-20260420-0001",生成时机在创建聚合根时(不是保存后) - 用户 ID 推荐用
UserId值对象封装,内部可为 GUID,但对外始终作为不可变整体参与业务流转 - 避免在聚合根里暴露
public int Id { get; set; }这种属性,要用只读属性 + 构造器强制初始化
最易被忽略的一点:聚合根的 ID 是它在整个系统中身份的唯一锚点。一旦 ID 在不同上下文里含义不一致(比如订单号在财务域是字符串,在物流域被转成数字),反腐败层就得硬扛,后期集成成本指数级上升。



















