ASP.NET Core Web API 架构模式:从三层、REPR 到 DDD
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 只是为了突出端点结构。生产系统应根据业务改用 Roles、Policies 或其他授权配置。创建资源时还可以使用 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 适合规则复杂、状态变化严格、业务语言重要的领域。它不是把项目拆成 Domain、Application、Infrastructure 三个目录,也不是给实体增加几个方法就完成了建模。
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 请求在不知情的情况下开启多次独立提交。
校验边界
不同校验应放在不同位置:
- 格式和必填项属于请求校验。
- 当前用户能否操作资源属于授权。
- 订单能否确认属于领域规则。
- 数据库唯一约束是并发情况下的最终保护。
错误边界
业务异常不应直接把堆栈信息返回给客户端。可以通过 IExceptionHandler 和 AddProblemDetails 统一转换错误,同时保留 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 | 外部依赖多、核心逻辑需隔离 | 依赖方向明确、便于测试 | 过度抽象和转发 |
| REPR | API 用例多、Controller 膨胀 | HTTP 契约高内聚 | Endpoint 承担过多业务 |
| 垂直切片 | 功能持续增长、多人并行 | 修改范围集中、用例自治 | 重复代码、跨切片耦合 |
| CQRS | 读写复杂度明显不同 | 分别优化查询与命令 | 模型数量和一致性成本 |
| DDD | 规则复杂、生命周期严格 | 模型表达业务并保护不变量 | 学习和建模成本高 |
| 模块化单体 | 多业务域但不需要独立部署 | 边界清晰、部署简单 | 边界缺乏约束时退化 |
🛤️ 推荐的演进路径
对于多数 ASP.NET Core Web API,可以按复杂度逐步演进:
- 小型系统从简单 Controller 或 REPR 开始,不急于建立大量抽象。
- 用例增多后,按垂直切片组织代码,控制修改范围。
- 外部依赖变多时,用 Clean Architecture 的依赖规则隔离核心逻辑。
- 读取与写入复杂度明显分化时,局部引入 CQRS。
- 只在复杂核心领域使用 DDD,不为普通 CRUD 创建聚合仪式。
- 业务区域增加后,通过模块化单体明确边界,再判断是否需要拆分服务。
架构不是一次性设计出来的目录结构,而是随着业务变化持续维护的边界。好的模式让常见修改集中、复杂规则有归属、错误能够被诊断;如果增加一个字段需要跨越十几个抽象层,或者任何模块都能直接修改任何数据,那么无论使用了多少架构名词,都没有真正降低复杂度。