.NET API Development Skill
dotnet-api
Patterns and best practices for building .NET Web APIs with ASP.NET Core
SKILL.md
Full skill instructions
.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
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
| 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 |
// 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 = "[email protected]"
}
});
// 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
| 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)
