mcpField
D
dotnet-architecture-patterns

dotnet-architecture-patterns

github.com/wshaddix开发86112 次安装35 周前更新
代码仓库

关于 dotnet-architecture-patterns

为 .NET 开发者提供应用架构模式参考,涵盖 Minimal API 组织、垂直切片、验证、缓存与可靠写入等实践。重点在可扩展的功能组织及请求处理方式。

主要功能

  1. 01垂直切片架构组织
  2. 02大规模组织 Minimal API
  3. 03请求管道与端点筛选器
  4. 04验证与 Problem Details 错误处理
  5. 05输出、分布式及混合缓存
  6. 06幂等键与事务发件箱模式

适用场景

  1. 01按功能拆分大型 .NET 应用
  2. 02为 Minimal API 组合请求处理流程
  3. 03为可重试写请求防止重复处理
  4. 04在数据库写入后可靠发布领域事件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-architecture-patterns

安装后在 Claude Code 中输入/dotnet-architecture-patterns确认已加载。

详细介绍

dotnet-architecture-patterns

适用于 .NET 应用程序的现代架构模式。涵盖在大规模场景下组织 Minimal API、纵向切片架构、请求管道组合、验证策略、缓存、错误处理,以及幂等性/Outbox 模式等实用方法。

不在范围内: DI 容器机制和 async/await 模式——请参阅 [skill:dotnet-csharp-dependency-injection] 和 [skill:dotnet-csharp-async-patterns]。项目脚手架和文件布局——请参阅 [skill:dotnet-scaffold-project]。测试策略——有关决策指导,请参阅 [skill:dotnet-testing-strategy];有关 WebApplicationFactory 模式,请参阅 [skill:dotnet-integration-testing]。

交叉引用:[skill:dotnet-csharp-dependency-injection] 介绍服务注册和生命周期,[skill:dotnet-csharp-async-patterns] 介绍异步管道模式,[skill:dotnet-csharp-configuration] 介绍配置中的 Options 模式,[skill:dotnet-solid-principles] 介绍影响类和接口设计的 SOLID/DRY 设计原则。


纵向切片架构

按功能(纵向切片)而非技术层(控制器、服务、存储库)组织代码。每个切片都拥有自己的端点、处理程序、验证和数据访问。

目录结构
Features/
  Orders/
    CreateOrder/
      CreateOrderEndpoint.cs
      CreateOrderHandler.cs
      CreateOrderRequest.cs
      CreateOrderValidator.cs
    GetOrder/
      GetOrderEndpoint.cs
      GetOrderHandler.cs
    ListOrders/
      ListOrdersEndpoint.cs
      ListOrdersHandler.cs
  Products/
    GetProduct/
      ...
采用纵向切片的原因
  • 低耦合:更改某项功能不会波及共享层
  • 易于导航:某项功能的所有内容都集中在一处
  • 可独立测试:每个切片都有明确的输入/输出契约
  • 易于团队扩展:不同开发人员可以处理不同功能,避免合并冲突
切片构成

每个切片通常包含:

  1. 请求/响应 DTO —— 契约
  2. 验证器 —— 输入验证规则
  3. 处理程序 —— 业务逻辑
  4. 端点 —— HTTP 映射(路由、方法、状态码)
// Features/Orders/CreateOrder/CreateOrderRequest.cs
public sealed record CreateOrderRequest(
    string CustomerId,
    List<OrderLineRequest> Lines);

public sealed record OrderLineRequest(
    string ProductId,
    int Quantity);

// Features/Orders/CreateOrder/CreateOrderResponse.cs
public sealed record CreateOrderResponse(
    string OrderId,
    decimal Total,
    DateTimeOffset CreatedAt);

大规模场景下组织 Minimal API

路由组模式

使用 MapGroup 组织相关端点并应用共享筛选器:

// Program.cs
var app = builder.Build();

app.MapGroup("/api/orders")
   .WithTags("Orders")
   .MapOrderEndpoints();

app.MapGroup("/api/products")
   .WithTags("Products")
   .MapProductEndpoints();

app.Run();
// Features/Orders/OrderEndpoints.cs
public static class OrderEndpoints
{
    public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)
    {
        group.MapPost("/", CreateOrderEndpoint.Handle)
             .WithName("CreateOrder")
             .Produces<CreateOrderResponse>(StatusCodes.Status201Created)
             .ProducesValidationProblem();

        group.MapGet("/{id}", GetOrderEndpoint.Handle)
             .WithName("GetOrder")
             .Produces<OrderResponse>()
             .ProducesProblem(StatusCodes.Status404NotFound);

        group.MapGet("/", ListOrdersEndpoint.Handle)
             .WithName("ListOrders")
             .Produces<PagedResult<OrderSummary>>();

        return group;
    }
}
端点类

将每个端点放在独立的静态类中,并使用单个 Handle 方法:

public static class CreateOrderEndpoint
{
    public static async Task<IResult> Handle(
        CreateOrderRequest request,
        IValidator<CreateOrderRequest> validator,
        IOrderService orderService,
        CancellationToken ct)
    {
        var validation = await validator.ValidateAsync(request, ct);
        if (!validation.IsValid)
        {
            return Results.ValidationProblem(validation.ToDictionary());
        }

        var order = await orderService.CreateAsync(request, ct);

        return Results.Created($"/api/orders/{order.OrderId}", order);
    }
}

请求管道组合

端点筛选器(端点中间件)

使用端点筛选器处理仅适用于特定路由的横切关注点:

// Validation filter applied to a route group
public sealed class ValidationFilter<TRequest> : IEndpointFilter
    where TRequest : class
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var request = context.Arguments.OfType<TRequest>().FirstOrDefault();
        if (request is null)
        {
            return Results.BadRequest();
        }

        var validator = context.HttpContext.RequestServices
            .GetService<IValidator<TRequest>>();

        if (validator is not null)
        {
            var result = await validator.ValidateAsync(request);
            if (!result.IsValid)
            {
                return Results.ValidationProblem(result.ToDictionary());
            }
        }

        return await next(context);
    }
}

// Usage
group.MapPost("/", CreateOrderEndpoint.Handle)
     .AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
管道顺序

标准中间件管道的顺序很重要:

app.UseExceptionHandler();       // 1. Global error handling
app.UseStatusCodePages();        // 2. Status code formatting
app.UseRateLimiter();            // 3. Rate limiting
app.UseAuthentication();         // 4. Authentication
app.UseAuthorization();          // 5. Authorization
// Endpoint routing happens here

错误处理

Problem Details(RFC 9457)

使用内置的 Problem Details 支持,确保错误响应保持一致:

// Program.cs
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Extensions["traceId"] =
            context.HttpContext.TraceIdentifier;
    };
});

app.UseExceptionHandler();
app.UseStatusCodePages();
业务逻辑的 Result 模式

对于预期的业务失败,让处理程序返回结果类型,而不是抛出异常:

public abstract record Result<T>
{
    public sealed record Success(T Value) : Result<T>;
    public sealed record NotFound(string Message) : Result<T>;
    public sealed record ValidationFailed(IDictionary<string, string[]> Errors) : Result<T>;
    public sealed record Conflict(string Message) : Result<T>;
}

// In the handler
public async Task<Result<Order>> CreateAsync(
    CreateOrderRequest request,
    CancellationToken ct)
{
    var customer = await _db.Customers.FindAsync([request.CustomerId], ct);
    if (customer is null)
    {
        return new Result<Order>.NotFound($"Customer {request.CustomerId} not found");
    }

    // ... create order
    return new Result<Order>.Success(order);
}

// In the endpoint -- map result to HTTP response
return result switch
{
    Result<Order>.Success s => Results.Created($"/api/orders/{s.Value.Id}", s.Value),
    Result<Order>.NotFound n => Results.Problem(n.Message, statusCode: 404),
    Result<Order>.ValidationFailed v => Results.ValidationProblem(v.Errors),
    Result<Order>.Conflict c => Results.Problem(c.Message, statusCode: 409),
    _ => Results.Problem("Unexpected error", statusCode: 500)
};

验证策略

根据复杂程度选择验证方式。默认优先使用内置机制;对于超出声明式属性处理能力的复杂业务规则,再使用 FluentValidation。有关框架的详细指导,请参阅 [skill:dotnet-input-validation]。有关 SOLID 原则中对架构内验证职责划分的指导,请参阅 [skill:dotnet-solid-principles]。

内置方式:Data Annotations + MiniValidation(默认)

简单的属性级约束从 Data Annotations 开始。在 Minimal API 中使用 MiniValidation 进行轻量级验证,避免 MVC 模型绑定的开销。对于 .NET 10+ 项目,优先使用内置的 AddValidation() 源生成器管道(参阅 [skill:dotnet-input-validation])。

public sealed record CreateProductRequest(
    [Required, MaxLength(200)] string Name,
    [Range(0.01, double.MaxValue)] decimal Price);

// In endpoint
if (!MiniValidator.TryValidate(request, out var errors))
{
    return Results.ValidationProblem(errors);
}
FluentValidation(复杂规则的可选方案)

当验证规则超出注解的处理能力时——例如跨属性规则、条件逻辑、依赖数据库的检查——请使用 FluentValidation。通过程序集扫描进行注册,并通过端点筛选器或手动验证来应用:

// Register validators by assembly scanning
builder.Services.AddValidatorsFromAssemblyContaining<Program>(ServiceLifetime.Scoped);

// Validator implementation
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty()
            .MaximumLength(50);

        RuleFor(x => x.Lines)
            .NotEmpty()
            .WithMessage("Order must have at least one line item");

        RuleForEach(x => x.Lines)
            .ChildRules(line =>
            {
                line.RuleFor(l => l.ProductId).NotEmpty();
                line.RuleFor(l => l.Quantity).GreaterThan(0);
            });
    }
}

缓存策略

输出缓存(HTTP 响应缓存)
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(p => p.NoCache());

    options.AddPolicy("ProductList", p =>
        p.Expire(TimeSpan.FromMinutes(5))
         .Tag("products"));

    options.AddPolicy("ProductDetail", p =>
        p.Expire(TimeSpan.FromMinutes(10))
         .SetVaryByRouteValue("id")
         .Tag("products"));
});

app.UseOutputCache();

// Apply to endpoints
group.MapGet("/", ListProductsEndpoint.Handle)
     .CacheOutput("ProductList");

group.MapGet("/{id}", GetProductEndpoint.Handle)
     .CacheOutput("ProductDetail");

// Invalidate by tag
app.MapPost("/api/products", async (
    IOutputCacheStore cache,
    /* ... */) =>
{
    // ... create product
    await cache.EvictByTagAsync("products", ct);
    return Results.Created(/* ... */);
});
分布式缓存(应用级)
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration
        .GetConnectionString("Redis");
});

// Usage with IDistributedCache
public sealed class ProductService(
    IDistributedCache cache,
    AppDbContext db)
{
    public async Task<Product?> GetByIdAsync(
        string id, CancellationToken ct = default)
    {
        var cacheKey = $"product:{id}";
        var cached = await cache.GetStringAsync(cacheKey, ct);

        if (cached is not null)
        {
            return JsonSerializer.Deserialize<Product>(cached);
        }

        var product = await db.Products.FindAsync([id], ct);
        if (product is not null)
        {
            await cache.SetStringAsync(
                cacheKey,
                JsonSerializer.Serialize(product),
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
                },
                ct);
        }

        return product;
    }
}
HybridCache(.NET 9+)

HybridCache 将 L1(内存)和 L2(分布式)缓存结合起来,并提供防止缓存击穿的保护:

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(10),
        LocalCacheExpiration = TimeSpan.FromMinutes(2)
    };
});

// Usage -- stampede-safe, two-tier
public sealed class ProductService(HybridCache cache, AppDbContext db)
{
    public async Task<Product?> GetByIdAsync(
        string id, CancellationToken ct = default)
    {
        return await cache.GetOrCreateAsync(
            $"product:{id}",
            async cancel => await db.Products.FindAsync([id], cancel),
            cancellationToken: ct);
    }
}

幂等性与 Outbox 模式

幂等键

防止重试请求被重复处理。健壮的幂等性实现必须:

  1. 限定键的作用范围为路由 + 用户/租户,防止不同端点之间发生冲突
  2. 以原子方式认领该键后再执行,拒绝并发的重复请求
  3. 存储具体的响应封装(而非 IResult 引用),以便安全地重放响应
基于数据库的幂等性(推荐)

使用带唯一约束的数据库行,以原子方式执行认领后再处理:

// Idempotency record stored alongside domain data
public sealed class IdempotencyRecord
{
    public required string Key { get; init; }         // Scoped key
    public required string RequestRoute { get; init; }
    public required string? UserId { get; init; }
    public int StatusCode { get; set; }
    public string? ResponseBody { get; set; }         // Serialized JSON
    public string? ContentType { get; set; }
    public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
    public bool IsCompleted { get; set; }
}

public sealed class IdempotencyFilter(AppDbContext db) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var httpContext = context.HttpContext;
        if (!httpContext.Request.Headers.TryGetValue(
            "Idempotency-Key", out var keyValues))
        {
            return await next(context);
        }

        var clientKey = keyValues.ToString();
        if (string.IsNullOrWhiteSpace(clientKey) || clientKey.Length > 256)
        {
            return Results.Problem("Invalid Idempotency-Key", statusCode: 400);
        }

        // Scope key by route + user to prevent cross-endpoint/cross-tenant collisions
        var route = $"{httpContext.Request.Method}:{httpContext.Request.Path}";
        var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
        var scopedKey = $"{route}:{userId}:{clientKey}";

        // Check for existing record (completed = replay, in-progress = reject)
        var existing = await db.IdempotencyRecords
            .FirstOrDefaultAsync(r => r.Key == scopedKey);

        if (existing is { IsCompleted: true })
        {
            // Replay: value responses get body, non-value responses get status only
            if (existing.ResponseBody is not null)
            {
                return Results.Text(
                    existing.ResponseBody,
                    existing.ContentType ?? "application/json",
                    statusCode: existing.StatusCode);
            }

            return Results.StatusCode(existing.StatusCode);
        }

        if (existing is { IsCompleted: false })
        {
            // Another request claimed this key but hasn't completed yet.
            // Reject to prevent duplicate execution.
            return Results.Problem(
                "Duplicate request in progress", statusCode: 409);
        }

        // Atomic claim: insert with unique constraint -- concurrent duplicate
        // requests will throw DbUpdateException and get a 409 Conflict
        {
            var record = new IdempotencyRecord
            {
                Key = scopedKey,
                RequestRoute = route,
                UserId = userId,
                IsCompleted = false
            };
            db.IdempotencyRecords.Add(record);

            try
            {
                await db.SaveChangesAsync();
            }
            catch (DbUpdateException)
            {
                return Results.Problem(
                    "Duplicate request in progress", statusCode: 409);
            }

            existing = record;
        }

        // Execute the actual handler
        var result = await next(context);

        // Always finalize the record -- handles both value and non-value results
        // (Results.Ok(obj), Results.NoContent(), Results.Accepted(), etc.)
        existing.StatusCode = result is IStatusCodeHttpResult statusResult
            ? statusResult.StatusCode ?? 200
            : 200;
        existing.ResponseBody = result is IValueHttpResult valueResult
            ? JsonSerializer.Serialize(valueResult.Value)
            : null;  // No body for non-value results (204, 202, etc.)
        existing.ContentType = existing.ResponseBody is not null
            ? "application/json"
            : null;
        existing.IsCompleted = true;
        await db.SaveChangesAsync();

        return result;
    }
}

关键设计选择:

  • 三种状态:无记录(认领该键)、处理中(拒绝并返回 409)、已完成(重放缓存的响应)
  • Key 列上的唯一约束可在无需分布式锁的情况下原子地认领键
  • 作用范围限定的键(route:userId:clientKey)可防止不同端点和不同租户之间发生冲突
  • 响应封装存储序列化后的正文 + 状态码 + 内容类型(而非 IResult 引用)
  • 处理中记录(已认领但尚未完成)会对并发重复请求返回 409
  • 考虑添加过期记录清理作业,以处理被遗弃的处理中记录(例如,进程在执行中途崩溃)
事务性 Outbox 模式

确保领域事件与数据库写入一同实现至少一次投递:

// 1. Store outbox messages in the same transaction as the domain write
public sealed class OutboxMessage
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public required string EventType { get; init; }
    public required string Payload { get; init; }
    public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? ProcessedAt { get; set; }
}

// 2. In the handler -- same DbContext transaction
public async Task<Order> CreateOrderAsync(
    CreateOrderRequest request,
    CancellationToken ct)
{
    await using var transaction = await _db.Database
        .BeginTransactionAsync(ct);

    var order = new Order { /* ... */ };
    _db.Orders.Add(order);

    _db.OutboxMessages.Add(new OutboxMessage
    {
        EventType = "OrderCreated",
        Payload = JsonSerializer.Serialize(
            new OrderCreatedEvent(order.Id, order.Total))
    });

    await _db.SaveChangesAsync(ct);
    await transaction.CommitAsync(ct);

    return order;
}

// 3. Background processor publishes outbox messages
// See [skill:dotnet-background-services] for the Channels-based
// processor that polls and publishes these messages.

Outbox 模式可确保只要数据库写入成功,事件最终就一定会发布,即使消息代理暂时不可用。


核心原则

  • 应用 SOLID 原则 -- 单一职责(每个类只有一个变更原因)、开闭原则(通过新增类型扩展,而非修改现有代码)、依赖倒置(在模块边界依赖抽象)。反模式、修复方案和合规性测试请参阅 [skill:dotnet-solid-principles]。
  • 优先使用组合而非继承 -- 使用端点筛选器、中间件和管道组合,而非基类
  • 保持各切片独立 -- 避免通过共享抽象将功能耦合在一起;DRY 原则适用于避免知识重复,而非不同限界上下文之间的代码相似性
  • 尽早验证,快速失败 -- 在进入业务逻辑之前,于边界处(端点筛选器)进行验证
  • 始终使用 Problem Details -- 在所有端点之间保持一致的错误格式
  • 在合适的层级进行缓存 -- HTTP 响应使用输出缓存,共享状态使用分布式缓存,两者兼顾则使用 HybridCache
  • 确保写入操作具有幂等性 -- 对客户端可能重试的任何非幂等操作使用幂等键

Agent 易踩问题

  1. 幂等性必须处理三种状态 —— 幂等性实现必须区分无记录(认领)、进行中(拒绝重复请求)和已完成(重放缓存的响应)。如果检查后执行时未处理进行中状态,并发重复执行就可能发生。
  2. 始终无条件完成幂等性记录 —— 不要将完成操作限制为特定的 IResult 子类型(例如 IValueHttpResult)。Results.NoContent() 或 Results.Accepted() 之类的非值结果会永久卡在进行中状态。
  3. 必须显式使缓存失效 —— 使用输出缓存或分布式缓存时,写入操作后务必使缓存失效(按标签或键逐出)。忘记使缓存失效会导致读取到陈旧数据,且很难排查。
  4. HybridCache 的防缓存击穿保护仅适用于 GetOrCreateAsync —— 不要对 HybridCache 使用先获取再单独设置的模式;应使用工厂重载,以便库为同一键的并发请求进行串行处理。
  5. Outbox 消息必须与领域数据写入同一事务 —— 如果在领域事务之外写入 Outbox 消息,两次写入之间发生崩溃就会丢失事件。务必使用 BeginTransactionAsync 将两次写入包装在同一原子事务中。
  6. 端点筛选器的顺序很重要 —— 先添加的筛选器会最先运行,位于最外层。验证筛选器必须先于幂等性筛选器运行,否则无效请求会被缓存为幂等响应。
  7. 不要在并发请求间共享 DbContext —— DbContext 不是线程安全的。每个请求都必须通过 DI 获取自己的作用域实例。使用单例或静态 DbContext 会在并发情况下导致数据损坏。

知识来源

本技能中的架构模式基于以下公开内容:

  • Jimmy Bogard 的 Vertical Slice Architecture —— 按功能而非技术层组织代码。Bogard 倡导每个垂直切片都拥有自己的请求、处理器、验证逻辑和数据访问,从而减少功能间的耦合。他创建了广受欢迎的 MediatR 库,用于在 .NET 中分发请求和处理器,不过 MediatR 现在用于商业用途时需要商业许可。应用垂直切片指导时,简单场景应优先采用上文展示的内置 IEndpointFilter 和处理器模式,而非引入第三方中介器依赖。来源:https://www.jimmybogard.com/vertical-slice-architecture/
  • Jimmy Bogard 的 Domain-Driven Design Patterns —— 丰富领域模型的指导,包括实体设计、值对象、领域事件和聚合边界。关键要点:领域事件应在聚合状态变更持久化后(而非之前)分发,以避免持久化失败时出现不一致。来源:https://www.jimmybogard.com/
  • Nick Chapsas 的 Modern .NET Patterns —— 适用于现代 .NET 的实用模式,包括用于错误处理的结果类型、结构化验证管道,以及在生产代码库中采用现代 C# 功能。来源:https://www.youtube.com/@nickchapsas

注意: 本技能采用公开记录的指导内容,不代表或代言上述来源。MediatR 是用于商业用途的商业产品;本文中的模式使用 .NET 内置机制演示。

参考资料

评论

支持 Markdown