C#怎么实现CQRS模式?MediatR命令查询分离实战教程

来源:开发教程作者:北京网站建设头衔:草根站长
导读:本期聚焦于小伙伴创作的《C#怎么实现CQRS模式?MediatR命令查询分离实战教程》,敬请观看详情。把写操作和读操作硬塞进同一个服务里,往往会让业务代码越改越乱,事务边界也难以控制。CQRS通过彻底分离命令与查询,让写模型专注领域规则、读模型直连优化视图。本文以C#配合MediatR为例,说明如何用INotificationHandler与IRequestHandler拆解增改与查询,并给出可运行的命令校验、无返回值写操作、以及避免读模型污染写模型的落地代码。掌握这套结构后,复杂业务下的接口清晰度和可测试性都会明显提升。

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

C#怎么实现CQRS模式?MediatR命令查询分离实战教程

一、CQRS与MediatR核心概念

CQRS全称是命令查询职责分离,它并不是强制你使用事件溯源或读写分库,而是一种设计原则:修改系统状态的方法(命令)不应该返回数据,返回数据的方法(查询)不应该修改状态。这样可以让写侧围绕领域聚合设计,读侧直接拼装DTO,绕开繁琐的实体映射。

MediatR通过IRequestINotification两类消息实现中介者模式。命令通常实现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契约的前提下,逐步演进后台结构。

CQRSMediatR命令查询分离修改时间:2026-08-09 18:42:57

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。