Skip to content
Go back

Vertical Slice Architecture 生产落地指南:.NET 项目结构的最佳布局

Vertical Slice Architecture(VSA)按 feature 而不是技术层组织项目。传统分层项目里,Controllers、Services、Repositories 各占一个文件夹,改一个功能要在三四个目录之间横跳。VSA 的做法是把一个 feature 需要的一切放在一起——endpoint、业务逻辑、校验、数据访问,都在同一个文件夹里。

这带来的好处很直接:feature 内部高内聚、feature 之间松耦合、代码导航更快、改一个 feature 不影响其他 feature。

我(原作者 Anton Martyniuk)在生产环境用 VSA 交付过不少系统,几年下来布局收敛到了一个固定形状,现在每个新项目都复用。这篇文章就展示这个布局——从切片内部结构到模块间通信。

生产级 VSA 布局总览

每个垂直切片放在以用例命名的文件夹里。市面上有三种常见风格:

风格 1:每个类一个文件

Features/
  CreateShipment/
    CreateShipmentRequest.cs
    CreateShipmentResponse.cs
    CreateShipmentEndpoint.cs
    CreateShipmentHandler.cs
    CreateShipmentValidator.cs

风格 2:单文件 + 嵌套类

public static class CreateShipment
{
    public sealed record Request(...);
    public sealed record Response(...);
    public class Validator : AbstractValidator<Request> { }
    public static void MapEndpoint(WebApplication app) { ... }
    private static async Task<IResult> Handle() { ... }
}

风格 3:按关注点拆分——这也是我在生产中用的

结合前两种的优点:Request 和 Response 放在 Endpoint 文件里,文件数量不会太多,也不必把一切都嵌套在一个 static class 里。

以 “Create Shipment” 为例:

Features/
└── CreateShipment/
    ├── CreateShipment.Endpoint.cs
    ├── CreateShipment.Handler.cs
    ├── CreateShipment.Mapping.cs
    └── CreateShipment.Validators.cs

如果这个切片会发布事件给其他模块响应,再加一个 Events/ 子文件夹:

Features/
└── CreateShipment/
    ├── CreateShipment.Endpoint.cs
    ├── CreateShipment.Handler.cs
    ├── CreateShipment.Mapping.cs
    ├── CreateShipment.Validators.cs
    └── Events/
        ├── ShipmentCreatedEvent.cs
        ├── UpdateStockEventHandler.cs
        └── CreateCarrierEventHandler.cs

命名规范用 . 后缀:{Slice}.Endpoint.cs{Slice}.Handler.cs,方便在 IDE 搜索和文件树中快速定位。

Endpoint 文件

Endpoint 文件包含 Request/Response records 和 Minimal API endpoint 类。Endpoint 的职责只有三件事:解析 HTTP 请求、跑校验、调 handler 并翻译成 HTTP 响应。

public sealed record CreateShipmentRequest(
    string OrderId,
    Address Address,
    string Carrier,
    string ReceiverEmail,
    List<ShipmentItemRequest> Items);

public class CreateShipmentApiEndpoint : IApiEndpoint
{
    public void MapEndpoint(WebApplication app)
    {
        app.MapPost(RouteConsts.BaseRoute, Handle);
    }

    private static async Task<IResult> Handle(
        [FromBody] CreateShipmentRequest request,
        IValidator<CreateShipmentRequest> validator,
        ICreateShipmentHandler handler,
        CancellationToken cancellationToken)
    {
        var validationResult = await validator
            .ValidateAsync(request, cancellationToken);
        if (!validationResult.IsValid)
        {
            return Results.ValidationProblem(
                validationResult.ToDictionary());
        }

        var response = await handler
            .HandleAsync(request, cancellationToken);
        if (response.IsError)
        {
            return response.Errors.ToProblem();
        }

        return Results.Ok(response.Value);
    }
}

Endpoint 实现一个轻量的 marker 接口 IApiEndpoint,后面用它做自动注册:

public interface IApiEndpoint
{
    void MapEndpoint(WebApplication app);
}

校验在 endpoint 里显式调用,不用 MediatR pipeline behavior。整个校验流程保持简单、可追踪。

response.Errors.ToProblem() 是一个扩展方法,把业务错误类型(Conflict、NotFound、Validation 等)映射到对应的 HTTP 状态码。

Handler 文件

Handler 负责业务逻辑。这里不用 MediatR——只是实现 IHandler 这个 marker 接口的 plain class:

public interface IHandler;

Handler 类通过构造注入拿 DbContext、跨模块 API、事件发布器和 logger:

internal sealed class CreateShipmentHandler(
    ShipmentsDbContext context,
    IStockModuleApi stockApi,
    IEventPublisher eventPublisher,
    ILogger<CreateShipmentHandler> logger)
    : IHandler
{
    public async Task<Result<ShipmentResponse>> HandleAsync(
        CreateShipmentRequest request,
        CancellationToken cancellationToken)
    {
        var shipmentExists = await context.Shipments
            .AnyAsync(x => x.OrderId == request.OrderId,
                cancellationToken);

        if (shipmentExists)
        {
            return ShipmentErrors.AlreadyExists(request.OrderId);
        }

        var stockRequest = CreateCheckStockRequest(request);
        var stockResponse = await stockApi
            .CheckStockAsync(stockRequest, cancellationToken);
        if (!stockResponse.IsSuccess)
        {
            return stockResponse.Errors;
        }

        var shipmentNumber = new Faker().Commerce.Ean8();
        var shipment = request.MapToShipment(shipmentNumber);

        await context.Shipments.AddAsync(shipment, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        var shipmentCreatedEvent =
            new ShipmentCreatedEvent(shipment);
        await eventPublisher.PublishAsync(
            shipmentCreatedEvent, cancellationToken);

        return shipment.MapToResponse();
    }
}

结构很直:没有多余接口、没有 command、没有 MediatR、没有”魔法导航”。就是对一个确定类的直接调用。

对于预期内的错误,handler 用 Result Pattern 而不是抛异常。模块的错误定义集中在一个静态类里,保持风格一致:

internal static class ShipmentErrors
{
    private const string ErrorPrefix = "Shipments";

    internal static Error NotFound(string shipmentNumber) =>
        Error.NotFound(
            $"{ErrorPrefix}.{nameof(NotFound)}",
            $"Shipment with number '{shipmentNumber}' not found");

    internal static Error AlreadyExists(string orderId) =>
        Error.Conflict(
            $"{ErrorPrefix}.{nameof(AlreadyExists)}",
            $"Shipment for order '{orderId}' already exists");
}

**跨模块调用走 PublicApi。**handler 不碰 Stocks 模块的任何 internal 实现,只通过 IStockModuleApi 调用。这个接口由独立的 Modules.Stocks.PublicApi 项目对外暴露。

**事件在持久化后发布。**shipment 落库后再发 ShipmentCreatedEvent,其他切片和模块可以响应这个事件,而 handler 不需要知道它们的存在。

Mapping 和 Validation

对象映射全部手写,用静态扩展方法,不用 AutoMapper 或 Mapster:

internal static class CreateShipmentMappingExtensions
{
    public static Shipment MapToShipment(
        this CreateShipmentRequest request) { ... }

    public static ShipmentResponse MapToResponse(
        this Shipment shipment) { ... }
}

手写映射有两个好处:完全显式——能直接导航到并看清楚每一步;没有反射开销。如果某个映射被多个切片复用,就挪到模块的 Shared/ 文件夹,不重复。

校验用 FluentValidation,一个切片的所有 validator 放在一个文件里:

public class CreateShipmentRequestValidator
    : AbstractValidator<CreateShipmentRequest>
{
    public CreateShipmentRequestValidator()
    {
        RuleFor(s => s.OrderId).NotEmpty();
        RuleFor(s => s.Carrier).NotEmpty();
        RuleFor(s => s.ReceiverEmail).NotEmpty();
        RuleFor(s => s.Items).NotEmpty();

        RuleFor(s => s.Address)
            .Cascade(CascadeMode.Stop)
            .NotNull()
            .SetValidator(new AddressValidator());
    }
}

模块内的 Shared 文件夹

同一个模块里多个切片会共用一些东西:路由常量、错误定义、响应类型。这些放在模块 Features/ 下的 Shared/ 文件夹里:

Features/
├── CreateShipment/
├── DispatchShipment/
├── GetShipmentByNumber/
└── Shared/
    ├── Errors/
    │   └── ShipmentErrors.cs
    ├── Requests/
    │   └── ShipmentItemRequest.cs
    ├── Responses/
    │   ├── ShipmentResponse.cs
    │   └── ShipmentItemResponse.cs
    └── Routes/
        └── RouteConsts.cs

路由常量集中管理,修改时不用在多个切片文件里搜索:

internal static class RouteConsts
{
    internal const string BaseRoute = "/api/shipments";
    internal const string GetByNumber =
        $"{BaseRoute}/{{shipmentNumber}}";
    internal const string CancelShipment =
        $"{BaseRoute}/cancel/{{shipmentNumber}}";
}

Endpoint、Handler 和 Validator 的自动注册

每个切片至少四个文件——如果每个都手动在 Program.cs 里注册,DI 代码会爆炸。这里用反射扫描程序集并自动注册。

Endpoint 注册:

public static IServiceCollection
    RegisterApiEndpointsFromAssemblyContaining(
        this IServiceCollection services, Type marker)
{
    var assembly = marker.Assembly;
    var endpointTypes = assembly.GetTypes()
        .Where(t => t.IsAssignableTo(typeof(IApiEndpoint))
            && t is { IsClass: true, IsAbstract: false });

    var descriptors = endpointTypes
        .Select(type => ServiceDescriptor.Transient(
            typeof(IApiEndpoint), type))
        .ToArray();

    services.TryAddEnumerable(descriptors);
    return services;
}

public static WebApplication MapApiEndpoints(
    this WebApplication app)
{
    var endpoints = app.Services
        .GetRequiredService<IEnumerable<IApiEndpoint>>();
    foreach (var endpoint in endpoints)
    {
        endpoint.MapEndpoint(app);
    }
    return app;
}

Handler 注册:

public static IServiceCollection
    RegisterHandlersFromAssemblyContaining(
        this IServiceCollection services, Type marker)
{
    var assembly = marker.Assembly;
    var handlerTypes = assembly.GetTypes()
        .Where(t => t is { IsClass: true, IsAbstract: false }
            && t.IsAssignableTo(typeof(IHandler))
            && !t.IsAssignableTo(typeof(IEventHandler)));

    foreach (var implementationType in handlerTypes)
    {
        var interfaceType = implementationType
            .GetInterfaces()
            .FirstOrDefault(i =>
                i != typeof(IHandler)
                && i.IsAssignableTo(typeof(IHandler)));

        if (interfaceType is not null)
        {
            services.AddScoped(interfaceType, implementationType);
        }
    }
    return services;
}

新增一个切片时不需要碰任何 DI 注册代码——只要类实现了 IHandlerIApiEndpoint,就会被自动发现。

切片间的事件通信

切片和模块之间通过事件和方法调用通信。“Create Shipment” 完成后,需要更新库存(Stocks 模块)和注册承运商(Carriers 模块)。事件和 handler 各占一个文件,放在切片内的 Events/ 子文件夹:

public sealed class UpdateStockEventHandler(
    IStockModuleApi stockApi,
    ILogger<UpdateStockEventHandler> logger)
    : IEventHandler<ShipmentCreatedEvent>
{
    public async Task HandleAsync(
        ShipmentCreatedEvent @event,
        CancellationToken cancellationToken)
    {
        var updateRequest = CreateDecreaseStockRequest(
            @event.Shipment);
        var response = await stockApi.DecreaseStockAsync(
            updateRequest, cancellationToken);

        if (!response.IsSuccess)
        {
            throw new Exception(
                $"Failed to update stock: {response.Errors}");
        }
    }
}

事件通过 IEventPublisher 派发。发布者不知道谁在监听,监听者不依赖发布者——典型的松耦合。

模块间通过 PublicApi 通信

在 Modular Monolith 里,模块不能碰彼此的 internal 实现。模块之间只通过公开接口或事件通信。每个模块有一个独立的 Modules.{Module}.PublicApi 项目,里面只有接口和对应的 request/response record:

public interface IStockModuleApi
{
    Task<Result<Success>> CheckStockAsync(
        CheckStockRequest request,
        CancellationToken cancellationToken);

    Task<Result<Success>> DecreaseStockAsync(
        DecreaseStockRequest request,
        CancellationToken cancellationToken);
}

Shipments 模块只引用 Modules.Stocks.PublicApi,不能引用 Stock 的 domain entity、DbContext 或 internal service。IStockModuleApi 的实现放在 Stock 模块内部,标记为 internal sealed

这给了你微服务的分离好处(清晰契约、不共享内部实现),同时保持单体部署的简单。如果将来把某个模块拆成独立服务,PublicApi 契约不用动,只换底层传输实现。

小结

这个切片布局现在是我每个新 .NET 项目的起点:

实际效果:导航快、团队内可预测、调试直接(没有装饰器、没有魔法)、测试简单、第三方依赖少。

如果你关注 AI 助手、开发工具和软件工程实践,可以关注 Aide Hub。这里会继续分享能落地的工具教程、技术观察和项目经验。

参考


Tags


Previous

一位资深 .NET 开发者的 20+ 条实战建议:从 DI、Async 到 EF Core 优化

Next

Interpreter 模式最佳实践:让 C# 表达式解析代码干净且可维护