--- name: dotnet-api description: Patterns and best practices for building .NET Web APIs with ASP.NET Core --- # .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: | Aspect | Minimal APIs | Controllers | |--------|--------------|-------------| | Best for | Microservices, small APIs | Large APIs, complex routing | | Ceremony | Low (less boilerplate) | Higher (class-based) | | Filters | Endpoint filters | Action filters, extensive | | Model binding | Manual or auto | Automatic, comprehensive | | OpenAPI | Built-in support | Swashbuckle integration | | Testing | Direct endpoint testing | Controller unit testing | | Performance | Slightly faster startup | Negligible 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 ```csharp 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 ```csharp [ApiController] [Route("api/v1/[controller]")] public class ProductsController : ControllerBase { [HttpGet] public async Task>> GetAll() { } [HttpGet("{id:int}")] public async Task> GetById(int id) { } [HttpPost] public async Task> Create(CreateProductRequest request) { } } ``` ### Model Binding Sources ```csharp // 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 Create( [FromBody] CreateProductRequest body, [FromQuery] bool? notify = false) { } ``` ## Validation ### FluentValidation (Recommended) Note: `FluentValidation.AspNetCore` is deprecated. Use manual validation: ```csharp // Validator definition public class CreateProductValidator : AbstractValidator { 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(); // Manual validation in endpoint app.MapPost("/api/products", async ( CreateProductRequest request, IValidator 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) ```csharp 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) ```csharp // 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+) ```csharp public class GlobalExceptionHandler : IExceptionHandler { public async ValueTask 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(); app.UseExceptionHandler(); ``` ## Dependency Injection ### Service Lifetimes | Lifetime | Description | Use Case | |----------|-------------|----------| | Singleton | One instance for app lifetime | Configuration, caching | | Scoped | One instance per request | DbContext, repositories | | Transient | New instance every time | Lightweight, stateless | ```csharp // Registration patterns builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddTransient(); // Keyed services (.NET 8+) builder.Services.AddKeyedSingleton("email"); builder.Services.AddKeyedSingleton("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 ```csharp 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 ```csharp 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(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 ```csharp 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 ```csharp 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 ```csharp builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.Providers.Add(); options.Providers.Add(); }); builder.Services.Configure(options => { options.Level = CompressionLevel.Fastest; }); app.UseResponseCompression(); ``` ## Health Checks ```csharp 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` for integration tests: ```csharp public class ApiTests : IClassFixture> { private readonly HttpClient _client; public ApiTests(WebApplicationFactory factory) { _factory = factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => { services.RemoveAll(); services.AddScoped(); })); _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 | Task | Code | |------|------| | 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)