Skip to content
Go back

.NET 架构模式深度对比:N 层架构、整洁架构与垂直切片架构的权衡与选择

Published:  at  12:00 AM

.NET 架构模式深度对比:N 层架构、整洁架构与垂直切片架构的权衡与选择

引言

在现代 .NET 软件开发中,项目架构的选择是影响团队生产力、代码可维护性和功能交付速度的关键决策之一。一个合适的架构模式不仅能够加速新成员的上手速度,还能为长期的业务演进提供坚实的基础。

当前,.NET 生态系统中存在三种主流的项目架构方法:

  1. N 层架构(N-Layered Architecture):也称为 Controller-Service-Repository 模式
  2. 整洁架构(Clean Architecture):强调依赖倒置和领域驱动
  3. 垂直切片架构(Vertical Slice Architecture):以功能为中心的组织方式

每种架构模式都有其独特的优势和局限性,理解这些差异对于构建可扩展、可维护的企业级应用至关重要。本文将深入剖析这三种架构模式的核心理念、实际应用中的痛点,以及如何根据项目特点做出最优选择。

N 层架构:传统但依然流行的选择

核心概念与结构

N 层架构(N-Layered Architecture)是 .NET 开发中最广泛采用的架构模式,从简单的 CRUD 应用到复杂的企业级系统都能看到它的身影。这种架构的核心思想是将应用程序按照职责分离为多个逻辑层次。

典型的三层结构包括:

项目结构通常组织为:

/Controllers
/Services
/Repositories
/Models

代码示例

以下是一个典型的 N 层架构实现,展示了创建和查询货运(Shipment)实体的过程:

// Controller 层
[ApiController]
[Route("api/shipments")]
public class ShipmentsController : ControllerBase
{
    private readonly IShipmentService _service;

    public ShipmentsController(IShipmentService service)
    {
        _service = service;
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var shipment = await _service.GetShipmentByIdAsync(id);
        return Ok(shipment);
    }
}

// Service 层
public class ShipmentService : IShipmentService
{
    private readonly IShipmentRepository _repository;

    public ShipmentService(IShipmentRepository repository)
    {
        _repository = repository;
    }

    public async Task<ShipmentDto> GetShipmentByIdAsync(int id)
    {
        return await _repository.GetByIdAsync(id);
    }
}

// Repository 层
public class ShipmentRepository : IShipmentRepository
{
    private readonly ShipmentDbContext _dbContext;

    public ShipmentRepository(ShipmentDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<ShipmentDto> GetByIdAsync(int id)
    {
        return await _dbContext.Shipments
            .Where(s => s.Id == id)
            .Select(s => new ShipmentDto 
            { 
                Number = s.Number, 
                OrderId = s.OrderId 
            })
            .FirstOrDefaultAsync();
    }
}

N 层架构的优势

  1. 低学习曲线:几乎所有 .NET 开发者都熟悉这种模式,新团队成员能够快速理解代码组织方式
  2. 明确的职责分离:每一层都有清晰的职责边界,便于代码的初期组织
  3. 广泛的社区支持:大量的教程、示例和最佳实践可供参考

实践中的痛点

尽管 N 层架构看似简单明了,但在实际项目中会逐渐暴露出一些严重的问题。

1. 臃肿的 Controller 和 Service

随着业务需求的增长,Controller 和 Service 类往往会快速膨胀。一个最初只有 4 个 CRUD 方法的简单 Controller,经过几个月的迭代后可能演变成包含数十个方法的庞大类:

[ApiController]
[Route("api/[controller]")]
public class ShipmentsController : ControllerBase
{
    private readonly IShipmentService _shipmentService;

    public ShipmentsController(IShipmentService shipmentService)
    {
        _shipmentService = shipmentService;
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetShipment(int id);

    [HttpGet("user/{userId}")]
    public async Task<IActionResult> GetShipmentsByUser(int userId);

    [HttpGet("date-range")]
    public async Task<IActionResult> GetShipmentsByDateRange(DateTime from, DateTime to);

    [HttpPost]
    public async Task<IActionResult> CreateShipment(CreateShipmentRequest request);

    [HttpPut("{id}")]
    public async Task<IActionResult> UpdateShipment(int id, UpdateShipmentRequest request);

    [HttpPatch("{id}/status")]
    public async Task<IActionResult> UpdateShipmentStatus(int id, ShipmentStatus status);

    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteShipment(int id);

    [HttpPost("{id}/track")]
    public async Task<IActionResult> TrackShipment(int id);

    [HttpPost("{id}/cancel")]
    public async Task<IActionResult> CancelShipment(int id);

    [HttpPost("{id}/approve")]
    public async Task<IActionResult> ApproveShipment(int id);
    // 更多方法...
}

虽然理论上可以将这些端点拆分到多个 Controller 中,但在实际开发中,添加一个新方法往往比创建新的 Controller 更容易,导致单个类持续膨胀。Service 和 Repository 层的情况可能更糟。

2. 过多的小型 Service 和 Repository

当领域模型变得复杂时,开发团队会面临一个关键抉择:是否为每个实体创建独立的 Service 和 Repository?

例如,处理 ShipmentsShipmentItemsOrders 时,遵循传统的 N 层方法会导致:

public interface IShipmentRepository
{
    Task<ShipmentDto> GetByIdAsync(int id);
    Task<IEnumerable<ShipmentDto>> GetAllAsync();
    // ...
}

public interface IShipmentItemRepository
{
    Task<ShipmentItemDto> GetByIdAsync(int id);
    Task<IEnumerable<ShipmentItemDto>> GetByShipmentIdAsync(int shipmentId);
    // ...
}

public interface IOrderRepository
{
    Task<OrderDto> GetByIdAsync(int id);
    Task<IEnumerable<OrderDto>> GetByUserIdAsync(int userId);
    // ...
}

但当需要跨实体查询时会出现问题:

这些跨实体的方法应该放在哪里?

许多开发者最终创建了大量只包含 1-2 个方法的小型 Service 和 Repository,或者创建了试图处理所有相关实体的臃肿 Repository。当实现新功能时,开发者会困惑于应该在三个 Repository 中的哪一个添加新方法。

3. 分散的业务逻辑与难以测试的代码

在 N 层架构中,业务规则往往分散在多个 Service 类中,难以识别和理解。此外,由于没有严格的规则约束,开发者可能直接在 Controller 中调用 Repository,绕过 Service 层,或者省略接口直接传递实现,导致代码难以测试。

这导致以下尴尬的解决方案:

对于小型或简单的项目,N 层架构并非”糟糕”的选择,但对于追求快速变更、清晰代码和真正模块化的团队来说,它很少是最佳选项。随着团队构建更复杂的系统(如模块化单体、DDD 或微服务),N 层架构的局限性会变得更加明显。

整洁架构:领域驱动的严格分层

核心原则与层次设计

整洁架构(Clean Architecture)旨在通过明确的层次划分来分离应用程序的关注点,促进高内聚和低耦合。它为开发团队提供了开箱即用的架构纪律。

整洁架构包含以下层次:

典型的解决方案结构:

Domain/
Application/
  ├── Queries/
  ├── QueryHandlers/
  ├── Commands/
  └── CommandHandlers/
Infrastructure/
Presentation/

整洁架构的核心优势

整洁架构解决了 N 层架构的许多缺陷:

  1. 严格的关注点分离:所有层次的依赖都指向内部,应用层不能调用基础设施层的具体实现,只能依赖抽象接口。这使代码库更易于维护和理解。

  2. 高可测试性:通过将业务逻辑与基础设施和 UI 隔离,整洁架构使单元测试变得更加容易。应用程序的核心(用例和实体)可以在不关心外部依赖和具体实现的情况下进行测试。

  3. 灵活性:允许在对核心业务逻辑影响最小的情况下更换技术栈(例如,从一个数据库提供者切换到另一个)。这种灵活性通过将基础设施关注点抽象为核心应用依赖的接口来实现。

  4. 代码可重用性:通过解耦核心业务逻辑与实现细节,整洁架构鼓励在不同项目或同一项目的不同层之间重用代码。

典型实现:使用 MediatR 的用例处理

整洁架构通常使用 MediatR 或手动处理器在 API 端点中实现:

public class CreateShipmentEndpoint : IEndpoint
{
    public void MapEndpoint(WebApplication app)
    {
        app.MapPost("/api/shipments", Handle);
    }

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

        var command = request.MapToCommand();
        var response = await mediator.Send(command, cancellationToken);

        if (response.IsError)
        {
            return response.Errors.ToProblem();
        }

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

API 端点放置在表现层项目中,并调用应用层的处理器。处理器使用 Repository 接口,对基础设施层的具体实现一无所知:

internal sealed class CreateShipmentCommandHandler(
    IShipmentRepository repository,
    IUnitOfWork unitOfWork,
    ILogger<CreateShipmentCommandHandler> logger)
    : IRequestHandler<CreateShipmentCommand, ErrorOr<ShipmentResponse>>
{
    public async Task<ErrorOr<ShipmentResponse>> Handle(
        CreateShipmentCommand request,
        CancellationToken cancellationToken)
    {
        var shipmentAlreadyExists = await repository.ExistsByOrderIdAsync(
            request.OrderId, 
            cancellationToken);

        if (shipmentAlreadyExists)
        {
            logger.LogInformation(
                "Shipment for order '{OrderId}' is already created", 
                request.OrderId);
            
            return Error.Conflict(
                $"Shipment for order '{request.OrderId}' is already created");
        }

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

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

        logger.LogInformation("Created shipment: {@Shipment}", shipment);

        var response = shipment.MapToResponse();
        return response;
    }
}

实用的整洁架构:直接使用 EF Core

随着时间的推移,整洁架构已演变为更加实用的方法:开发者社区达成共识,可以在应用层用例中直接使用 EF Core,而不必创建 Repository 抽象。

这是否打破了整洁架构提供的所有好处?并非如此。原因如下:

  1. EF Core 本身已实现了 Repository 和工作单元模式:正如 EF Core 官方文档所述,DbContext 已经实现了这些模式。在 EF Core 之上创建 Repository,实际上是在抽象之上再创建抽象,导致过度工程化。

  2. 生产环境中更换数据库的频率极低:99% 的情况下不需要切换数据库。即使需要切换,也不仅仅是将 EF Core 替换为 MongoDB 那么简单。切换到完全不同的数据库可能需要完全重写应用程序,因为数据访问模式可能会发生显著变化。

  3. 同类 SQL 数据库迁移影响较小:当从一个 SQL 数据库切换到另一个(例如,Postgres → SQL Server)时,95% 的 EF Core 代码不需要更改。

  4. 测试与重复代码的权衡:对于单元测试,可以使用内存数据库(In-Memory DbContext),而集成测试更适合测试数据库逻辑。为了消除重复的 EF Core 查询,可以使用 Specification 模式。作为权衡,也可以为少数查询创建 Repository 以避免代码重复。

使用 EF Core 直接在应用层实现的 CommandHandler:

internal sealed class CreateShipmentCommandHandler(
    ShipmentsDbContext context,
    ILogger<CreateShipmentCommandHandler> logger)
    : IRequestHandler<CreateShipmentCommand, ErrorOr<ShipmentResponse>>
{
    public async Task<ErrorOr<ShipmentResponse>> Handle(
        CreateShipmentCommand request,
        CancellationToken cancellationToken)
    {
        var shipmentAlreadyExists = await context.Shipments
            .AnyAsync(x => x.OrderId == request.OrderId, cancellationToken);

        if (shipmentAlreadyExists)
        {
            logger.LogInformation(
                "Shipment for order '{OrderId}' is already created", 
                request.OrderId);
            
            return Error.Conflict(
                $"Shipment for order '{request.OrderId}' is already created");
        }

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

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

        logger.LogInformation("Created shipment: {@Shipment}", shipment);

        var response = shipment.MapToResponse();
        return response;
    }
}

富领域模型:将业务逻辑内聚到实体

整洁架构的演进还包括从贫血领域模型(Anemic Domain Model)转向富领域模型(Rich Domain Model)。贫血模型的问题在于,业务逻辑分散在多个 Service 类中,难以维护。

贫血模型示例

public class Shipment
{
    public Guid Id { get; set; }
    public string Number { get; set; }
    public string OrderId { get; set; }
    public Address Address { get; set; }
    public string Carrier { get; set; }
    public string ReceiverEmail { get; set; }
    public ShipmentStatus Status { get; set; }
    public List<ShipmentItem> Items { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
}

这个实体只是属性的容器,无法提供任何关于货运状态如何变化或添加项目时应执行哪些检查的信息。由于所有属性都公开了 setter,开发者可能从其他类直接更新属性,绕过应用层的任何检查。

富领域模型示例

public class Shipment
{
    private readonly List<ShipmentItem> _items = [];

    public Guid Id { get; private set; }
    public string Number { get; private set; }
    public string OrderId { get; private set; }
    public Address Address { get; private set; }
    public string Carrier { get; private set; }
    public string ReceiverEmail { get; private set; }
    public ShipmentStatus Status { get; private set; }
    public IReadOnlyList<ShipmentItem> Items => _items.AsReadOnly();
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }

    private Shipment() { }

    public static Shipment Create(
        string number,
        string orderId,
        Address address,
        string carrier,
        string receiverEmail,
        List<ShipmentItem> items)
    {
        var shipment = new Shipment 
        { 
            Id = Guid.NewGuid(),
            Number = number,
            OrderId = orderId,
            Address = address,
            Carrier = carrier,
            ReceiverEmail = receiverEmail,
            Status = ShipmentStatus.Created,
            CreatedAt = DateTime.UtcNow
        };
        
        shipment.AddItems(items);
        return shipment;
    }

    public void AddItem(ShipmentItem item)
    {
        // 验证逻辑
        _items.Add(item);
    }

    public void AddItems(List<ShipmentItem> items)
    {
        foreach (var item in items)
        {
            AddItem(item);
        }
    }

    public ErrorOr<Success> Process()
    {
        if (Status is not ShipmentStatus.Created)
        {
            return Error.Validation(
                "Can only update to Processing from Created status");
        }

        Status = ShipmentStatus.Processing;
        UpdatedAt = DateTime.UtcNow;
        return Result.Success;
    }

    public ErrorOr<Success> Dispatch()
    {
        if (Status is not ShipmentStatus.Processing)
        {
            return Error.Validation(
                "Can only update to Dispatched from Processing status");
        }

        Status = ShipmentStatus.Dispatched;
        UpdatedAt = DateTime.UtcNow;
        return Result.Success;
    }

    // 其他状态转换方法...
}

现在,Shipment 类不再公开属性的 setter,而是使用静态工厂方法 Create 来创建实例。这确保了创建实体的单一且正确的方式。此外,类提供了更新状态的方法(AddItem、Process、Dispatch、Cancel 等),将业务逻辑封装在单一位置,消除了业务规则分散和重复的问题。

功能文件夹组织方式

传统方法按技术关注点组织代码——将 Controllers、QueryHandlers、Services 和 Repositories 分离到不同的文件夹中。然而,开发者很快意识到这使得理解和修改功能变得困难,因为相关代码分散在多个文件夹中。

整洁架构已自然从技术文件夹(Technical Folders)演进为功能文件夹(Feature Folders)。功能文件夹将与特定用例相关的所有代码组织在一起。无需在多个项目和 5-7 个文件之间跳转来实现单个功能,现在可以在 /Features/Shipments/CreateShipment 文件夹中找到该功能所需的所有内容。

这使代码库更直观,并消除了实现单个功能时在多个项目和文件之间跳转的需要。

整洁架构的局限性

尽管整洁架构有诸多优势,但它并非银弹:

  1. 复杂性:引入多个层次和抽象会增加代码库的复杂性,特别是对于小型项目。如果将架构不必要地应用于简单应用程序,开发者可能会感到不知所措。

  2. 开销:关注点分离和接口的使用会导致额外的样板代码,可能减慢开发过程。这种开销在较小的项目中尤为明显,因为整洁架构的好处可能不那么显著。

  3. 学习曲线:对于不熟悉整洁架构的开发者来说,学习曲线陡峭。理解原则并正确应用它们需要时间,特别是对于刚接触软件架构模式的人。

  4. 初始设置时间:从头开始设置整洁架构项目需要仔细规划和组织。与 N 层架构相比,初始设置时间更长。

垂直切片架构:以功能为中心的现代方法

核心理念与结构

垂直切片架构(Vertical Slice Architecture,VSA)是当今非常流行的项目结构方式。它追求切片(功能)内的高内聚和切片之间的松耦合。

与按技术层次组织应用程序不同,垂直切片架构按功能来组织。每个切片封装特定功能的所有方面,包括 API、业务逻辑和数据访问。

典型的项目结构:

Features/
  ├── CreateShipment/
  │   ├── CreateShipmentEndpoint.cs
  │   ├── CreateShipmentHandler.cs
  │   ├── CreateShipmentRequest.cs
  │   └── CreateShipmentValidator.cs
  ├── GetShipment/
  │   ├── GetShipmentEndpoint.cs
  │   └── GetShipmentQuery.cs
  └── UpdateShipment/
      ├── UpdateShipmentEndpoint.cs
      ├── UpdateShipmentHandler.cs
      └── UpdateShipmentRequest.cs

每个文件夹代表一个功能(用例),每个功能可以包含一个或多个文件。对于具有少量端点的简单 CRUD 服务,直接在 API 端点中注入 DbContext 就足够了。对于更复杂的项目,可以为每个用例创建处理器。

垂直切片架构的优势

  1. 功能聚焦:变更被隔离到特定功能,减少了意外副作用的风险。

  2. 可扩展性:允许不同的开发者和团队独立工作于不同的功能,更容易扩展开发。

  3. 灵活性:允许在每个切片内根据需要使用不同的技术或方法。

  4. 可维护性:由于功能的所有方面都包含在单个切片中,因此更容易在解决方案中导航、理解和维护。

  5. 降低耦合:最小化不同切片之间的依赖关系。

垂直切片架构的局限性

  1. 潜在的代码重复:跨切片可能存在代码重复。

  2. 一致性:确保跨切片的一致性和管理横切关注点(如错误处理、日志记录、验证)需要仔细规划。

  3. 大量的类和文件:大型应用程序可能有很多垂直切片,每个切片包含多个小类。

对于前两个缺点,可以通过精心设计架构来处理。例如,可以将通用功能提取到独立的类中。为了管理横切关注点(如错误处理、日志记录和验证),可以使用 ASP.NET Core 中间件。良好结构的文件夹可以解决第三个缺点。

融合之道:整洁架构与垂直切片的结合

两者融合的理论基础

整洁架构提供了应用程序不同层之间的清晰分离,但需要跨多个项目导航才能探索单个用例的实现。整洁架构最好的方面之一是它为应用程序提供了以领域为中心的设计,显著简化了复杂领域和项目的开发。

另一方面,垂直切片架构允许以提供快速导航和开发的方式组织代码。单个用例的实现都在一个地方。

如果我们能够将两者的优点结合起来会怎样?

我认为整洁架构的自然演进(通过其功能文件夹)已经导致它转变为垂直切片架构。原始的整洁架构并不总是关于将解决方案分离为多个项目,而是关于类及其关系。

核心要点是内层的类不能调用外层的类。使用垂直切片架构,可以实现相同的目标,但项目更少。

实践中的融合架构

我发现将整洁架构与垂直切片结合是复杂应用程序的优秀架构设计。在小型应用程序或没有复杂业务逻辑的应用程序中,可以使用不带整洁架构的垂直切片。

作为核心,使用整洁架构层次并将它们与垂直切片结合:

层次修改如下:

项目结构示例:

Domain/
  ├── Entities/
  │   └── Shipment.cs
  └── ValueObjects/
      └── Address.cs
Infrastructure/
  ├── Data/
  │   └── ShipmentsDbContext.cs
  └── Authentication/
      └── JwtTokenService.cs
Features/
  ├── Shipments/
  │   ├── CreateShipment/
  │   │   ├── CreateShipmentEndpoint.cs
  │   │   ├── CreateShipmentHandler.cs
  │   │   └── CreateShipmentValidator.cs
  │   ├── GetShipment/
  │   │   └── GetShipmentEndpoint.cs
  │   └── UpdateShipment/
  │       └── UpdateShipmentEndpoint.cs
  └── Orders/
      └── ...

在基础设施项目中,放置外部集成的实现,如数据库、缓存和身份验证。如果项目不需要实现 Repository 或其他外部集成,可以省略基础设施项目。

实现与整洁架构解决方案实际上是相同的:

public class CreateShipmentEndpoint : IEndpoint
{
    public void MapEndpoint(WebApplication app)
    {
        app.MapPost("/api/shipments", Handle);
    }

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

        var command = request.MapToCommand();
        var response = await mediator.Send(command, cancellationToken);

        if (response.IsError)
        {
            return response.Errors.ToProblem();
        }

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

垂直切片与整洁架构结合需要一些时间让新开发者上手,但显著减少了他们理解实际用例和领域所需的时间。

2025 年的架构选择指南

何时选择 N 层架构

最适合:

选择 N 层架构的时机:

何时选择整洁架构

最适合:

选择整洁架构的时机:

何时选择垂直切片架构

最适合:

选择垂直切片架构的时机:

何时选择整洁架构 + 垂直切片

最适合:

选择这种组合的时机:

2025 年的推荐方案

对于 2025 年启动的大多数新项目,我推荐整洁架构与垂直切片的结合

这种方法提供:

  1. 需要时的结构:整洁架构为复杂业务逻辑提供坚实的基础
  2. 需要时的速度:垂直切片允许快速功能开发
  3. 未来的灵活性:易于添加新功能和更改现有功能
  4. 团队可扩展性:不同的团队可以独立处理不同的功能

关键是选择与团队技能、项目复杂性和当前业务需求相匹配的方法,同时考虑未来的增长。

根据当前情况开始:

总结

架构选择不是一劳永逸的决定,而是需要根据项目阶段、团队能力和业务需求持续评估的过程。N 层架构虽然传统,但在简单场景下仍有其价值;整洁架构为复杂领域提供了严格的分层和测试保障;垂直切片架构则以功能为中心,提供了极高的开发效率。

将整洁架构与垂直切片结合,是当前最具前景的架构方法之一。它既保留了整洁架构的领域驱动优势和严格的依赖规则,又获得了垂直切片的快速导航和开发效率。对于构建模块化单体或需要长期演进的复杂系统,这种融合方案是 2025 年的最佳选择。

无论选择哪种架构,始终牢记:架构服务于业务,而非业务服务于架构。选择最适合当前情况的方案,并保持对未来变化的开放态度。



Previous Post
使用 OAuth 和 Entra ID 实现安全的 MCP 桌面客户端
Next Post
C# PDF 库完全对比指南:IronPDF、QuestPDF 与 PuppeteerSharp 深度解析