Backend & APIs No. 076

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.

  • nameaspnet-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.

~/.claude/skills/aspnet-core-apis/SKILL.md

--- name: aspnet-core-apis description: Builds production ASP.NET Core Web APIs on .NET 8, 9 and 10 - minimal APIs versus controllers, route groups with TypedResults, RFC 9457… license: MIT ---

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

ChooseWhen
Minimal APIs (default for new services)Focused HTTP APIs, vertical slices, AOT or small footprint, endpoint filters suffice
ControllersExisting 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<...> with TypedResults documents every response in OpenAPI and is unit-testable.
  • Bind request DTOs, never EF entities (overposting, cycles in serialization).
  • Accept and pass CancellationToken all 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() plus Select into DTOs; avoid Include chains that return whole graphs. Use AsSplitQuery() 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, xmin mapping on Npgsql via IsRowVersion() / UseXminAsConcurrencyToken depending on provider version) and translate DbUpdateConcurrencyException to 409, or 412 when the client sent If-Match.
  • Migrations: dotnet ef migrations add AddOrderStatus, review the generated code, then deploy with dotnet ef migrations script --idempotent or a migration bundle (dotnet ef migrations bundle). Avoid Database.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 add AddStandardResilienceHandler() (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 Program to be visible; on older templates add public 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; ValidateScopes catches it in development.
  • IOptionsSnapshot<T> is scoped; inject IOptionsMonitor<T> into singletons.
  • .Result or .Wait() on tasks inside request code causes thread pool starvation.
  • new HttpClient() per request exhausts sockets; use IHttpClientFactory.
  • UseAuthorization before UseAuthentication silently treats every user as anonymous.
  • Returning null from an endpoint produces 200 with an empty body; return TypedResults.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.

esc