ASP.NET Core APIs skill for Claude Code
Problem details, fail-closed auth, real integration tests. Claude wires ASP.NET Core APIs so failures are consistent and safe: IExceptionHandler and status code pages produce problem details, a fallback policy requires auth everywhere, options validate on start, and DI scopes are checked at build. Endpoints return TypedResults with owner-filtered EF Core projections, and integration tests run the real pipeline against Postgres in Testcontainers.
- name
aspnet-core-apis - size257 lines · ≈2,700 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds production ASP.NET Core Web APIs on .NET 8, 9 and 10 - minimal APIs versus controllers, route groups with TypedResults, RFC 9457 problem details through IExceptionHandler, built-in validation, DI lifetimes and options validation, EF Core read and write patterns with concurrency tokens, JWT bearer auth with policies and resource-based checks, rate limiting, health checks, OpenAPI documents, and WebApplicationFactory integration tests. Use when creating or reviewing an ASP.NET Core API, adding auth or error handling, or testing endpoints end to end. Try asking
- Set up a .NET 10 minimal API with JWT auth, problem details and health checks
- Our API returns HTML error pages and stack traces. Make errors consistent
- Write integration tests for these endpoints against a real Postgres
Use it when
- Creating or reviewing an ASP.NET Core Web API
- Adding authentication, authorization policies or rate limiting
- Standardizing error responses as problem details
- Testing endpoints end to end with WebApplicationFactory
Skip it when
- The question is C# language features, nullable or build settings — use csharp-dotnet
- You are designing resource URLs, pagination or versioning — use rest-api-design
How to check the result
dotnet build -warnaserror and dotnet test pass, tests cover 401, 403 or 404 for foreign resources and 409 conflicts, and the exported OpenAPI document shows every response code.
The common mistake it prevents
Registering the DbContext as scoped but injecting it into a singleton service, which shares one context across requests until it corrupts state or throws threading errors.
Done well means: every failure path returns a problem details document with the right status, auth is required by default and checked per resource, DI lifetimes are validated at startup, EF Core reads are untracked projections, configuration fails fast on boot, and integration tests run the real pipeline against a real database.
Workflow
- [ ] 1. Inventory target framework, hosting model, and conventions
- [ ] 2. Choose minimal APIs or controllers (match the codebase)
- [ ] 3. Wire the pipeline: problem details, auth, rate limiting, health, OpenAPI
- [ ] 4. Write endpoints with validation and TypedResults
- [ ] 5. Data access with EF Core: projections, concurrency, migrations
- [ ] 6. Integration tests with WebApplicationFactory and Testcontainers
- [ ] 7. Verify build, tests, and the exported OpenAPI document
1. Inventory
dotnet --list-sdks
grep -h "<TargetFramework" -r --include=*.csproj .
dotnet list package --outdated
grep -rn "MapGroup\|\[ApiController\]" --include=*.cs . | head
.NET 10 (LTS, Nov 2025) is the default target for new services; .NET 8 is the previous
LTS; .NET 9 is STS. Note the auth scheme, the error format already in use, and whether
the project uses Swashbuckle (removed from templates in .NET 9) or the built-in
Microsoft.AspNetCore.OpenApi.
2. Minimal APIs or controllers
| Choose | When |
|---|---|
| Minimal APIs (default for new services) | Focused HTTP APIs, vertical slices, AOT or small footprint, endpoint filters suffice |
| Controllers | Existing MVC codebase, heavy use of action filters/conventions, OData, complex model binding |
Do not mix styles inside one feature area. Organize minimal APIs as one static class per
feature with a MapOrders(this IEndpointRouteBuilder app) extension.
3. Pipeline
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseDefaultServiceProvider(o =>
{
o.ValidateScopes = true; // catch scoped-in-singleton captive dependencies
o.ValidateOnBuild = true;
});
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddValidation(); // .NET 10 built-in minimal API validation
builder.Services.AddOpenApi();
builder.Services.AddOptions<PaymentsOptions>()
.BindConfiguration("Payments")
.ValidateDataAnnotations()
.ValidateOnStart(); // bad config fails the deploy, not the first request
builder.Services.AddDbContextPool<AppDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Main")));
builder.Services.AddAuthentication().AddJwtBearer(o =>
{
o.Authority = builder.Configuration["Auth:Authority"];
o.Audience = builder.Configuration["Auth:Audience"];
});
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
.AddPolicy("orders:write", p => p.RequireClaim("scope", "orders:write"));
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.AddFixedWindowLimiter("per-client", l => { l.PermitLimit = 100; l.Window = TimeSpan.FromMinutes(1); });
});
builder.Services.AddHealthChecks().AddDbContextCheck<AppDb>("db", tags: ["ready"]);
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages(); // empty 404/405 bodies become problem details
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapOpenApi().AllowAnonymous(); // /openapi/v1.json
app.MapHealthChecks("/healthz/live", new() { Predicate = _ => false }).AllowAnonymous();
app.MapHealthChecks("/healthz/ready", new() { Predicate = c => c.Tags.Contains("ready") }).AllowAnonymous();
app.MapOrders();
app.Run();
AddValidation()is .NET 10; on .NET 8/9 use FluentValidation or an endpoint filter.- Fallback policy makes every endpoint require auth unless it opts out with
AllowAnonymous(), so a forgotten attribute fails closed. - Liveness must not check dependencies; a database blip should mark the pod not-ready, not restart it.
- Middleware order matters: exception handler first, authentication before authorization, rate limiter after auth when limits are per user.
public sealed class GlobalExceptionHandler(IProblemDetailsService problems, ILogger<GlobalExceptionHandler> log)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
var (status, title) = ex switch
{
DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "The resource was modified by someone else."),
OperationCanceledException when ctx.RequestAborted.IsCancellationRequested => (499, "Client closed request"),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred."),
};
if (status >= 500) log.LogError(ex, "Unhandled exception");
ctx.Response.StatusCode = status;
return await problems.TryWriteAsync(new() { HttpContext = ctx, Exception = ex, ProblemDetails = { Status = status, Title = title } });
}
}
Never put ex.Message or stack traces in the response for 5xx.
4. Endpoints
public static class OrderEndpoints
{
public static IEndpointRouteBuilder MapOrders(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/orders").WithTags("Orders").RequireRateLimiting("per-client");
g.MapGet("/{id:guid}", GetOrder).WithName("GetOrder");
g.MapPost("/", CreateOrder).RequireAuthorization("orders:write");
return app;
}
static async Task<Results<Ok<OrderDto>, NotFound>> GetOrder(
Guid id, AppDb db, ClaimsPrincipal user, CancellationToken ct)
{
var userId = user.GetUserId();
var dto = await db.Orders.AsNoTracking()
.Where(o => o.Id == id && o.OwnerId == userId) // per-resource check
.Select(o => new OrderDto(o.Id, o.Status, o.TotalCents, o.CreatedAt))
.FirstOrDefaultAsync(ct);
return dto is null ? TypedResults.NotFound() : TypedResults.Ok(dto);
}
static async Task<Results<Created<OrderDto>, ValidationProblem>> CreateOrder(
CreateOrderRequest req, AppDb db, ClaimsPrincipal user, CancellationToken ct)
{
var order = Order.Create(user.GetUserId(), req.Items); // never bind entities directly
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return TypedResults.Created($"/orders/{order.Id}", OrderDto.From(order));
}
}
public sealed record CreateOrderRequest(
[property: Required, MinLength(1), MaxLength(100)] List<OrderItemRequest> Items);
Results<...>withTypedResultsdocuments every response in OpenAPI and is unit-testable.- Bind request DTOs, never EF entities (overposting, cycles in serialization).
- Accept and pass
CancellationTokenall the way to EF and HttpClient calls. - For authorization that depends on the loaded resource beyond a simple owner filter, use
IAuthorizationService.AuthorizeAsync(user, resource, "PolicyName")with a resource handler.
5. EF Core
- Reads:
AsNoTracking()plusSelectinto DTOs; avoidIncludechains that return whole graphs. UseAsSplitQuery()when several collection includes multiply rows. - Bulk changes:
ExecuteUpdateAsync/ExecuteDeleteAsync(no entity loading; bypasses change tracking and interceptors). - Concurrency: add a concurrency token (
[Timestamp]on SQL Server,xminmapping on Npgsql viaIsRowVersion()/UseXminAsConcurrencyTokendepending on provider version) and translateDbUpdateConcurrencyExceptionto 409, or 412 when the client sentIf-Match. - Migrations:
dotnet ef migrations add AddOrderStatus, review the generated code, then deploy withdotnet ef migrations script --idempotentor a migration bundle (dotnet ef migrations bundle). AvoidDatabase.Migrate()on startup when several instances boot at once. Applying migrations to production is a deploy step the user must approve. - HttpClient: register typed clients with
AddHttpClient<T>()and addAddStandardResilienceHandler()(Microsoft.Extensions.Http.Resilience) for timeouts, retries and circuit breaking instead of hand-rolled Polly code.
6. Integration tests
public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder().WithImage("postgres:17").Build();
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDb>>();
services.AddDbContextPool<AppDb>(o => o.UseNpgsql(_db.GetConnectionString()));
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
});
public async Task InitializeAsync()
{
await _db.StartAsync();
using var scope = Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<AppDb>().Database.MigrateAsync();
}
public new async Task DisposeAsync() => await _db.DisposeAsync();
}
- The test project needs
Programto be visible; on older templates addpublic partial class Program;at the end of Program.cs. - Prefer Testcontainers over the EF InMemory provider, which ignores constraints, transactions and SQL translation.
- Test each endpoint for success, 400/422 validation, 401 without a token, 403 or 404 for another user’s resource, and 409 on a concurrency conflict.
Gotchas
- Injecting a scoped service (DbContext) into a singleton captures one instance forever;
ValidateScopescatches it in development. IOptionsSnapshot<T>is scoped; injectIOptionsMonitor<T>into singletons..Resultor.Wait()on tasks inside request code causes thread pool starvation.new HttpClient()per request exhausts sockets; useIHttpClientFactory.UseAuthorizationbeforeUseAuthenticationsilently treats every user as anonymous.- Returning
nullfrom an endpoint produces 200 with an empty body; returnTypedResults.NotFound().
Verification
dotnet build -warnaserror
dotnet format --verify-no-changes
dotnet test
curl -s localhost:5000/openapi/v1.json -o openapi.json && oasdiff breaking main-openapi.json openapi.json
If the OpenAPI document lacks a response code an endpoint can return, switch that
endpoint to Results<...>; if a test needs AllowAnonymous to pass, the fallback policy
is doing its job and the test needs an authenticated client instead.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.