一键导入
dotnet-core
ASP.NET Core patterns and architecture
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
ASP.NET Core patterns and architecture
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Audit a project against a canon's rules and checklist. Read-only — produces prioritized report without fixing. Works with any canon (nextjs, sql, typescript, etc.).
Lens home base - status, help, and setup
Plan and build a new feature with quality gates.
Simple changes done right. Make the change, clean up after yourself, report what happened.
Review against canons + quality gate, fix findings, verify. Claude-native — no external models.
Plan and improve existing code with quality gates.
| name | dotnet-core |
| description | ASP.NET Core patterns and architecture |
The ASP.NET Core team's core belief: Convention over configuration, but always let developers override. The framework gives you a pit of success — DI, middleware, configuration — use them as designed and things just work.
"The request pipeline is everything. Understand the middleware pipeline, and you understand ASP.NET Core."
Every HTTP request flows through an ordered pipeline of middleware. The order you register middleware is the order it executes. Get this wrong, and auth checks happen after routing, or CORS headers never appear.
Minimal APIs are for lean endpoints. Controllers are for structured, feature-rich APIs.
Minimal API — route groups reduce repetition:
var api = app.MapGroup("/api/products").RequireAuthorization().WithTags("Products");
api.MapGet("/", async (IProductService svc) => Results.Ok(await svc.GetAllAsync()));
api.MapGet("/{id:int}", async (int id, IProductService svc) =>
await svc.GetByIdAsync(id) is { } product ? Results.Ok(product) : Results.NotFound());
api.MapPost("/", async (CreateProductRequest req, IProductService svc) =>
{
var product = await svc.CreateAsync(req);
return Results.Created($"/api/products/{product.Id}", product);
});
Controllers — large APIs, filters, OpenAPI attributes:
[ApiController]
[Route("api/[controller]")]
public class ProductsController(IProductService svc) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id)
{
var product = await svc.GetByIdAsync(id);
return product is null ? NotFound() : Ok(product);
}
}
When to choose:
Middleware order is not a suggestion. It is execution order.
Not this:
app.UseRouting();
app.UseEndpoints(e => e.MapControllers());
app.UseAuthentication(); // Too late — unauthenticated requests already hit endpoints
app.UseAuthorization();
This:
app.UseExceptionHandler("/error");
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("AllowFrontend");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Use vs Run vs Map:
// Use: chain link — calls next
app.Use(async (context, next) =>
{
var sw = Stopwatch.StartNew();
await next(context);
context.Response.Headers.Append("X-Elapsed-Ms", sw.ElapsedMilliseconds.ToString());
});
// Run: terminal — dead end
app.Run(async context => await context.Response.WriteAsync("Hello"));
// Map: branch by path
app.Map("/health", b => b.Run(async ctx => await ctx.Response.WriteAsync("OK")));
Custom middleware class — constructor takes RequestDelegate, method takes HttpContext:
public class CorrelationIdMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
var id = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Items["CorrelationId"] = id;
context.Response.Headers.Append("X-Correlation-Id", id);
await next(context);
}
}
ASP.NET Core has DI built in. Understand lifetimes or suffer subtle bugs.
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>(); // New every time
builder.Services.AddScoped<IShoppingCart, SessionCart>(); // One per request
builder.Services.AddSingleton<ICacheService, MemoryCacheService>(); // One for app lifetime
The captive dependency bug — Singleton captures Scoped:
Not this:
public class CacheWarmer : ICacheWarmer // Registered as Singleton
{
private readonly IDbContext _db; // Registered as Scoped — BUG!
public CacheWarmer(IDbContext db) => _db = db;
}
This:
public class CacheWarmer : ICacheWarmer // Singleton
{
private readonly IServiceScopeFactory _scopeFactory;
public CacheWarmer(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
public async Task WarmAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IDbContext>();
await db.Products.LoadAsync();
}
}
Lifetime rule: A service can only depend on services with equal or longer lifetimes. Singleton -> Singleton only. Scoped -> Singleton + Scoped. Transient -> any.
// Catch violations at startup
builder.Host.UseDefaultServiceProvider(o => { o.ValidateScopes = true; o.ValidateOnBuild = true; });
Bind config sections to typed objects. Never scatter Configuration["Magic:String"] through code.
Not this:
public EmailService(IConfiguration config)
{
var host = config["Smtp:Host"]; // string? — might be null
var port = int.Parse(config["Smtp:Port"]!); // Throws if missing
}
This:
public class SmtpOptions
{
public const string Section = "Smtp";
public required string Host { get; init; }
public int Port { get; init; } = 587;
}
builder.Services.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.Section)
.ValidateDataAnnotations()
.ValidateOnStart(); // Fail at startup, not first request
public EmailService(IOptions<SmtpOptions> options) => _opts = options.Value;
IOptions vs IOptionsSnapshot vs IOptionsMonitor:
IOptions<T> — Singleton. Read once at startup. Never changes.IOptionsSnapshot<T> — Scoped. Re-reads config each request.IOptionsMonitor<T> — Singleton. Fires OnChange callback when config changes.Bind input to models. Validate before processing. Return ProblemDetails on failure.
public class CreateOrderRequest
{
[Required, StringLength(100)]
public required string CustomerName { get; init; }
[Range(1, 10000)]
public decimal Amount { get; init; }
[EmailAddress]
public required string Email { get; init; }
}
FluentValidation for complex rules:
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerName).NotEmpty().MaximumLength(100);
RuleFor(x => x.Amount).GreaterThan(0).LessThanOrEqualTo(10000);
}
}
[ApiController] enables automatic 400 responses with ProblemDetails (RFC 7807) for invalid models — no manual ModelState.IsValid needed.
Middleware operates on every request. Filters operate on MVC actions with access to action context.
Use middleware when: logic applies to all requests — logging, CORS, compression. Use filters when: logic needs action context — audit logging, result transformation.
public class AuditLogFilter(IAuditService audit) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
var result = await next();
if (result.Exception is null)
await audit.LogAsync(context.ActionDescriptor.DisplayName, "Success");
}
}
builder.Services.AddControllers(o => o.Filters.Add<AuditLogFilter>()); // Global
[ServiceFilter(typeof(AuditLogFilter))] // Per-action
Filter execution order: Authorization -> Resource -> Action -> Exception -> Result.
Long-running work belongs in BackgroundService, not in request handlers.
Not this:
[HttpPost("send-email")]
public IActionResult SendEmail(EmailRequest req)
{
_ = _emailService.SendAsync(req); // Fire-and-forget — lost on restart
return Accepted();
}
This — queue work for a BackgroundService:
[HttpPost("send-email")]
public IActionResult SendEmail(EmailRequest req, IBackgroundTaskQueue queue)
{
queue.Enqueue(async (scope, ct) =>
{
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
await sender.SendAsync(req, ct);
});
return Accepted();
}
Timed background service — use PeriodicTimer, create own DI scope:
public class MetricsCollector(IServiceScopeFactory sf) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(ct))
{
using var scope = sf.CreateScope();
await scope.ServiceProvider.GetRequiredService<IMetricsService>().CollectAsync(ct);
}
}
}
Global exception handler middleware. ProblemDetails (RFC 7807). Never leak stack traces.
Not this:
// try/catch in every action, inconsistent error formats, leaks internals
catch (Exception ex) { return StatusCode(500, new { error = ex.Message }); }
This:
builder.Services.AddProblemDetails();
app.UseExceptionHandler(err => err.Run(async context =>
{
var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error;
context.Response.StatusCode = ex switch
{
NotFoundException => 404, ValidationException => 400, _ => 500
};
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = context.Response.StatusCode,
Title = ex switch
{
NotFoundException => "Resource not found",
ValidationException => "Validation failed",
_ => "An unexpected error occurred"
},
Detail = context.Response.StatusCode != 500 ? ex?.Message : null // Never expose 500 details
});
}));
// Controllers stay clean — throw domain exceptions, middleware handles them
var product = await _svc.GetByIdAsync(id) ?? throw new NotFoundException($"Product {id}");
Claims-based identity. Policy-based authorization. Keep auth declarative.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = builder.Configuration["Auth:Authority"];
o.Audience = builder.Configuration["Auth:Audience"];
});
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("AdminOnly", p => p.RequireRole("Admin"));
o.AddPolicy("CanEditProducts", p => p.RequireClaim("permission", "products:write"));
o.AddPolicy("MinAge", p => p.AddRequirements(new MinimumAgeRequirement(18)));
});
Custom requirement + handler:
public record MinimumAgeRequirement(int MinimumAge) : IAuthorizationRequirement;
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, MinimumAgeRequirement req)
{
var claim = context.User.FindFirst("birth_date");
if (claim is not null && DateTime.TryParse(claim.Value, out var dob)
&& DateTime.Today.Year - dob.Year >= req.MinimumAge)
context.Succeed(req);
return Task.CompletedTask;
}
}
Apply to endpoints:
[Authorize(Policy = "CanEditProducts")] // Controller
app.MapPut("/api/products/{id}", handler).RequireAuthorization("CanEdit"); // Minimal API
// Resource-based auth inside handler
var authResult = await _authService.AuthorizeAsync(User, document, "EditPolicy");
if (!authResult.Succeeded) return Forbid();
| Anti-Pattern | Problem | Fix |
|---|---|---|
IConfiguration injected everywhere | Magic strings, no validation | IOptions<T> with ValidateOnStart |
| Captive dependency | Singleton holds Scoped service | Inject IServiceScopeFactory |
| Service locator in constructors | Hides dependencies, breaks testability | Constructor injection |
| Auth middleware after routing | Unauthenticated requests reach endpoints | Correct middleware order |
Task.Run in request handlers | Wastes thread pool thread | Call async directly or BackgroundService |
Catching Exception in every action | Inconsistent error responses | Global exception handler |
| Returning raw exception messages | Leaks internals to clients | ProblemDetails with safe messages |
AddSingleton<DbContext>() | DbContext is not thread-safe | Always register as Scoped |
| Question | Answer |
|---|---|
| Few endpoints, simple routing? | Minimal API |
| Many endpoints, filters, OpenAPI? | Controllers |
| Logic applies to all requests? | Middleware |
| Logic needs action/model context? | Filter |
| Config value read once? | IOptions<T> |
| Config reloads per request? | IOptionsSnapshot<T> |
| Config changes with notification? | IOptionsMonitor<T> |
| Work outlives the request? | BackgroundService with queue |
| Auth rule is role/claim based? | Built-in policy |
| Auth rule is resource-based? | Custom IAuthorizationHandler |
ValidateScopes enabled in dev?ValidateDataAnnotations() and ValidateOnStart() called?[Authorize(Policy = "...")], not manual claims checks?"Make the right thing easy and the wrong thing hard." — ASP.NET Core design philosophy