Introduction
Building an ASP.NET Core REST API that works is relatively easy. Building one that remains predictable, secure, maintainable, and performant as traffic and business requirements grow requires more deliberate architecture.
Good REST API design is not only about endpoint naming. It includes resource modeling, HTTP semantics, validation, authentication, authorization, error handling, pagination, database access, observability, and deployment.
This guide focuses on practical ASP.NET Core patterns that help an API remain maintainable as the application grows.
Design Resources Around Business Concepts
Prefer resource-oriented endpoints:
GET /api/orders
GET /api/orders/{id}
POST /api/orders
PUT /api/orders/{id}
DELETE /api/orders/{id}
Avoid designing the entire API around implementation details such as:
/api/getAllOrders
/api/createNewOrder
/api/deleteOrder
The HTTP method already communicates the operation.
For commands that do not naturally map to CRUD, use explicit actions where they represent a meaningful business operation:
POST /api/orders/{id}/cancel
POST /api/orders/{id}/approve
Use DTOs at the API Boundary
Do not expose EF Core entities directly from controllers.
Use request and response DTOs:
public sealed record CreateOrderRequest(
Guid CustomerId,
IReadOnlyCollection<CreateOrderItemRequest> Items);
public sealed record OrderResponse(
Guid Id,
Guid CustomerId,
decimal Total,
string Status);
This prevents persistence concerns from becoming part of the public API contract and gives the API room to evolve independently of the database model.
Keep Controllers Thin
Controllers or Minimal API endpoints should coordinate HTTP concerns rather than contain business rules.
A typical flow is:
HTTP Request
↓
Endpoint / Controller
↓
Application Service / Handler
↓
Domain / Business Rules
↓
Repository / EF Core
↓
Database
Validation, authorization, business rules, persistence, and response mapping should have clear responsibilities.
Validate Requests at the Boundary
Use FluentValidation or another validation approach appropriate to the application.
For example:
public sealed class CreateOrderValidator
: AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty();
RuleFor(x => x.Items)
.NotEmpty();
RuleForEach(x => x.Items)
.ChildRules(item =>
{
item.RuleFor(x => x.ProductId)
.NotEmpty();
item.RuleFor(x => x.Quantity)
.GreaterThan(0);
});
}
}
Validation should reject invalid input before it reaches deeper application layers.
Return Consistent Errors
Use a consistent problem-details representation rather than returning arbitrary strings from every endpoint.
ASP.NET Core supports RFC 7807-style problem details through its built-in infrastructure.
A client should be able to distinguish:
- validation errors
- authentication failures
- authorization failures
- missing resources
- conflicts
- unexpected server errors
Do not expose stack traces or database exceptions to clients.
Use HTTP Status Codes Consistently
Typical responses include:
| Situation | Status |
|---|---|
| Successful read | 200 OK |
| Resource created | 201 Created |
| Successful command with no body | 204 No Content |
| Invalid request | 400 Bad Request |
| Unauthenticated | 401 Unauthorized |
| Forbidden | 403 Forbidden |
| Resource not found | 404 Not Found |
| Business conflict | 409 Conflict |
| Unexpected server error | 500 Internal Server Error |
The exact response should follow the API contract rather than being chosen arbitrarily.
Authentication and Authorization
Authentication establishes identity. Authorization determines whether that identity can perform an operation.
For JWT-based APIs, validate:
- issuer
- audience
- signature
- expiration
- required claims
Use policies for business authorization rather than scattering role checks throughout controllers.
[Authorize(Policy = "Orders.Read")]
[HttpGet("{id:guid}")]
public async Task<ActionResult<OrderResponse>> Get(
Guid id,
CancellationToken cancellationToken)
{
...
}
Keep authorization decisions close to the application boundary, while keeping sensitive business rules enforceable deeper in the application where necessary.
Use EF Core Efficiently
Avoid loading entire tables when an endpoint needs a small projection.
Prefer:
var orders = await dbContext.Orders
.AsNoTracking()
.Where(x => x.CustomerId == customerId)
.Select(x => new OrderResponse(
x.Id,
x.CustomerId,
x.Total,
x.Status))
.ToListAsync(cancellationToken);
This reduces unnecessary entity materialization and makes the database query match the API response.
Use AsNoTracking() for read-only queries where tracking is not required.
Avoid accidental N+1 queries and inspect generated SQL for important endpoints.
Pagination Is a Contract
Do not return an unbounded collection from a production API.
A simple offset-based contract can expose:
GET /api/orders?page=2&pageSize=50
For large or frequently changing datasets, keyset/cursor pagination can be more appropriate.
The choice depends on the access pattern and consistency requirements.
Use CancellationToken
Propagate cancellation through asynchronous operations:
public async Task<IResult> GetOrders(
CancellationToken cancellationToken)
{
var result = await service.GetOrdersAsync(cancellationToken);
return Results.Ok(result);
}
This helps stop unnecessary work when the client disconnects or the request is cancelled.
Observability Matters
Production APIs need structured logging, metrics, and tracing.
Serilog or the built-in Microsoft.Extensions.Logging abstractions can provide structured logs.
Log useful context such as:
- request correlation
- endpoint
- duration
- status code
- relevant identifiers
Do not log passwords, access tokens, secrets, or unnecessary personal data.
Distributed tracing becomes increasingly useful when requests cross multiple services.
Rate Limiting and Abuse Protection
Public or sensitive APIs should consider rate limiting.
ASP.NET Core provides rate-limiting middleware that can be configured according to the application’s traffic model.
Rate limiting should complement, not replace, authentication and authorization.
Version APIs Deliberately
API versioning should be driven by contract compatibility rather than by a desire to version every deployment.
If breaking changes are necessary, establish a clear versioning strategy and communicate deprecation timelines.
Keep Configuration Outside Code
Use configuration and environment variables for environment-specific values.
Secrets should come from an appropriate secret-management system rather than source control.
Practical Checklist
Before calling an API production-ready, verify:
- DTOs separate API and persistence models
- Input validation is centralized
- Authorization policies are defined
- Errors use a consistent contract
- Queries are projected efficiently
- Pagination exists for large collections
- Cancellation is propagated
- Logging is structured
- Sensitive information is not logged
- Rate limiting is considered
- Database queries have appropriate indexes
- API contracts are documented
- Health checks and observability are available
Conclusion
The best ASP.NET Core REST API practices are less about individual framework features and more about establishing clear boundaries.
Keep HTTP concerns at the API boundary, business rules in the application/domain layers, and persistence concerns behind a deliberate data-access boundary. Combine consistent contracts with efficient EF Core queries, strong authorization, validation, observability, and operational controls.
The result is an API that is easier to evolve without turning every new requirement into a change across the entire application.
