ASP.NET Core Web API 的架构设计,经常从一个看似简单的问题开始:Controller、Service 和 Repository 应该怎么分层?随着系统增长,团队又会陆续接触 Clean Architecture、REPR、垂直切片、CQRS、DDD 等概念。

这些模式并不处在同一个维度,也不是只能选择其中一种。三层架构和垂直切片主要讨论代码如何组织;Clean Architecture 关注依赖方向;CQRS 关注读写模型是否分离;DDD 关注如何表达复杂业务;REPR 则把一个 HTTP 用例收敛为请求、端点和响应。

真正重要的不是项目使用了多少模式,而是每个模式是否解决了实际存在的问题。

🧭 架构模式解决的是不同问题

可以先把常见模式放到不同的设计维度中:

设计问题常见模式关注点
代码按什么方向组织三层、分层架构、垂直切片按技术职责还是业务用例组织
核心代码依赖谁Clean、Onion、Hexagonal业务规则是否依赖数据库和框架
一个 HTTP 用例如何表达Controller、REPR、Minimal API请求、处理逻辑和响应是否聚合
读写模型是否一致CQRS查询与命令是否需要分别优化
复杂业务如何建模DDD聚合、值对象、领域服务和业务语言
大型单体如何划分边界模块化单体模块自治、内部契约和数据边界

因此,一个系统完全可以同时采用:

  • 模块化单体作为整体部署形态。
  • DDD 划分领域边界并建模核心业务。
  • 垂直切片组织应用层用例。
  • REPR 组织 HTTP 端点。
  • CQRS 分离复杂查询和状态变更。
  • Clean Architecture 控制模块内部的依赖方向。

不要先决定模式,再让业务迁就模式。应先识别复杂度来自哪里,再选择最小且足够的方案。

🏗️ 传统三层架构

传统三层模式通常将系统拆为表示层、业务逻辑层和数据访问层。在 ASP.NET Core Web API 中,经常对应以下结构:

这里的“三层”首先是代码职责上的逻辑分层,不等于必须部署三套进程。只有表示层、业务服务和数据服务分别运行在不同节点时,才属于物理意义上的多层部署。不要为了符合名称而人为增加网络边界。

src/
├─ Api/
│  └─ Controllers/
├─ Application/
│  └─ Services/
└─ Infrastructure/
   └─ Repositories/

一次创建订单请求按照固定方向流动:

HTTP Request

OrdersController

OrderService

OrderRepository

Database

典型实现

Controller 只处理 HTTP 协议,将业务操作交给 Service:

[ApiController]
[Route("api/orders")]
public sealed class OrdersController(IOrderService orderService)
    : ControllerBase
{
    [HttpPost]
    [ProducesResponseType(
        typeof(CreateOrderResponse),
        StatusCodes.Status201Created)]
    public async Task<ActionResult<CreateOrderResponse>> Create(
        CreateOrderRequest request,
        CancellationToken cancellationToken)
    {
        var order = await orderService.CreateAsync(
            request,
            cancellationToken);

        return CreatedAtAction(
            nameof(GetById),
            new { id = order.Id },
            order);
    }

    [HttpGet("{id:guid}")]
    public async Task<ActionResult<OrderDetailsResponse>> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var order = await orderService.GetByIdAsync(
            id,
            cancellationToken);

        return order is null ? NotFound() : Ok(order);
    }
}

Service 编排业务规则和事务:

public sealed class OrderService(
    IOrderRepository repository,
    IUnitOfWork unitOfWork) : IOrderService
{
    public async Task<CreateOrderResponse> CreateAsync(
        CreateOrderRequest request,
        CancellationToken cancellationToken)
    {
        if (request.Lines.Count == 0)
        {
            throw new BusinessException("订单至少需要一个明细项。");
        }

        var order = new OrderEntity
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            Status = OrderStatus.Draft
        };

        await repository.AddAsync(order, cancellationToken);
        await unitOfWork.SaveChangesAsync(cancellationToken);

        return new CreateOrderResponse(order.Id, order.Status);
    }
}

这种结构直观、学习成本低,适合以数据维护为主、业务规则较少的管理系统。它的问题通常不是“三层”本身,而是项目增长后所有功能都被塞进几个横向目录:

  • OrdersController 不断增加操作,最终包含几十个 Action。
  • OrderService 逐渐变成拥有大量依赖的上帝类。
  • 修改一个用例需要同时跳转 Controller、Service、Repository 和 DTO 目录。
  • 所有业务都被强制套入相同层级,即使某些查询只需要一条 SQL。
  • Service 容易只做 DTO 搬运,无法真正承载业务规则。

三层模式适合从简单系统起步,但需要持续控制类的职责和依赖数量。给每张表生成 Controller、Service、Repository,并不等于完成了架构设计。

🧅 Clean、Onion 与 Hexagonal Architecture

Clean Architecture、Onion Architecture 和 Hexagonal Architecture 的表达方式不同,但核心目标相近:让业务规则位于内部,框架、数据库、消息系统和外部服务位于外部,依赖只能指向更稳定的内层。

一个常见结构如下:

src/
├─ Api/                 # HTTP、认证、序列化、依赖注入
├─ Application/         # 用例、命令、查询、端口接口
├─ Domain/              # 实体、值对象、领域规则
└─ Infrastructure/      # EF Core、Dapper、Redis、外部服务

关键不在项目数量,而在依赖方向:

Api ───────────────→ Application ←──────── Infrastructure

                         Domain

Api 通常还是应用的组合根,需要在启动阶段调用 AddInfrastructure 完成数据库和外部服务注册,因此可能存在对 Infrastructure 程序集的引用。这个引用应限制在装配入口,业务 Endpoint 和 Controller 仍然只依赖 Application 提供的用例与契约。

例如,应用层定义保存订单所需要的端口:

public interface IOrderRepository
{
    Task<Order?> GetAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task AddAsync(
        Order order,
        CancellationToken cancellationToken);
}

Infrastructure 可以使用 EF Core 或 Dapper 实现这个接口,但 Application 和 Domain 不需要知道数据库类型。这样做的收益包括:

  • 核心用例不直接依赖 ASP.NET Core、EF Core 或具体数据库。
  • 外部实现可以替换,测试时也更容易控制边界。
  • 业务规则与传输协议、持久化细节分离。

它同样可能被滥用。如果每一个简单查询都创建接口、实现、DTO、映射器和多层转发,代码量会远大于业务本身。抽象的价值来自变化边界,而不是文件数量。

🎯 REPR:Request、Endpoint、Response

REPR 将一个 API 用例组织为三个紧密相关的部分:

  • Request:端点接收的输入契约。
  • Endpoint:处理一次 HTTP 用例。
  • Response:端点输出的响应契约。

它避免按资源创建越来越庞大的 Controller,而是让一个端点只负责一个操作。目录通常围绕功能组织:

Features/
└─ Orders/
   ├─ Create/
   │  ├─ CreateOrderRequest.cs
   │  ├─ CreateOrderEndpoint.cs
   │  └─ CreateOrderResponse.cs
   └─ GetById/
      ├─ GetOrderRequest.cs
      ├─ GetOrderEndpoint.cs
      └─ GetOrderResponse.cs

REPR 不是 ASP.NET Core 官方规定的架构,也不依赖特定类库。Controller 和 Minimal API 都能实现 REPR;在 .NET 社区中,FastEndpoints 则是一个直接围绕 REPR 设计的流行库。它为端点发现、模型绑定、验证、授权和响应发送提供了约定,同时保留 ASP.NET Core 原有的依赖注入和中间件能力。

使用 FastEndpoints 实现 REPR

先安装核心包:

dotnet add package FastEndpoints

Program.cs 中注册服务和端点中间件:

using FastEndpoints;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddFastEndpoints();
builder.Services.AddScoped<CreateOrderHandler>();

var app = builder.Build();

app.UseFastEndpoints();

app.Run();

AddFastEndpoints 扫描并注册端点、验证器等组件,UseFastEndpoints 将发现的端点加入 ASP.NET Core 请求管道。实际项目如果启用了认证与授权,应在 UseFastEndpoints 之前调用相应中间件。

Request 和 Response 仍然是当前用例自己的 HTTP 契约:

public sealed record CreateOrderRequest(
    Guid CustomerId,
    IReadOnlyList<CreateOrderLineRequest> Lines);

public sealed record CreateOrderLineRequest(
    Guid ProductId,
    int Quantity);

public sealed record CreateOrderResponse(
    Guid Id,
    string Status);

验证器与 Request 放在同一个功能目录。FastEndpoints 的 Validator<TRequest> 基于 FluentValidation,验证失败时会在进入 Endpoint 前返回 400 响应:

using FastEndpoints;

public sealed class CreateOrderValidator
    : Validator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(request => request.CustomerId)
            .NotEmpty()
            .WithMessage("客户不能为空。");

        RuleFor(request => request.Lines)
            .NotEmpty()
            .WithMessage("订单至少需要一个明细项。");

        RuleForEach(request => request.Lines)
            .ChildRules(line =>
            {
                line.RuleFor(item => item.ProductId)
                    .NotEmpty();

                line.RuleFor(item => item.Quantity)
                    .GreaterThan(0);
            });
    }
}

Endpoint 继承 Endpoint<TRequest, TResponse>,在 Configure 中声明 HTTP 契约,在 HandleAsync 中执行当前用例:

using FastEndpoints;

public sealed class CreateOrderEndpoint(
    CreateOrderHandler handler)
    : Endpoint<CreateOrderRequest, CreateOrderResponse>
{
    public override void Configure()
    {
        Post("/api/orders");
        AllowAnonymous();
    }

    public override async Task HandleAsync(
        CreateOrderRequest request,
        CancellationToken cancellationToken)
    {
        var command = new CreateOrderCommand(
            request.CustomerId,
            request.Lines.Select(line => new CreateOrderLine(
                line.ProductId,
                line.Quantity)).ToArray());

        var result = await handler.HandleAsync(
            command,
            cancellationToken);

        var response = new CreateOrderResponse(
            result.Id,
            result.Status);

        await Send.ResponseAsync(
            response,
            StatusCodes.Status201Created,
            cancellationToken);
    }
}

示例使用 AllowAnonymous 只是为了突出端点结构。生产系统应根据业务改用 RolesPolicies 或其他授权配置。创建资源时还可以使用 Send.CreatedAtAsync 返回 201 和 Location 响应头;查询目标端点明确时,FastEndpoints 支持通过 Endpoint 类型建立引用。

端点只依赖当前用例需要的 CreateOrderHandler,不必经过包含所有订单操作的 IOrderService。请求格式校验、授权、状态码和响应模型属于 Endpoint;事务编排交给应用层 Handler;复杂不变量由领域模型维护。

FastEndpoints 还提供 Pre/Post Processor、全局端点配置和统一错误响应等机制,适合封装审计、幂等、租户识别等横切能力。但不要把所有业务都放进 Processor,也不要让 Endpoint 直接持有 DbContext 完成复杂事务,否则只是把“胖 Controller”换成了“胖 Endpoint”。

REPR 的优势是高内聚:查看一个目录即可了解完整 HTTP 契约。代价是端点数量会明显增加,因此需要稳定的功能目录、命名约定和集成测试。对于只有少量接口的小项目,原生 Minimal API 已经足够;当端点数量增长并需要统一验证、授权和处理管道时,FastEndpoints 的约定会更有价值。

🧩 垂直切片架构

垂直切片架构按照业务用例组织代码。每个切片可以包含端点、输入输出、校验、应用逻辑和数据访问,而不是先把所有代码拆进横向技术层。

Features/
└─ Orders/
   ├─ CreateOrder/
   │  ├─ Endpoint.cs
   │  ├─ Command.cs
   │  ├─ Handler.cs
   │  └─ Validator.cs
   ├─ CancelOrder/
   │  ├─ Endpoint.cs
   │  ├─ Command.cs
   │  └─ Handler.cs
   └─ GetOrderDetails/
      ├─ Endpoint.cs
      ├─ Query.cs
      └─ Handler.cs

REPR 和垂直切片经常一起使用,但两者关注点不同:

  • REPR 描述一个 API 端点的 HTTP 形态。
  • 垂直切片描述从 HTTP 到业务和数据的一整个用例如何组织。

垂直切片允许不同用例选择不同实现。创建订单可以通过聚合和 EF Core 保证一致性;订单列表可以直接用 Dapper 查询只读模型;统计报表可以调用专门的分析数据库。它们不必被强制包装成完全相同的 Repository 接口。

这种方式适合功能持续增长、多人并行开发的系统。风险是切片之间复制公共规则,或绕过领域边界直接修改彼此数据。可以复用稳定的基础能力,但不要建立一个无所不包的 Common 目录重新制造全局耦合。

🔀 CQRS:分离命令和查询

CQRS 将改变系统状态的命令与读取数据的查询分开建模。

Command: CreateOrder → 校验规则 → 修改聚合 → 提交事务
Query:   GetOrderDetails → 读取投影 → 返回页面需要的结构

命令侧关注一致性和业务行为:

public sealed record ConfirmOrderCommand(Guid OrderId);

public sealed class ConfirmOrderHandler(
    IOrderRepository repository,
    IUnitOfWork unitOfWork)
{
    public async Task HandleAsync(
        ConfirmOrderCommand command,
        CancellationToken cancellationToken)
    {
        var order = await repository.GetAsync(
            command.OrderId,
            cancellationToken)
            ?? throw new OrderNotFoundException(command.OrderId);

        order.Confirm();

        await unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

查询侧可以直接生成面向客户端的投影:

public sealed record GetOrderDetailsQuery(Guid OrderId);

public sealed class GetOrderDetailsHandler(DbConnection connection)
{
    public Task<OrderDetailsResponse?> HandleAsync(
        GetOrderDetailsQuery query,
        CancellationToken cancellationToken)
    {
        const string sql = """
            select id,
                   customer_id as CustomerId,
                   status,
                   total_amount as TotalAmount
            from orders
            where id = @OrderId
            """;

        return connection.QuerySingleOrDefaultAsync<OrderDetailsResponse>(
            new CommandDefinition(
                sql,
                new { query.OrderId },
                cancellationToken: cancellationToken));
    }
}

CQRS 不等于必须使用 MediatR,也不等于微服务、事件溯源或两套数据库。最轻量的 CQRS 只是在代码层为读写建立不同模型。只有当读取规模、可用性或延迟要求确实不同,才需要进一步引入独立存储和异步投影。

🧠 DDD:为复杂业务建立模型

DDD 适合规则复杂、状态变化严格、业务语言重要的领域。它不是把项目拆成 DomainApplicationInfrastructure 三个目录,也不是给实体增加几个方法就完成了建模。

DDD 首先要求识别业务边界和统一语言,然后才是聚合、实体、值对象、领域服务、领域事件等战术模式。

使用聚合保护不变量

订单聚合应保证状态变化始终合法,而不是允许外部代码任意修改属性:

public sealed class Order
{
    private readonly List<OrderLine> _lines = [];

    public Guid Id { get; }
    public Guid CustomerId { get; }
    public OrderStatus Status { get; private set; }
    public IReadOnlyCollection<OrderLine> Lines => _lines;

    private Order(Guid id, Guid customerId)
    {
        Id = id;
        CustomerId = customerId;
        Status = OrderStatus.Draft;
    }

    public static Order Create(Guid customerId)
    {
        if (customerId == Guid.Empty)
        {
            throw new DomainException("客户不能为空。");
        }

        return new Order(Guid.NewGuid(), customerId);
    }

    public void AddLine(
        Guid productId,
        int quantity,
        decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
        {
            throw new DomainException("只有草稿订单可以添加明细。");
        }

        if (quantity <= 0 || unitPrice < 0)
        {
            throw new DomainException("数量和价格不合法。");
        }

        _lines.Add(new OrderLine(
            productId,
            quantity,
            unitPrice));
    }

    public void Confirm()
    {
        if (_lines.Count == 0)
        {
            throw new DomainException("空订单不能确认。");
        }

        if (Status != OrderStatus.Draft)
        {
            throw new DomainException("当前状态不能确认订单。");
        }

        Status = OrderStatus.Confirmed;
    }
}

应用层负责加载聚合、调用业务行为并提交事务;领域层负责决定操作是否合法;Infrastructure 负责把聚合持久化。Controller 或 Endpoint 不应该自己拼装状态变化。

DDD 的适用边界

下面这些信号说明 DDD 可能有价值:

  • 业务规则多,并且规则之间相互影响。
  • 同一概念在不同业务场景中含义不同。
  • 对象存在严格生命周期和状态迁移。
  • 数据一致性边界需要被明确保护。
  • 开发人员需要与领域专家持续建立统一语言。

如果系统主要是配置维护、报表查询和简单 CRUD,引入完整 DDD 往往只会增加映射与抽象。可以只在订单、结算、调度等核心复杂模块使用 DDD,外围模块继续使用事务脚本或简单切片。

🧱 模块化单体

当系统已经包含订单、权限、配置、统计等多个业务区域,但尚不需要独立部署时,模块化单体通常比直接拆微服务更稳妥。

src/
├─ Modules/
│  ├─ Orders/
│  │  ├─ Domain/
│  │  ├─ Features/
│  │  ├─ Infrastructure/
│  │  └─ OrdersModule.cs
│  ├─ Identity/
│  └─ Reporting/
└─ Api/
   └─ Program.cs

模块化单体仍然是一个部署单元,但要求模块拥有明确边界:

  • 模块通过公开契约交互,不直接引用对方内部实现。
  • 数据表可以位于同一个数据库,但应明确所有权。
  • 跨模块操作不能随意共享事务和修改对方数据。
  • 模块内部可以根据复杂度选择三层、垂直切片或 DDD。

这种结构为未来拆分服务保留可能性,同时避免过早引入分布式事务、网络故障、消息一致性和运维复杂度。

🔗 各种模式如何组合

一个中大型 Web API 可以采用下面的组合:

模块化单体
└─ Orders 模块
   ├─ Api:REPR Endpoint
   ├─ Application:垂直切片 + CQRS
   ├─ Domain:DDD 聚合和值对象
   └─ Infrastructure:EF Core 写入 + Dapper 查询

请求进入系统后的职责划分如下:

Request
  ↓ HTTP 绑定、认证、协议校验
REPR Endpoint
  ↓ 转换为 Command 或 Query
Vertical Slice Handler
  ↓ 编排用例与事务
Domain Aggregate / Query Model

Infrastructure

Response

这不是要求每个系统都采用的“终极架构”。如果一个模块只有几张配置表,简单 REPR 端点加 Dapper 就足够;只有核心领域才需要聚合和仓储。架构应允许不同复杂度的功能采用不同重量的方案。

📐 无论选择哪种模式,都要明确的边界

HTTP 边界

Endpoint 或 Controller 负责:

  • 路由、认证授权和输入绑定。
  • HTTP 层校验以及状态码映射。
  • 将业务结果转换为响应契约。
  • 通过 ProblemDetails 返回稳定的错误结构。

它不应直接实现复杂业务规则。

事务边界

一次命令通常对应一个明确事务。事务应由应用用例控制,而不是散落在多个 Repository 中。不要让一次 HTTP 请求在不知情的情况下开启多次独立提交。

校验边界

不同校验应放在不同位置:

  • 格式和必填项属于请求校验。
  • 当前用户能否操作资源属于授权。
  • 订单能否确认属于领域规则。
  • 数据库唯一约束是并发情况下的最终保护。

错误边界

业务异常不应直接把堆栈信息返回给客户端。可以通过 IExceptionHandlerAddProblemDetails 统一转换错误,同时保留 TraceId 便于日志关联。

可观测性边界

一次用例至少应记录:

  • TraceId 和业务实体编号。
  • 用例名称与执行耗时。
  • 结果类型,而不是只记录一段异常文本。
  • 外部依赖耗时和重试情况。

如果架构让日志、指标和追踪必须复制到每个 Handler 中,说明横切能力还没有被正确封装。

⚠️ 常见反模式

所有业务都经过万能 Service

一个 OrderService 同时处理创建、查询、导出、统计、权限判断和缓存,最终会形成高耦合类。可以按用例拆分 Handler,而不是继续增加方法。

为每张表创建泛型 Repository

泛型 Repository 往往只能提供 CRUD,无法表达有意义的业务查询,还会隐藏 EF Core 或 Dapper 已经提供的能力。仓储应服务于聚合或明确的数据访问边界。

只有目录,没有依赖规则

项目中虽然存在 Domain、Application 和 Infrastructure,但 Domain 仍然引用 EF Core,Application 仍然直接使用具体 Redis 客户端。这只是文件分类,不是 Clean Architecture。

使用了 MediatR 就声称是 CQRS

Mediator 只解决消息分发。命令与查询是否拥有不同模型、不同职责和不同优化方式,才是 CQRS 的关键。

所有实体属性都可以公开修改

如果任何代码都能直接设置 order.Status,聚合无法保护业务不变量。DDD 的价值来自行为和边界,而不是实体类放在哪个目录。

过早拆分微服务

边界尚未稳定时,拆分服务只会把代码耦合变成网络耦合。先在模块化单体中验证边界,通常更容易演进。

🧪 测试策略

不同层次验证不同风险:

测试类型主要验证内容
领域单元测试聚合状态变化、值对象和业务不变量
Handler 测试用例编排、权限、事务和错误分支
Infrastructure 集成测试SQL、ORM 映射、事务和外部组件
API 集成测试路由、绑定、认证、状态码和响应契约

ASP.NET Core 可以使用 WebApplicationFactory<Program> 启动测试宿主,对 Controller 或 Minimal API 进行端到端验证。不要为了让所有代码都能使用 Mock 而创建大量无意义接口;数据库查询是否正确,最终仍需要集成测试证明。

📊 选型对照

模式更适合主要收益主要风险
传统三层简单管理系统、稳定 CRUD直观、团队容易理解Service 膨胀、跨目录修改
Clean/Onion外部依赖多、核心逻辑需隔离依赖方向明确、便于测试过度抽象和转发
REPRAPI 用例多、Controller 膨胀HTTP 契约高内聚Endpoint 承担过多业务
垂直切片功能持续增长、多人并行修改范围集中、用例自治重复代码、跨切片耦合
CQRS读写复杂度明显不同分别优化查询与命令模型数量和一致性成本
DDD规则复杂、生命周期严格模型表达业务并保护不变量学习和建模成本高
模块化单体多业务域但不需要独立部署边界清晰、部署简单边界缺乏约束时退化

🛤️ 推荐的演进路径

对于多数 ASP.NET Core Web API,可以按复杂度逐步演进:

  1. 小型系统从简单 Controller 或 REPR 开始,不急于建立大量抽象。
  2. 用例增多后,按垂直切片组织代码,控制修改范围。
  3. 外部依赖变多时,用 Clean Architecture 的依赖规则隔离核心逻辑。
  4. 读取与写入复杂度明显分化时,局部引入 CQRS。
  5. 只在复杂核心领域使用 DDD,不为普通 CRUD 创建聚合仪式。
  6. 业务区域增加后,通过模块化单体明确边界,再判断是否需要拆分服务。

架构不是一次性设计出来的目录结构,而是随着业务变化持续维护的边界。好的模式让常见修改集中、复杂规则有归属、错误能够被诊断;如果增加一个字段需要跨越十几个抽象层,或者任何模块都能直接修改任何数据,那么无论使用了多少架构名词,都没有真正降低复杂度。

📚 延伸阅读