1. DDD 分层架构
一句话总结: DDD 用分层隔离关注点:表现层管输入输出、应用层管用例编排、领域层管业务规则、基础设施层管技术实现。
领域驱动设计(DDD)的核心是让业务规则成为架构的中心。经典四层:表现层(Controller/Minimal API)只做协议转换;应用层(Application Service)编排用例但不含业务规则;领域层(Domain)承载实体、值对象、聚合与领域服务;基础设施层(EF Core、Redis、外部 API)实现接口。
// 表现层:只做 HTTP 协议转换
[ApiController]
[Route("api/orders")]
public class OrdersController(IOrderAppService app) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest req)
=> Ok(await app.CreateAsync(req.ToCommand()));
}
// 应用层:用例编排,不写业务规则
public class OrderAppService(IOrderRepository repo, IUnitOfWork uow)
{
public async Task<OrderDto> CreateAsync(CreateOrderCommand cmd)
{
var order = Order.Create(cmd.CustomerName, cmd.Total); // 领域规则在领域层
await repo.AddAsync(order);
await uow.SaveChangesAsync();
return OrderDto.From(order);
}
}
// 领域层:业务规则的家
public class Order
{
public int Id { get; private set; }
public string Customer { get; private set; }
public OrderStatus Status { get; private set; }
public static Order Create(string customer, decimal total)
{
if (total <= 0) throw new DomainException("金额必须大于 0");
return new Order { Customer = customer, Status = OrderStatus.New };
}
public void Confirm()
{
if (Status != OrderStatus.New) throw new DomainException("只有新订单可确认");
Status = OrderStatus.Confirmed;
}
}
| 层 | 职责 | 不做什么 |
|---|---|---|
| 表现层 | HTTP 转换、参数校验 | 不含业务规则 |
| 应用层 | 用例编排、事务边界 | 不写 if 业务判断 |
| 领域层 | 实体、值对象、规则 | 不依赖 EF/HTTP |
| 基础设施层 | EF、Redis、外部调用 | 不决定业务 |
避坑: 别把「贫血模型」当 DDD——实体只有 getter/setter,规则全散在 Service 里,最后 Service 变成上帝类。让实体拥有自己的行为(
Order.Confirm()),业务规则内聚在它所属的地方。分层是依赖方向约束:领域层永远不引用基础设施层,方向靠接口倒置。
2. 聚合与领域模型
一句话总结: 聚合把一组内聚实体圈成一个一致性边界,聚合根是外部访问的唯一入口,保证不变量不被破坏。
聚合(Aggregate)解决「对象图里谁对谁负责」:订单聚合包含订单项(OrderLine),通过聚合根 Order 访问。外部不能直接改订单项,必须 order.AddLine(...)。这保证「订单总额 = 各项之和」这类不变量在并发下依然成立。
public class Order
{
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public decimal Total => _lines.Sum(l => l.Quantity * l.UnitPrice);
public OrderLine AddLine(int productId, int quantity, decimal unitPrice)
{
if (quantity <= 0) throw new DomainException("数量必须为正");
var line = new OrderLine(productId, quantity, unitPrice);
_lines.Add(line);
return line;
}
public void RemoveLine(int productId)
{
var line = _lines.FirstOrDefault(l => l.ProductId == productId)
?? throw new DomainException("订单项不存在");
if (Status != OrderStatus.New) throw new DomainException("已确认订单不可改项");
_lines.Remove(line);
}
}
public record OrderLine(int ProductId, int Quantity, decimal UnitPrice);
| 概念 | 说明 |
|---|---|
| 聚合根 | 唯一外部入口,保不变量 |
| 实体 | 有身份、有生命周期 |
| 值对象 | 无身份、不可变(OrderLine 可作值对象) |
| 仓储 | 按聚合粒度存取 |
| 边界 | 一次事务只改一个聚合 |
避坑: 聚合边界别划太大——一个聚合包含几十个实体会让事务锁放大、并发性能崩。也别划太小——每个实体一个聚合,跨聚合一致性全靠事件补偿。经验法则:一次业务操作必须原子的对象圈是一个聚合。仓储只对聚合根提供接口,不暴露子实体集合。
3. CQRS 读写分离
一句话总结: CQRS 把「改状态的命令」与「查数据的查询」分离,让写入模型围绕领域规则、读取模型围绕查询效率,两者可以独立演进。
大多数系统读多写少,但共享同一个模型时,查询优化(投影、去规范化)会被写入的领域约束绑住。CQRS 分离后:命令走领域模型、单聚合事务;查询走专用读模型(Dapper/视图/物化表),可以大胆做投影与缓存。
// 命令侧:领域模型 + 事务
public class OrderAppService(IOrderRepository repo, IUnitOfWork uow)
{
public async Task CreateAsync(CreateOrderCommand cmd)
{
var order = Order.Create(cmd.CustomerName, cmd.Total);
await repo.AddAsync(order);
await uow.SaveChangesAsync();
}
}
// 查询侧:专用读模型,按需投影
public class OrderQueryService(IDbConnection db)
{
public async Task<IEnumerable<OrderSummaryDto>> ListAsync(string customer)
{
// 直接 SQL/Dapper,绕过领域约束,为查询而生
return await db.QueryAsync<OrderSummaryDto>(
"SELECT Id, Customer, Total, Status FROM Orders WHERE Customer = @customer",
new { customer });
}
}
| 侧 | 模型 | 技术 |
|---|---|---|
| 命令(写) | 领域模型、聚合 | EF Core、事务 |
| 查询(读) | 投影 DTO、读模型 | Dapper、视图、Redis |
避坑: 别为所有系统都上 CQRS——CRUD 型后台管理加一层读模型纯属过度设计。CQRS 的收益在「查询复杂 + 写入受领域约束」的系统。同一份数据库做读写分离时,读模型要容忍轻微不一致(最终一致),别幻想强一致。
4. MediatR 与中介者模式
一句话总结: MediatR 让请求(Request)经中介者路由到处理器(Handler),解耦调用方与实现,并把横切逻辑(验证、日志、事务)收进管道。
直接依赖 OrderAppService 会让 Controller 与实现紧密耦合。MediatR 引入中介者:Controller 发 CreateOrderCommand,MediatR 路由到 CreateOrderCommandHandler。AddMediatR 自动注册处理器,IPipelineBehavior 让验证、日志、事务成为可插拔管道。
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// 请求:应用层用例的定义
public record CreateOrderCommand(string CustomerName, decimal Total) : IRequest<int>;
// 处理器:用例实现
public class CreateOrderHandler(IOrderRepository repo, IUnitOfWork uow)
: IRequestHandler<CreateOrderCommand, int>
{
public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerName, cmd.Total);
await repo.AddAsync(order);
await uow.SaveChangesAsync(ct);
return order.Id;
}
}
// 管道行为:统一事务边界
public class TransactionBehavior<TReq, TResp>(IUnitOfWork uow)
: IPipelineBehavior<TReq, TResp> where TReq : notnull
{
public async Task<TResp> Handle(TReq request,
RequestHandlerDelegate<TResp> next, CancellationToken ct)
{
await uow.BeginTransactionAsync(ct);
var response = await next();
await uow.CommitAsync(ct);
return response;
}
}
// Controller 使用
[HttpPost]
public async Task<IActionResult> Create(CreateOrderCommand cmd)
=> Ok(await _mediator.Send(cmd));
| 角色 | 职责 |
|---|---|
| IRequest | 用例请求(数据 + 意图) |
| IRequestHandler | 用例实现 |
| IPipelineBehavior | 横切管道(验证/事务/日志) |
| INotification | 领域事件通知 |
避坑: MediatR 只是路由工具,别把业务规则写进 Handler 然后 Handler 变大杂烩。一个 Handler 一个用例,命令对象承载输入数据,处理器保持薄。管道行为的执行顺序要文档化——验证在前、事务包外,别让日志把异常吞掉导致事务误提交。
5. 结果类型与错误处理
一句话总结: 用 Result 类型显式携带「成功/失败 + 错误码」,把业务错误从异常流中剥离,让调用方能确定性处理每种失败。
异常适合「意外故障」(DB 挂了、参数非法),但业务失败(余额不足、订单状态非法)不该靠异常传播——异常路径性能差且调用方难以穷举。Result<T> 返回成功值或错误信息,编译器强制调用方处理两种情况。
public record Result<T>(T? Value, Error? Error)
{
public bool IsSuccess => Error is null;
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(Error error) => new(default, error);
public TValue Match<TValue>(
Func<T, TValue> onSuccess,
Func<Error, TValue> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(Error!);
}
public record Error(string Code, string Message);
// 领域方法返回结果而非抛异常
public Result<Order> Confirm()
{
if (Status != OrderStatus.New)
return Result<Order>.Fail(new Error("ORDER.NOT_CONFIRMABLE", "只有新订单可确认"));
Status = OrderStatus.Confirmed;
return Result<Order>.Ok(this);
}
// 调用方显式处理
var result = order.Confirm();
return result.Match(
success => Ok(success),
error => Problem(error.Message, statusCode: 409));
| 失败类型 | 手段 |
|---|---|
| 意外故障 | 异常 + 全局处理器 |
| 业务失败 | Result + 错误码 |
| 参数非法 | Data Annotation / FluentValidation |
| 外部依赖失败 | 异常转 Result 或降级 |
避坑: Result 模式的纪律是不要吞掉错误——调用方必须处理 Failure 分支,否则静默
null继续跑会埋雷。也别让 Result 泛滥到每条内部调用,只在领域边界与用例出口使用。错误码要稳定(ORDER.NOT_CONFIRMABLE),客户端据此做文案与重试决策。
6. 领域事件驱动
一句话总结: 领域事件表达「已经发生的事实」,用发布-订阅解耦聚合之间的一致性,把副作用从用例中剥离出来异步执行。
一个用例常常触发跨聚合的副作用:订单确认后要发邮件、更新库存、记日志。把这些写成领域事件(OrderConfirmed),在聚合内发布,由事件处理器订阅执行。配合 MediatR 的 INotification 可实现进程内发布,基础设施层可桥接消息队列做成异步。
// 领域事件:已发生的过去时
public record OrderConfirmed(int OrderId, string Customer) : INotification;
// 聚合内发布事件
public class Order
{
private readonly List<INotification> _events = [];
public IReadOnlyList<INotification> Events => _events;
public void Confirm()
{
if (Status != OrderStatus.New) throw new DomainException("订单状态不允许确认");
Status = OrderStatus.Confirmed;
_events.Add(new OrderConfirmed(Id, Customer));
}
public void ClearEvents() => _events.Clear();
}
// 事件处理器:副作用在这里
public class OrderConfirmedHandler(INotificationService notify)
: INotificationHandler<OrderConfirmed>
{
public async Task Handle(OrderConfirmed evt, CancellationToken ct)
{
await notify.SendEmailAsync(evt.Customer, "订单已确认", evt.OrderId, ct);
}
}
| 要素 | 说明 |
|---|---|
| 事件命名 | 过去时:OrderConfirmed |
| 发布时机 | 事务提交后发布 |
| 订阅 | MediatR 进程内 / 消息队列跨服务 |
| 幂等 | 处理器必须幂等(可能重发) |
避坑: 领域事件最经典的坑是在事务提交前发送——事件已发出但事务回滚,消费者看到假事实。做法是先把事件攒在聚合里,
SaveChanges成功后统一发布,失败则不发布。跨服务事件要用消息队列 + 幂等消费,别指望进程内通知跨进程。
7. 演进式架构
一句话总结: 架构不是一次性蓝图,而是随着业务复杂度的提升逐步演进:从模块化单体到微服务,靠的是治理能力而非过早拆分。
多数团队掉进两个极端:一开始就上微服务(拆分过度,运维爆炸),或从不重构(大泥球,改哪崩哪)。演进式架构主张:先模块化单体,用明确边界 + 测试保护 + 契约约束,当某个模块真正独立演进时才拆出为服务。
// 模块化单体:按领域划项目/程序集
// src/
// Orders.Domain/ 领域模型、聚合
// Orders.Application/ 用例、MediatR Handler
// Orders.Infrastructure/ EF、仓储实现
// Orders.Api/ 表现层、DI 组装
// Shared.Kernel/ 通用基座(结果类型、领域事件基类)
// DI 组装:把模块的依赖集中注册,避免循环引用
builder.Services.AddOrdersModule(builder.Configuration);
builder.Services.AddPaymentsModule(builder.Configuration);
| 演进阶段 | 手段 | 触发拆分信号 |
|---|---|---|
| 模块化单体 | 程序集边界 + 接口 | 团队数 > 2,模块部署频率冲突 |
| 模块化单体 + 队列 | 领域事件桥接消息 | 模块间同步耦合过深 |
| 微服务 | 独立部署单元 | 独立扩展需求、独立团队所有权 |
| 平台化 | 共享平台 + 租户隔离 | 多团队多产品复用 |
避坑: 拆分微服务的信号是「独立部署与独立扩展的现实需求」,不是「代码看着大」。拆之前先保证:模块边界清晰、测试覆盖到位、契约稳定、可观测性成熟。模块化单体的失败模式是边界被突破——所以接口方向(依赖倒置)与代码评审纪律,比架构图更重要。
8. 总结
| 主题 | 要点 |
|---|---|
| DDD 分层 | 依赖方向约束:领域不依赖基础设施 |
| 聚合建模 | 一致性边界,聚合根是唯一入口 |
| CQRS | 写走领域模型、读走专用读模型 |
| MediatR | 中介者路由用例,管道承载横切逻辑 |
| 结果类型 | 业务失败用 Result,意外故障用异常 |
| 领域事件 | 已发生的事实,事务提交后异步发布 |
| 演进式架构 | 模块化单体起步,按信号拆分 |
架构模式的价值不在「用了 DDD / CQRS / MediatR」的名号,而在让业务复杂度被有序承载:分层守住依赖方向,聚合保住不变量,CQRS 让读写各得其所,MediatR 收敛用例编排,Result 显式处理失败,领域事件解耦副作用,演进式策略避免过度设计。对多数 .NET 后端,从模块化单体 + 明确的领域边界 + 一套统一用例管道开始,比一开始就铺开全套 DDD + 微服务要务实得多。架构是持续的选择,不是一次性的交付。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。