Building an API that works for a simple CRUD demo is easy. Building an API that remains maintainable, testable, and performant as your team adds dozens of endpoints and entities is a different challenge. In this guide, we walk through the architectural layers of modern ASP.NET Core Web APIs.
1. The Layer Breakdown
A resilient .NET solution typically divides responsibility into 4 distinct projects or layers:
- Domain Layer: Pure business entities, enums, exceptions, and domain events. Zero external dependencies.
- Application Layer: Use cases, service contracts, DTOs, CQRS handlers (MediatR), and validation rules (FluentValidation).
- Infrastructure Layer: Database access (EF Core DbContext), external API integrations, email senders, and caching (Redis).
- API / Presentation Layer: Controllers or Minimal APIs, middleware, authentication handlers, and dependency injection configuration.
2. Writing Slim Controllers
Controllers should do only three things: validate incoming HTTP inputs, delegate execution to an application service or mediator, and return an appropriate HTTP status code.
[ApiController]
[Route("api/v1/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ProductResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var product = await _productService.GetByIdAsync(id, ct);
if (product is null) return NotFound();
return Ok(product);
}
}
3. Data Transfer Objects (DTOs) vs Domain Entities
Never return raw Entity Framework models directly to clients. Returning EF entities leaks database schema details and causes cyclic serialization bugs.
public record CreateProductRequest(
string Name,
decimal Price,
string Sku
);
public record ProductResponse(
Guid Id,
string Name,
decimal Price,
DateTime CreatedAtUtc
);
record types for DTOs. They provide value-based equality, immutability, and concise syntax with zero boilerplate.
4. Global Exception Middleware
Instead of wrapping every controller action in try/catch blocks, configure global error handling using the native IExceptionHandler introduced in .NET 8.
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) => _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken ct)
{
_logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server Error",
Detail = "An unexpected error occurred. Please try again later."
};
context.Response.StatusCode = problem.Status.Value;
await context.Response.WriteAsJsonAsync(problem, ct);
return true;
}
}
5. Key Takeaways
- Keep controllers lightweight — delegate to business services or MediatR commands.
- Always map database entities to request/response DTO records.
- Rely on ASP.NET Core built-in Dependency Injection and register services with proper lifetimes (
Scoped,Singleton,Transient). - Use
IExceptionHandlerfor standardized RFC 7807 Problem Details error responses.