关于 dotnet-architecture-patterns
为 .NET 开发者提供应用架构模式参考,涵盖 Minimal API 组织、垂直切片、验证、缓存与可靠写入等实践。重点在可扩展的功能组织及请求处理方式。
主要功能
- 01垂直切片架构组织
- 02大规模组织 Minimal API
- 03请求管道与端点筛选器
- 04验证与 Problem Details 错误处理
- 05输出、分布式及混合缓存
- 06幂等键与事务发件箱模式
适用场景
- 01按功能拆分大型 .NET 应用
- 02为 Minimal API 组合请求处理流程
- 03为可重试写请求防止重复处理
- 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/
...
采用纵向切片的原因
- 低耦合:更改某项功能不会波及共享层
- 易于导航:某项功能的所有内容都集中在一处
- 可独立测试:每个切片都有明确的输入/输出契约
- 易于团队扩展:不同开发人员可以处理不同功能,避免合并冲突
切片构成
每个切片通常包含:
- 请求/响应 DTO —— 契约
- 验证器 —— 输入验证规则
- 处理程序 —— 业务逻辑
- 端点 —— 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 模式
幂等键
防止重试请求被重复处理。健壮的幂等性实现必须:
- 限定键的作用范围为路由 + 用户/租户,防止不同端点之间发生冲突
- 以原子方式认领该键后再执行,拒绝并发的重复请求
- 存储具体的响应封装(而非
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 易踩问题
- 幂等性必须处理三种状态 —— 幂等性实现必须区分无记录(认领)、进行中(拒绝重复请求)和已完成(重放缓存的响应)。如果检查后执行时未处理进行中状态,并发重复执行就可能发生。
- 始终无条件完成幂等性记录 —— 不要将完成操作限制为特定的
IResult子类型(例如IValueHttpResult)。Results.NoContent()或Results.Accepted()之类的非值结果会永久卡在进行中状态。 - 必须显式使缓存失效 —— 使用输出缓存或分布式缓存时,写入操作后务必使缓存失效(按标签或键逐出)。忘记使缓存失效会导致读取到陈旧数据,且很难排查。
- HybridCache 的防缓存击穿保护仅适用于
GetOrCreateAsync—— 不要对HybridCache使用先获取再单独设置的模式;应使用工厂重载,以便库为同一键的并发请求进行串行处理。 - Outbox 消息必须与领域数据写入同一事务 —— 如果在领域事务之外写入 Outbox 消息,两次写入之间发生崩溃就会丢失事件。务必使用
BeginTransactionAsync将两次写入包装在同一原子事务中。 - 端点筛选器的顺序很重要 —— 先添加的筛选器会最先运行,位于最外层。验证筛选器必须先于幂等性筛选器运行,否则无效请求会被缓存为幂等响应。
- 不要在并发请求间共享
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 内置机制演示。