架构模式与实践

系统讲解 .NET 后端架构的模式与实践,覆盖 DDD 分层与聚合建模、CQRS 读写分离、MediatR 中介者模式、结果类型与错误处理、领域事件驱动,以及演进式架构的落地策略。

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 + 微服务要务实得多。架构是持续的选择,不是一次性的交付。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排