REST API Best Practices in ASP.NET Core 10 — Complete Guide 2026
I have reviewed hundreds of .NET codebases and the number one problem I see is not performance or architecture — it is inconsistent, poorly designed APIs. Endpoints that return 200 OK for errors. URLs like /api/getProduct. No versioning. Error messages that leak stack traces to the client.
A well-designed REST API is not just functional — it is predictable. Any developer who picks up your API documentation should be able to guess how a new endpoint behaves before reading its docs. That predictability comes from following standards consistently. In this guide I will walk you through 15 best practices with working ASP.NET Core 10 code so you can build APIs your consumers actually enjoy using.
15 Best Practices — Table of Contents
- Use proper URL naming conventions
- Return correct HTTP status codes
- Use consistent error responses
- Implement API versioning
- Always validate input
- Use DTOs — never expose domain entities
- Paginate large result sets
- Implement rate limiting
- Use OpenAPI / Swagger documentation
- Add structured logging
- Use async/await everywhere
- Implement global exception handling
- Use HTTPS and security headers
- Support filtering, sorting, searching
- Use CancellationToken on every endpoint
Best Practice 01 — Foundation Use Proper URL Naming Conventions
Your URLs are the public face of your API. They must be consistent, predictable, and resource-focused. REST URLs represent nouns (resources), not verbs (actions). The HTTP method tells you the action — the URL tells you the resource.
URLs// ❌ Bad — verb-based URLs, not RESTful GET /api/getProducts POST /api/createProduct GET /api/deleteProduct?id=5 POST /api/updateProductPrice // ✅ Good — noun-based, resource-focused URLs GET /api/products // get all products GET /api/products/{id} // get single product POST /api/products // create product PUT /api/products/{id} // full update PATCH /api/products/{id} // partial update DELETE /api/products/{id} // delete product // ✅ Nested resources — show relationships GET /api/orders/{id}/items // get items of an order POST /api/orders/{id}/items // add item to order DELETE /api/orders/{id}/items/{itemId} // remove item from order // URL rules to always follow: // ✅ Use lowercase: /api/products not /api/Products // ✅ Use plurals: /api/products not /api/product // ✅ Use hyphens: /api/product-categories not /api/productCategories // ✅ Never use: /api/getProduct, /api/createUser, /api/deleteOrder
Best Practice 02 — HTTP Standards Return Correct HTTP Status Codes
Returning 200 OK for everything — including errors — is one of the most common API mistakes. HTTP status codes carry semantic meaning that client developers rely on. Use them correctly.
C#[ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { // GET — 200 OK (found) or 404 Not Found [HttpGet("{id:guid}")] public async Task<IActionResult> GetById(Guid id, CancellationToken ct) { var product = await _service.GetByIdAsync(id, ct); return product == null ? NotFound() // 404 : Ok(product); // 200 } // POST — 201 Created with Location header [HttpPost] public async Task<IActionResult> Create(CreateProductDto dto, CancellationToken ct) { var id = await _service.CreateAsync(dto, ct); return CreatedAtAction(nameof(GetById), new { id }, new { id }); // 201 } // PUT — 200 OK or 204 No Content [HttpPut("{id:guid}")] public async Task<IActionResult> Update(Guid id, UpdateProductDto dto, CancellationToken ct) { await _service.UpdateAsync(id, dto, ct); return NoContent(); // 204 — updated, no body needed } // DELETE — 204 No Content [HttpDelete("{id:guid}")] public async Task<IActionResult> Delete(Guid id, CancellationToken ct) { await _service.DeleteAsync(id, ct); return NoContent(); // 204 } } /* Status code reference: 200 OK — successful GET, PUT, PATCH 201 Created — successful POST (include Location header) 204 No Content — successful DELETE or PUT with no response body 400 Bad Request — invalid input, validation error 401 Unauthorized— not authenticated 403 Forbidden — authenticated but not authorised 404 Not Found — resource does not exist 409 Conflict — duplicate resource, state conflict 422 Unprocessable — business rule violation 429 Too Many Requests — rate limit exceeded 500 Server Error — unexpected server failure */
Best Practice 03 — Error Handling Use Consistent Error Responses — ProblemDetails
When an error occurs, your API should return a structured, consistent error response — not a raw exception message, not a plain string, and not an empty body. ASP.NET Core 10 uses the RFC 7807 ProblemDetails standard out of the box.
C#// Program.cs — enable ProblemDetails for all errors builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // GlobalExceptionHandler.cs — catches all unhandled exceptions public class GlobalExceptionHandler : IExceptionHandler { private readonly ILogger<GlobalExceptionHandler> _logger; public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) => _logger = logger; public async ValueTask<bool> TryHandleAsync( HttpContext ctx, Exception exception, CancellationToken ct) { _logger.LogError(exception, "Unhandled exception: {Message}", exception.Message); var (statusCode, title) = exception switch { NotFoundException => (StatusCodes.Status404NotFound, "Resource not found"), ValidationException => (StatusCodes.Status400BadRequest, "Validation failed"), ConflictException => (StatusCodes.Status409Conflict, "Conflict"), _ => (StatusCodes.Status500InternalServerError, "Server error") }; ctx.Response.StatusCode = statusCode; await ctx.Response.WriteAsJsonAsync(new ProblemDetails { Status = statusCode, Title = title, Detail = exception.Message, Instance = ctx.Request.Path }, ct); return true; } } /* Consistent error response format (RFC 7807): { "type": "https://tools.ietf.org/html/rfc7807", "title": "Resource not found", "status": 404, "detail": "Product with ID abc-123 was not found", "instance": "/api/products/abc-123" } */
Best Practice 04 — Versioning Implement API Versioning from Day One
The most common mistake in API design is not adding versioning until it is too late. Once clients depend on your API, a breaking change without versioning breaks all of them at once. Add versioning from your very first endpoint — it is much harder to add it later.
C#// Install: dotnet add package Asp.Versioning.Mvc // Program.cs — configure versioning builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1, 0); options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; // adds api-supported-versions header options.ApiVersionReader = ApiVersionReader.Combine( new UrlSegmentApiVersionReader(), // /api/v1/products new HeaderApiVersionReader("api-version"), // header: api-version: 1.0 new QueryStringApiVersionReader("version") // ?version=1.0 ); }); // V1 Controller [ApiController] [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/[controller]")] public class ProductsController : ControllerBase { [HttpGet] public IActionResult GetV1() => Ok(new { version = "1.0", message = "V1 response" }); } // V2 Controller — new version, old clients unaffected [ApiController] [ApiVersion("2.0")] [Route("api/v{version:apiVersion}/[controller]")] public class ProductsV2Controller : ControllerBase { [HttpGet] public IActionResult GetV2() => Ok(new { version = "2.0", enhanced = true }); }
Best Practice 05 — Input Safety Always Validate Input
Never trust data coming from the client. Validate every request body, every query parameter, and every route value. ASP.NET Core 10 Minimal APIs now have built-in validation support. For controllers, use FluentValidation.
C#// FluentValidation — expressive, testable validators public class CreateProductValidator : AbstractValidator<CreateProductDto> { public CreateProductValidator() { RuleFor(x => x.Name) .NotEmpty().WithMessage("Name is required") .MaximumLength(200).WithMessage("Name cannot exceed 200 characters"); RuleFor(x => x.Price) .GreaterThan(0).WithMessage("Price must be greater than zero") .LessThan(1_000_000).WithMessage("Price is unrealistically high"); RuleFor(x => x.Stock) .GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative"); RuleFor(x => x.CategoryId) .NotEmpty().WithMessage("Category is required"); } } // ASP.NET Core 10 Minimal API — built-in validation (new!) app.MapPost("/api/products", (CreateProductDto dto, IProductService svc) => svc.CreateAsync(dto)) .AddValidation(); // automatic 400 if validation fails // Validation error response — automatically returned as ProblemDetails: /* { "title": "One or more validation errors occurred.", "status": 400, "errors": { "Name": ["Name is required"], "Price": ["Price must be greater than zero"] } } */
Best Practice 06 — Data Design Use DTOs — Never Expose Domain Entities Directly
Returning your EF Core entities directly from API endpoints is one of the most dangerous mistakes in backend development. It exposes your database schema to the public, makes circular reference serialisation errors likely, and couples your API contract to your internal data model. Always use separate Data Transfer Objects.
C#// ❌ Bad — returning domain entity directly [HttpGet("{id}")] public async Task<Product> Get(Guid id) // exposes ALL fields including internal ones => await _repo.GetByIdAsync(id); // ✅ Good — separate DTOs for each use case // Request DTO — what comes IN public record CreateProductDto( string Name, string Description, decimal Price, int Stock, Guid CategoryId ); // Response DTO — what goes OUT (you control exactly what is exposed) public record ProductResponseDto( Guid Id, string Name, string Description, decimal Price, int Stock, string CategoryName, DateTime CreatedAt // Notice: no PasswordHash, no InternalNotes, no NavigationProperties ); // List DTO — even less data for list views public record ProductSummaryDto(Guid Id, string Name, decimal Price);
Best Practice 07 — Performance Paginate Large Result Sets — Always
C#// Pagination request public record PagedRequest( int Page = 1, int PageSize = 20 ) { public int PageSize { get; } = Math.Min(PageSize, 100); // cap at 100 } // Pagination response — include metadata so clients know total count public record PagedResponse<T>( IReadOnlyList<T> Data, int Page, int PageSize, int TotalCount, bool HasNextPage, bool HasPreviousPage ); // Controller endpoint with pagination [HttpGet] public async Task<IActionResult> GetAll( [FromQuery] PagedRequest req, CancellationToken ct) { var total = await _context.Products.CountAsync(ct); var data = await _context.Products .AsNoTracking() .OrderBy(p => p.CreatedAt) .Skip((req.Page - 1) * req.PageSize) .Take(req.PageSize) .Select(p => new ProductSummaryDto(p.Id, p.Name, p.Price)) .ToListAsync(ct); return Ok(new PagedResponse<ProductSummaryDto>( Data: data, Page: req.Page, PageSize: req.PageSize, TotalCount: total, HasNextPage: req.Page * req.PageSize < total, HasPreviousPage: req.Page > 1 )); }
Best Practice 08 — Security Implement Rate Limiting — Built-in in .NET 10
Rate limiting protects your API from abuse, DDoS attacks, and runaway clients. ASP.NET Core has built-in rate limiting — no third-party library needed.
C#// Program.cs — configure rate limiting policies builder.Services.AddRateLimiter(options => { // Fixed window — 10 requests per minute per IP options.AddFixedWindowLimiter("fixed", config => { config.PermitLimit = 10; config.Window = TimeSpan.FromMinutes(1); config.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; config.QueueLimit = 2; }); // Sliding window — smoother, fairer for bursts options.AddSlidingWindowLimiter("sliding", config => { config.PermitLimit = 100; config.Window = TimeSpan.FromMinutes(1); config.SegmentsPerWindow = 4; }); // Token bucket — allow short bursts then steady rate options.AddTokenBucketLimiter("token", config => { config.TokenLimit = 20; config.ReplenishmentPeriod = TimeSpan.FromSeconds(10); config.TokensPerPeriod = 5; }); // 429 Too Many Requests on limit hit options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); app.UseRateLimiter(); // Apply to specific endpoints app.MapGet("/api/products", GetProducts) .RequireRateLimiting("fixed"); // Apply to all endpoints in a controller [EnableRateLimiting("sliding")] public class ProductsController : ControllerBase { }
Best Practice 09 — Documentation Use OpenAPI 3.1 Documentation — New in .NET 10
ASP.NET Core 10 now generates OpenAPI 3.1 documentation natively — no Swashbuckle required for basic setup. Good API documentation is what separates professional APIs from hobby projects.
C#// Program.cs — native OpenAPI 3.1 in .NET 10 builder.Services.AddOpenApi(options => { options.AddDocumentTransformer((document, context, ct) => { document.Info = new() { Title = "Code With Adnan — Product API", Version = "v1", Description = "REST API for product management built with ASP.NET Core 10", Contact = new() { Name = "Adnan Shaukat" } }; return Task.CompletedTask; }); }); app.MapOpenApi(); // serves /openapi/v1.json // Add Scalar UI (modern replacement for Swagger UI in .NET 10) // dotnet add package Scalar.AspNetCore app.MapScalarApiReference(); // Annotate endpoints for better docs app.MapGet("/api/products/{id}", GetProductById) .WithName("GetProduct") .WithSummary("Get a product by ID") .WithDescription("Returns a single product. Returns 404 if not found.") .WithOpenApi() .Produces<ProductResponseDto>(200) .ProducesProblem(404) .ProducesProblem(400);
Best Practice 10 — Observability Add Structured Logging to Every Endpoint
C#// ❌ Bad — string interpolation loses structured data _logger.LogInformation($"User {userId} fetched product {productId}"); // ✅ Good — structured logging with named parameters _logger.LogInformation( "User {UserId} fetched product {ProductId}", userId, productId); // ✅ Use log scopes for request-level correlation public async Task<IActionResult> GetById(Guid id, CancellationToken ct) { using var scope = _logger.BeginScope(new Dictionary<string, object> { ["ProductId"] = id, ["RequestPath"] = HttpContext.Request.Path }); _logger.LogInformation("Fetching product"); var product = await _service.GetByIdAsync(id, ct); if (product == null) { _logger.LogWarning("Product {ProductId} not found", id); return NotFound(); } _logger.LogInformation("Product {ProductId} returned successfully", id); return Ok(product); } // Use Serilog for production — structured, searchable logs // dotnet add package Serilog.AspNetCore builder.Host.UseSerilog((ctx, cfg) => cfg.WriteTo.Console( outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {Message} {Properties}{NewLine}") .WriteTo.File("logs/api-.log", rollingInterval: RollingInterval.Day));
Best Practice 11 — Async All The Way Use async/await on Every I/O Operation
Every database call, every HTTP call, every file operation in your API should be async. Synchronous I/O blocks a thread for the entire duration of the operation. In a high-traffic API this causes thread starvation where the thread pool runs out of available threads to serve new requests.
C#// ❌ Bad — synchronous, blocks thread pool public IActionResult GetProducts() { var products = _context.Products.ToList(); // blocks thread! return Ok(products); } // ✅ Good — fully async, thread released during DB wait public async Task<IActionResult> GetProducts(CancellationToken ct) { var products = await _context.Products .AsNoTracking() .ToListAsync(ct); // thread free during I/O return Ok(products); } // ✅ Always use ConfigureAwait(false) in library code public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct) => await _context.Products .FirstOrDefaultAsync(p => p.Id == id, ct) .ConfigureAwait(false); // avoids deadlocks in library code
Best Practice 12 — Resilience Support Filtering, Sorting, and Searching
C#// Clean query parameters for filtering, sorting, searching // GET /api/products?search=laptop&minPrice=500&maxPrice=2000&sortBy=price&sortOrder=asc&page=1&pageSize=20 public record ProductQueryParams( string? Search = null, decimal? MinPrice = null, decimal? MaxPrice = null, Guid? CategoryId = null, string SortBy = "createdAt", string SortOrder = "desc", int Page = 1, int PageSize = 20 ); [HttpGet] public async Task<IActionResult> GetAll( [FromQuery] ProductQueryParams q, CancellationToken ct) { var query = _context.Products.AsNoTracking().AsQueryable(); // Filtering if (!string.IsNullOrEmpty(q.Search)) query = query.Where(p => p.Name.Contains(q.Search) || p.Description.Contains(q.Search)); if (q.MinPrice.HasValue) query = query.Where(p => p.Price >= q.MinPrice); if (q.MaxPrice.HasValue) query = query.Where(p => p.Price <= q.MaxPrice); if (q.CategoryId.HasValue) query = query.Where(p => p.CategoryId == q.CategoryId); // Sorting query = (q.SortBy.ToLower(), q.SortOrder.ToLower()) switch { ("price", "asc") => query.OrderBy(p => p.Price), ("price", "desc") => query.OrderByDescending(p => p.Price), ("name", "asc") => query.OrderBy(p => p.Name), _ => query.OrderByDescending(p => p.CreatedAt) }; var total = await query.CountAsync(ct); var data = await query .Skip((q.Page - 1) * q.PageSize).Take(q.PageSize) .Select(p => new ProductSummaryDto(p.Id, p.Name, p.Price)) .ToListAsync(ct); return Ok(new PagedResponse<ProductSummaryDto>( data, q.Page, q.PageSize, total, HasNextPage: q.Page * q.PageSize < total, HasPreviousPage: q.Page > 1)); }
Best Practice 13 — Security Use HTTPS and Add Security Headers
C#// Program.cs — enforce HTTPS and add security headers app.UseHttpsRedirection(); app.UseHsts(); // HSTS header — forces HTTPS for a year // Add security headers middleware app.Use(async (context, next) => { context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); context.Response.Headers.Append("X-Frame-Options", "DENY"); context.Response.Headers.Append("X-XSS-Protection", "1; mode=block"); context.Response.Headers.Append("Referrer-Policy", "strict-origin"); context.Response.Headers.Append("Permissions-Policy", "geolocation=()"); context.Response.Headers.Remove("Server"); // hide server info await next(); }); // Configure CORS properly — never use AllowAnyOrigin in production builder.Services.AddCors(options => { options.AddPolicy("ProductionPolicy", policy => policy .WithOrigins("https://yourfrontend.com") .WithMethods("GET", "POST", "PUT", "DELETE") .WithHeaders("Content-Type", "Authorization") .WithExposedHeaders("X-Pagination")); });
Best Practice 14 — Health Checks Add Health Check Endpoints
C#// Program.cs — health checks for database and dependencies builder.Services.AddHealthChecks() .AddDbContextCheck<AppDbContext>("database") .AddUrlGroup(new Uri("https://api.external-service.com/health"), "external-api"); // Expose health check endpoints app.MapHealthChecks("/health"); app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready"), ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse });
Best Practice 15 — Resilience Use CancellationToken on Every Endpoint
When a client disconnects mid-request — closes the browser, cancels the fetch call — ASP.NET Core fires a CancellationToken. If you do not pass this token to your database queries, those queries keep running even though no one is waiting for the result. This wastes database connections and CPU on work that will never be used.
C#// ❌ Bad — query runs even if client disconnected [HttpGet] public async Task<IActionResult> GetAll() { var data = await _context.Products.ToListAsync(); // no cancellation! return Ok(data); } // ✅ Good — CancellationToken passed to ALL async operations [HttpGet] public async Task<IActionResult> GetAll(CancellationToken ct) { var data = await _context.Products .AsNoTracking() .ToListAsync(ct); // cancels DB query if client disconnects return Ok(data); } // ✅ Pass CancellationToken through every layer public async Task<List<Product>> GetProductsAsync(CancellationToken ct) { return await _repo.GetAllAsync(ct); // repository receives it too }
Quick Reference Production-Ready API Checklist
URLs use nouns, plurals, lowercase, and hyphens
Correct HTTP status codes returned for every scenario
ProblemDetails format used for all error responses
API versioning added from day one
All inputs validated with FluentValidation
Separate request and response DTOs — no entity exposure
All list endpoints paginated with metadata
Rate limiting configured and applied
OpenAPI 3.1 documentation generated
Structured logging on every endpoint
All I/O operations use async/await
HTTPS enforced + security headers added
Filter, sort, and search via query parameters
Health check endpoints configured
CancellationToken passed through every async call
Final Thoughts
A well-designed REST API is a joy to consume. Follow these 15 practices consistently and your APIs will be predictable, safe, documented, and performant. The best time to apply these practices is at the start of a project. The second best time is today.
Written by
Adnan Shaukat
Backend Developer with 5+ years in .NET, Python, and REST APIs. Writing daily at codewithadnanshaukat.blogspot.com
Comments
Post a Comment