REST API Best Practices in ASP.NET Core 10 — Complete Guide 2026

ASP.NET Core 10 REST API 🔥 Production Ready

REST API Best Practices in ASP.NET Core 10 — Complete Guide 2026

Stop building APIs that confuse, break, and frustrate your consumers. Follow these 15 battle-tested best practices with real C# code to build ASP.NET Core APIs that are scalable, predictable, and production-ready.

AS

Adnan Shaukat

May 20, 2026  ·  15 min read  ·  Backend Developer

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.

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.

REST API ASP.NET Core 10 Best Practices API Design C# .NET 10 Backend Web API
AS

Written by

Adnan Shaukat

Backend Developer with 5+ years in .NET, Python, and REST APIs. Writing daily at codewithadnanshaukat.blogspot.com

Comments

Popular posts from this blog

What is Agentic AI? A Complete Developer Guide for 2026

CQRS Pattern in .NET 10 with MediatR — Complete Step-by-Step Tutorial

EF Core 10 Performance Tips — 12 Ways to Make Your Queries Blazing Fast