Claude-skill-registry dotnet-api

Patterns and best practices for building .NET Web APIs with ASP.NET Core

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/dotnet-api" ~/.claude/skills/majiayu000-claude-skill-registry-dotnet-api && rm -rf "$T"
manifest: skills/data/dotnet-api/SKILL.md
source content

.NET API Development Skill

This skill provides patterns and best practices for building production-ready .NET Web APIs with ASP.NET Core 8+. It covers architecture decisions, validation, error handling, and testing strategies.

Controllers vs Minimal APIs

Choose the right approach based on your project needs:

AspectMinimal APIsControllers
Best forMicroservices, small APIsLarge APIs, complex routing
CeremonyLow (less boilerplate)Higher (class-based)
FiltersEndpoint filtersAction filters, extensive
Model bindingManual or autoAutomatic, comprehensive
OpenAPIBuilt-in supportSwashbuckle integration
TestingDirect endpoint testingController unit testing
PerformanceSlightly faster startupNegligible difference

When to use Minimal APIs:

  • Microservices with few endpoints
  • Rapid prototyping
  • Simple CRUD operations
  • Lambda/serverless deployments

When to use Controllers:

  • Large enterprise applications
  • Complex authorization scenarios
  • Need extensive filter pipelines
  • Team familiar with MVC patterns

Routing and Model Binding

Minimal API Routing

var app = builder.Build();

// Route groups for organization
var api = app.MapGroup("/api");
var v1 = api.MapGroup("/v1");

// Basic CRUD routes
v1.MapGet("/products", GetAllProducts);
v1.MapGet("/products/{id:int}", GetProductById);
v1.MapPost("/products", CreateProduct);
v1.MapPut("/products/{id:int}", UpdateProduct);
v1.MapDelete("/products/{id:int}", DeleteProduct);

// Route constraints
v1.MapGet("/orders/{id:guid}", GetOrderById);
v1.MapGet("/users/{username:alpha:minlength(3)}", GetUserByUsername);

Controller Routing

[ApiController]
[Route("api/v1/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<IEnumerable<Product>>> GetAll() { }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<Product>> GetById(int id) { }

    [HttpPost]
    public async Task<ActionResult<Product>> Create(CreateProductRequest request) { }
}

Model Binding Sources

// Minimal API - explicit binding
app.MapPost("/products", async (
    [FromBody] CreateProductRequest body,
    [FromQuery] bool? notify,
    [FromHeader(Name = "X-Correlation-Id")] string? correlationId,
    [FromServices] IProductRepository repository) => { });

// Controller - automatic binding
[HttpPost]
public async Task<ActionResult> Create(
    [FromBody] CreateProductRequest body,
    [FromQuery] bool? notify = false) { }

Validation

FluentValidation (Recommended)

Note:

FluentValidation.AspNetCore
is deprecated. Use manual validation:

// Validator definition
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.Price)
            .GreaterThan(0)
            .PrecisionScale(10, 2, false);

        RuleFor(x => x.Sku)
            .NotEmpty()
            .Matches(@"^[A-Z]{3}-\d{4}$")
            .WithMessage("SKU must be in format XXX-0000");
    }
}

// Register validators
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();

// Manual validation in endpoint
app.MapPost("/api/products", async (
    CreateProductRequest request,
    IValidator<CreateProductRequest> validator,
    IProductRepository repository) =>
{
    var result = await validator.ValidateAsync(request);
    if (!result.IsValid)
        return Results.ValidationProblem(result.ToDictionary());

    var product = await repository.CreateAsync(request);
    return Results.Created($"/api/products/{product.Id}", product);
});

Data Annotations (Simple Cases)

public record CreateProductRequest
{
    [Required]
    [StringLength(100, MinimumLength = 1)]
    public string Name { get; init; } = string.Empty;

    [Range(0.01, 999999.99)]
    public decimal Price { get; init; }

    [RegularExpression(@"^[A-Z]{3}-\d{4}$")]
    public string Sku { get; init; } = string.Empty;
}

Error Handling

Problem Details (RFC 7807)

// Configure Problem Details
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Extensions["traceId"] =
            context.HttpContext.TraceIdentifier;
        context.ProblemDetails.Extensions["instance"] =
            context.HttpContext.Request.Path;
    };
});

// Use with controllers
builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var problemDetails = new ValidationProblemDetails(context.ModelState)
            {
                Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
                Title = "Validation failed",
                Status = StatusCodes.Status400BadRequest,
                Instance = context.HttpContext.Request.Path
            };
            return new BadRequestObjectResult(problemDetails);
        };
    });

Global Exception Handler (.NET 8+)

public class GlobalExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext, Exception exception, CancellationToken ct)
    {
        var problemDetails = exception switch
        {
            NotFoundException => new ProblemDetails
                { Status = 404, Title = "Resource not found" },
            _ => new ProblemDetails
                { Status = 500, Title = "An error occurred" }
        };
        httpContext.Response.StatusCode = problemDetails.Status ?? 500;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, ct);
        return true;
    }
}

// Registration
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
app.UseExceptionHandler();

Dependency Injection

Service Lifetimes

LifetimeDescriptionUse Case
SingletonOne instance for app lifetimeConfiguration, caching
ScopedOne instance per requestDbContext, repositories
TransientNew instance every timeLightweight, stateless
// Registration patterns
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddTransient<IEmailService, EmailService>();

// Keyed services (.NET 8+)
builder.Services.AddKeyedSingleton<INotifier, EmailNotifier>("email");
builder.Services.AddKeyedSingleton<INotifier, SmsNotifier>("sms");

// Usage with keyed services
app.MapPost("/notify", ([FromKeyedServices("email")] INotifier notifier) =>
{
    notifier.Send("Hello");
});

Use extension methods to organize registrations:

builder.Services.AddApplicationServices();

OpenAPI/Swagger

Swashbuckle Configuration

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Products API",
        Version = "v1",
        Description = "API for managing products",
        Contact = new OpenApiContact
        {
            Name = "API Support",
            Email = "support@example.com"
        }
    });

    // Include XML comments
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
    options.IncludeXmlComments(xmlPath);

    // Add security definition
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT",
        Description = "Enter JWT token"
    });
});

// Middleware
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
        options.RoutePrefix = string.Empty;
    });
}

Endpoint Documentation

app.MapGet("/api/products/{id}", async (int id, IProductRepository repo) =>
{
    var product = await repo.GetByIdAsync(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
})
.WithName("GetProductById")
.WithTags("Products")
.Produces<Product>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.WithOpenApi(operation =>
{
    operation.Summary = "Get a product by ID";
    operation.Description = "Returns a single product";
    return operation;
});

API Versioning

Using Asp.Versioning

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version"),
        new QueryStringApiVersionReader("api-version"));
})
.AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

// Minimal API versioning
var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();

app.MapGet("/api/v{version:apiVersion}/products", GetProducts)
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(1, 0);

// Controller versioning
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    [MapToApiVersion("1.0")]
    public IActionResult GetV1() => Ok("v1");

    [HttpGet]
    [MapToApiVersion("2.0")]
    public IActionResult GetV2() => Ok("v2");
}

Performance

Response Caching

builder.Services.AddResponseCaching();
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(5)));
    options.AddPolicy("Products", builder =>
        builder.Expire(TimeSpan.FromMinutes(10)).Tag("products"));
});

app.UseOutputCache();

// Apply to endpoint
app.MapGet("/api/products", GetProducts)
    .CacheOutput("Products");

// Cache invalidation
app.MapPost("/api/products", async (
    CreateProductRequest request,
    IOutputCacheStore cache,
    IProductRepository repo) =>
{
    var product = await repo.CreateAsync(request);
    await cache.EvictByTagAsync("products", default);
    return Results.Created($"/api/products/{product.Id}", product);
});

Response Compression

builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});

builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
    options.Level = CompressionLevel.Fastest;
});

app.UseResponseCompression();

Health Checks

builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
    .AddSqlServer(connectionString, name: "database", tags: ["ready"]);

// Liveness probe (is app running?)
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live")
});

// Readiness probe (can app serve traffic?)
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

Integration Testing

Use

WebApplicationFactory<Program>
for integration tests:

public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ApiTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
            builder.ConfigureServices(services =>
            {
                services.RemoveAll<IProductRepository>();
                services.AddScoped<IProductRepository, InMemoryProductRepository>();
            }));
        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsSuccess()
    {
        var response = await _client.GetAsync("/api/products");
        response.EnsureSuccessStatusCode();
    }

    [Fact]
    public async Task CreateProduct_WithInvalidData_ReturnsBadRequest()
    {
        var response = await _client.PostAsJsonAsync("/api/products",
            new { Name = "", Price = -1 });
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }
}

Quick Reference

TaskCode
Create endpoint
app.MapGet("/path", Handler)
Return 201
Results.Created(uri, object)
Return 404
Results.NotFound()
Return Problem
Results.Problem(detail, statusCode)
Validation Problem
Results.ValidationProblem(errors)
Inject service
([FromServices] IService svc)
Route param
"/items/{id:int}"
Query param
([FromQuery] int? page)
Require auth
.RequireAuthorization()
Add tag
.WithTags("TagName")
Cache output
.CacheOutput("PolicyName")

Additional Resources

  • templates/minimal-api-template.cs - Starter for Minimal API projects
  • templates/controller-template.cs - Starter for controller-based APIs
  • examples/task-api-dotnet.cs - Task API (Minimal API approach)
  • examples/task-api-controller-dotnet.cs - Task API (Controller approach)