在复杂业务系统中,将数据的修改与读取混在同一个接口或方法内,会导致领域模型膨胀、事务难以控制,也让单元测试变得脆弱。CQRS(Command Query Responsibility Segregation)主张把命令(写操作)和查询(读操作)拆分为两套独立模型,而MediatR作为轻量中介者库,能用统一的消息管道优雅地承载这种分离。下面以C#控制台与Web API混合示例,展示如何落地。

一、CQRS与MediatR核心概念
CQRS全称是命令查询职责分离,它并不是强制你使用事件溯源或读写分库,而是一种设计原则:修改系统状态的方法(命令)不应该返回数据,返回数据的方法(查询)不应该修改状态。这样可以让写侧围绕领域聚合设计,读侧直接拼装DTO,绕开繁琐的实体映射。
MediatR通过IRequest与INotification两类消息实现中介者模式。命令通常实现IRequest<TResponse>,由IRequestHandler处理;若命令不需要返回值,可使用Unit作为响应类型。查询则实现带返回类型的IRequest,保证调用方只拿数据不改状态。这种消息分发机制天然契合CQRS的边界划分。
1.1 为什么不用同一套服务
传统三层架构里,一个UserService既包含RegisterUser也包含GetUserList,随业务增长,写逻辑的事务、校验、领域事件会污染读方法,读方法为了复用又被迫加载全部导航属性,引发性能浪费。分离后,读模型可直查视图或投影表,写模型只管聚合一致性。
另一个容易被忽视的点是测试成本。混合服务在单测时,为了测一个查询不得不mock一堆写依赖。CQRS配合MediatR后,查询处理器只依赖读仓储,命令处理器只依赖写仓储,测试粒度更细,构建速度也更快。
二、项目结构与依赖注入
先创建.NET 6及以上项目,引入MediatR包。在Program.cs中注册MediatR并扫描Handlers程序集,同时分别注册读写仓储,确保物理隔离。
// Program.cs
using MediatR;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// 注册MediatR,自动发现Handlers
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// 写侧仓储
builder.Services.AddScoped<IWriteUserRepository, EfWriteUserRepository>();
// 读侧仓储
builder.Services.AddScoped<IReadUserRepository, DapperReadUserRepository>();
var app = builder.Build();
app.MapGet("/", () => "CQRS with MediatR");
app.Run();
上面代码中,写仓储基于EF Core保证聚合一致性,读仓储用Dapper直连查询视图,两者接口完全独立。MediatR的RegisterServicesFromAssembly会扫描当前程序集下所有Handler,无需手动逐个注册。
这种注册方式在大型项目里也方便拆分:如果读写Handler分属不同类库,可多次调用RegisterServicesFromAssembly传入不同Assembly,保持边界清晰。
三、命令侧实现:创建用户
命令代表意图,应包含执行所需的所有参数,且不应暴露内部实体。下面定义CreateUserCommand及其处理器,写操作返回新建用户ID,但内部不掺杂任何查询逻辑。
// 命令定义
public class CreateUserCommand : IRequest<int>
{
public string UserName { get; set; }
public string Email { get; set; }
}
// 命令处理器
public class CreateUserHandler : IRequestHandler<CreateUserCommand, int>
{
private readonly IWriteUserRepository _repo;
public CreateUserHandler(IWriteUserRepository repo)
{
_repo = repo;
}
public async Task<int> Handle(CreateUserCommand request, CancellationToken ct)
{
// 领域校验
if (string.IsNullOrWhiteSpace(request.UserName))
throw new ArgumentException("用户名不能为空");
var user = new User(request.UserName, request.Email);
await _repo.AddAsync(user, ct);
await _repo.SaveChangesAsync(ct);
return user.Id;
}
}
在Handler中,我们只做聚合构建与持久化,不返回用户详情列表,也不调用读仓储。如果创建后需要通知其他系统,可发布INotification,由独立Handler异步处理,进一步解耦。
需要强调的是,命令处理器抛出的异常应当是领域异常,而非数据库异常。上层API可统一捕获并转为合适HTTP状态码,保证写模型对调用方只暴露业务语义。
3.1 无返回值命令示例
某些写操作如“禁用用户”不需要返回标识,可使用Unit。
public class DisableUserCommand : IRequest<Unit>
{
public int UserId { get; set; }
}
public class DisableUserHandler : IRequestHandler<DisableUserCommand, Unit>
{
private readonly IWriteUserRepository _repo;
public DisableUserHandler(IWriteUserRepository repo) => _repo = repo;
public async Task<Unit> Handle(DisableUserCommand cmd, CancellationToken ct)
{
var user = await _repo.GetByIdAsync(cmd.UserId, ct);
user.Disable();
await _repo.SaveChangesAsync(ct);
return Unit.Value;
}
}
使用Unit.Value明确表达“无业务返回值”,调用方通过是否抛异常判断成败,符合CQRS命令侧不返回数据的约定。
四、查询侧实现:获取用户列表
查询对象只携带筛选条件,处理器直接面向读模型,不经过领域层。下面示例返回扁平DTO,避免实体序列化带来的循环引用与过度抓取。
public class GetUserListQuery : IRequest<List<UserDto>>
{
public string Keyword { get; set; }
}
public class GetUserListHandler : IRequestHandler<GetUserListQuery, List<UserDto>>
{
private readonly IReadUserRepository _readRepo;
public GetUserListHandler(IReadUserRepository readRepo) => _readRepo = readRepo;
public async Task<List<UserDto>> Handle(GetUserListQuery q, CancellationToken ct)
{
return await _readRepo.QueryAsync(q.Keyword, ct);
}
}
public record UserDto(int Id, string UserName, string Email);
查询处理器没有注入写仓储,从编译期就杜绝了误改状态的可能。读仓储内部可用Dapper执行联表或视图查询,返回速度远快于通过领域实体再映射。
当界面需要不同形状的数据时,可新增独立Query与Handler,例如GetUserDetailQuery,彼此互不影响,也不用改原有写模型,这是CQRS在演进速度上的明显优势。
五、在API中调用与验证分离
控制器或Minimal API只负责接收入参并发送给MediatR,自身不含业务规则。借助管道行为(Pipeline Behavior),还能统一做校验、日志、事务。
app.MapPost("/users", async (CreateUserCommand cmd, IMediator m) =>
{
var id = await m.Send(cmd);
return Results.Ok(new { Id = id });
});
app.MapGet("/users", async (string keyword, IMediator m) =>
{
var list = await m.Send(new GetUserListQuery { Keyword = keyword });
return Results.Ok(list);
});
上述端点非常薄,所有复杂度下沉到Handler。若需对命令做前置校验,可实现IPipelineBehavior<TRequest, TResponse>,在next()前检查IRequest上的特性或规则,实现横切关注点复用。
最后提醒,CQRS不意味着必须分库分表。中小项目用同一数据库、不同仓储接口即可获得清晰度收益;只有当读写负载差异极大时,才考虑物理分离。MediatR让你在不改动API契约的前提下,逐步演进后台结构。