Accepted
When building RESTful APIs in .NET, there are multiple approaches to defining HTTP endpoints:
- Traditional MVC Controllers: Class-based controllers with action methods decorated with route attributes
- Minimal APIs: Lightweight, inline route handlers introduced in .NET 6+
- API Controllers with attributes: Attribute-based routing with
[ApiController]and[Route]attributes
Additionally, decisions must be made about:
- What to expose: Domain aggregates/entities directly vs. Data Transfer Objects (DTOs)
- OpenAPI documentation: How to generate comprehensive API documentation for consumers
- Layer boundaries: How to maintain clean architecture while exposing APIs
- Consistency: How to ensure uniform endpoint patterns across modules
The application needed an API strategy that:
- Provides lightweight, performant HTTP endpoints
- Maintains clean architecture by not exposing domain internals
- Generates comprehensive OpenAPI/Swagger documentation automatically
- Supports modular organization aligned with domain modules
- Enables explicit request/response contracts with proper HTTP semantics
- Allows easy testing and minimal boilerplate
Adopt ASP.NET Core Minimal APIs organized as endpoint classes, with DTO/Model exposure (never domain entities), and comprehensive OpenAPI metadata.
- Endpoint Classes: Derive from
EndpointsBaseand overrideMap(IEndpointRouteBuilder) - Route Groups: Use
MapGroup()to organize related endpoints with common prefixes and policies - DTO Exposure: API contracts use
*ModelDTOs from Application layer, never domain entities - IRequester Pattern: Endpoints delegate to commands/queries via
IRequester.SendAsync() - Result Mapping: Use
.MapHttpOk(),.MapHttpCreated(),.MapHttpNoContent()forResult<T>responses - OpenAPI Metadata: Every endpoint includes
.WithName(),.WithSummary(),.WithDescription(),.Produces<T>(),.ProducesProblem()
namespace CoreModule.Presentation.Web;
[ExcludeFromCodeCoverage]
public class CustomerEndpoints : EndpointsBase
{
public override void Map(IEndpointRouteBuilder app)
{
var group = app
.MapGroup("api/coremodule/customers")
.RequireAuthorization()
.WithTags("CoreModule.Customers");
// GET /{id:guid} -> Find one customer by ID
group.MapGet("/{id:guid}",
async ([FromServices] IRequester requester,
[FromRoute] string id, CancellationToken ct)
=> (await requester
.SendAsync(new CustomerFindOneQuery(id), cancellationToken: ct))
.MapHttpOk())
.WithName("CoreModule.Customers.GetById")
.WithSummary("Get customer by ID")
.WithDescription("Retrieves a single customer by their unique identifier.")
.Produces<CustomerModel>(StatusCodes.Status200OK, "application/json")
.Produces(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesResultProblem(StatusCodes.Status400BadRequest);
// POST -> Create new customer
group.MapPost("",
async ([FromServices] IRequester requester,
[FromBody] CustomerModel model, CancellationToken ct)
=> (await requester
.SendAsync(new CustomerCreateCommand(model), cancellationToken: ct))
.MapHttpCreated(v => $"/api/coremodule/customers/{v.Id}"))
.WithName("CoreModule.Customers.Create")
.WithSummary("Create a new customer")
.Accepts<CustomerModel>("application/json")
.Produces<CustomerModel>(StatusCodes.Status201Created, "application/json")
.ProducesResultProblem(StatusCodes.Status400BadRequest);
}
}Never expose domain entities directly:
- WRONG
Customer(domain aggregate) - WRONG
EmailAddress(value object) - CORRECT
CustomerModel(DTO with primitive properties) - CORRECT
CustomerUpdateStatusRequestModel(DTO for specific operations)
Mapping layer:
- Mapster configurations in
MapperRegisterclasses convert between domain and DTOs - Handlers return
Result<CustomerModel>, notResult<Customer> - Endpoints receive and return DTOs only
Every endpoint must include:
- Name:
.WithName("Module.Resource.Operation")for client code generation - Summary:
.WithSummary("Brief title")for OpenAPI UI - Description:
.WithDescription("Detailed explanation")for developer documentation - Request Body:
.Accepts<TModel>("application/json")when accepting JSON - Success Response:
.Produces<TModel>(StatusCodes.Status200OK)with model type - Error Responses:
.ProducesProblem()or.ProducesResultProblem()for each error status - Tags: Applied via
.WithTags()for grouping in OpenAPI UI
.MapHttpOk(): MapsResult<T>→ HTTP 200 with body.MapHttpOkAll(): MapsResult<IEnumerable<T>>→ HTTP 200 with collection.MapHttpCreated(locationFactory): MapsResult<T>→ HTTP 201 with Location header.MapHttpNoContent(): MapsResult→ HTTP 204 (no body)
These extensions automatically:
- Return 200/201/204 on success with appropriate body
- Return 400 with ProblemDetails on validation failure
- Return 404 when result is empty/not found
- Return 500 with ProblemDetails on unexpected errors
- Performance: Minimal APIs have lower overhead than MVC controllers (no model binding complexity)
- Simplicity: Inline handlers reduce ceremony and boilerplate compared to controllers
- Modern: Aligned with .NET's direction (introduced .NET 6, enhanced .NET 7+)
- Explicit: Route handlers are explicit and co-located with route definitions
- Testability: Easy to test without needing controller context infrastructure
- Less Magic: No attribute-based routing discovery; everything is explicit
- Layer Protection: Prevents external clients from depending on domain implementation details
- Versioning: DTOs can evolve independently from domain model for API compatibility
- Security: Domain entities may contain sensitive logic/data not meant for external exposure
- Serialization Control: DTOs designed for JSON serialization; domain entities designed for business logic
- Backward Compatibility: Can maintain old DTO versions while evolving domain model
- Explicit Contracts: API contracts are clear and don't leak aggregate structures
- Client Generation: Enables automatic client SDK generation (TypeScript, C#, etc.)
- Documentation: OpenAPI UI (Swagger) provides interactive API exploration for developers
- Validation: Tools can validate requests/responses against OpenAPI schema
- Discoverability: New developers can understand API capabilities without reading code
- Standards Compliance: OpenAPI is industry standard for REST API documentation
- Testing: OpenAPI spec can drive automated API testing tools
- Organization: Endpoints grouped by module/resource in dedicated classes
- Discoverability: Easy to find all endpoints for a resource in one file
- Testability: Endpoint classes can be unit tested independently
- Separation of Concerns: Keeps
Program.csclean and focused on composition - Module Alignment: Each module registers its own endpoints via
services.AddEndpoints<T>()
- Clean Separation: API layer completely decoupled from domain layer via DTOs
- Performance: Minimal APIs are faster than MVC controllers (lower memory allocation, faster routing)
- OpenAPI Quality: Comprehensive metadata produces excellent API documentation automatically
- Maintainability: Clear endpoint classes easy to locate and modify
- Type Safety: Strongly-typed DTOs provide compile-time safety for API contracts
- Testability: Thin endpoint classes are easy to test; business logic tested in handlers
- Consistency: Standardized pattern across all modules and endpoints
- Evolution: DTOs can version independently; domain can refactor without breaking API
- Client Experience: Generated clients are type-safe and well-documented
- Mapping Overhead: Every request/response requires mapping between domain and DTOs
- Duplication: DTOs may appear similar to domain entities, feeling redundant
- Maintenance: Changes to domain model require updating DTOs and mappings
- Learning Curve: Minimal APIs are newer; some team members may be unfamiliar
- Tooling: IDE tooling for Minimal APIs less mature than for MVC controllers (improving)
- OpenAPI Metadata Verbosity: Comprehensive metadata makes endpoints verbose but documents well
- Mapping Configuration: Mapster configurations centralized in
MapperRegisterclasses - Endpoint Registration: Each module registers endpoints via
services.AddEndpoints<T>() - Result Mapping: Custom extensions simplify
Result<T>→ HTTP response conversion
namespace <Module>.Presentation.Web;
[ExcludeFromCodeCoverage] // Endpoints are thin adapters, tested via integration tests
public class <Resource>Endpoints : EndpointsBase
{
public override void Map(IEndpointRouteBuilder app)
{
var group = app
.MapGroup("api/<module>/<resource>")
.RequireAuthorization() // Apply if auth required
.WithTags("<Module>.<Resource>");
// Define endpoints here using MapGet, MapPost, MapPut, MapDelete
}
}group.MapGet("/{id:guid}",
async ([FromServices] IRequester requester,
[FromRoute] string id,
CancellationToken ct)
=> (await requester
.SendAsync(new <Resource>FindOneQuery(id), cancellationToken: ct))
.MapHttpOk())
.WithName("<Module>.<Resource>.GetById")
.WithSummary("<Brief summary>")
.WithDescription("<Detailed description with examples>")
.Produces<TModel>(StatusCodes.Status200OK, "application/json")
.Produces(StatusCodes.Status401Unauthorized)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesResultProblem(StatusCodes.Status400BadRequest)
.ProducesResultProblem(StatusCodes.Status500InternalServerError);DTOs should:
- Use primitive types (
string,int,decimal,DateTime,Guid) - Be serializable to/from JSON without custom converters
- Include XML documentation for OpenAPI schema generation
- Have nullable reference types for optional fields
- Be immutable (records) when possible
Example DTO:
namespace CoreModule.Application.Models;
/// <summary>
/// Represents a customer in the system.
/// </summary>
public sealed record CustomerModel
{
/// <summary>
/// Gets or sets the unique identifier.
/// </summary>
public string Id { get; set; }
/// <summary>
/// Gets or sets the customer number.
/// </summary>
public string CustomerNumber { get; set; }
/// <summary>
/// Gets or sets the email address.
/// </summary>
public string Email { get; set; }
/// <summary>
/// Gets or sets the first name.
/// </summary>
public string FirstName { get; set; }
/// <summary>
/// Gets or sets the last name.
/// </summary>
public string LastName { get; set; }
/// <summary>
/// Gets or sets the customer status (Lead, Active, Retired).
/// </summary>
public string Status { get; set; }
/// <summary>
/// Gets or sets the concurrency version for optimistic locking.
/// </summary>
public string ConcurrencyVersion { get; set; }
}Mapster configurations in module MapperRegister:
namespace CoreModule.Application;
internal sealed class MapperRegister : IRegister
{
public void Register(TypeAdapterConfig config)
{
// Domain → DTO
config.NewConfig<Customer, CustomerModel>()
.Map(dest => dest.Id, src => src.Id.Value.ToString())
.Map(dest => dest.CustomerNumber, src => src.CustomerNumber.Value)
.Map(dest => dest.Email, src => src.Email.Value)
.Map(dest => dest.Status, src => src.Status.Name)
.Map(dest => dest.ConcurrencyVersion, src => src.ConcurrencyVersion.ToString());
// DTO → Domain (for commands)
config.NewConfig<CustomerModel, Customer>()
.ConstructUsing(src => Customer.Create(
CustomerNumber.Create(src.CustomerNumber).Value,
EmailAddress.Create(src.Email).Value,
src.FirstName,
src.LastName).Value)
.IgnoreNonMapped(true);
}
}- 200 OK: Successful GET, PUT (returns updated resource)
- 201 Created: Successful POST (returns created resource + Location header)
- 204 No Content: Successful DELETE (no response body)
- 400 Bad Request: Validation failure or invalid input (ProblemDetails)
- 401 Unauthorized: Authentication required but missing/invalid
- 404 Not Found: Resource not found
- 409 Conflict: Concurrency conflict (optimistic locking failure)
- 500 Internal Server Error: Unexpected server error (ProblemDetails)
Use route constraints for type safety:
{id:guid}- Ensures ID is a valid GUID{id:int}- Ensures ID is an integer{id:minlength(5)}- Minimum length validation
Be explicit with parameter sources:
[FromServices]- Dependency injection[FromRoute]- URL path parameters[FromQuery]- Query string parameters[FromBody]- Request body JSON[FromHeader]- HTTP headers
[ApiController]
[Route("api/coremodule/customers")]
public class CustomersController : ControllerBase
{
private readonly IRequester requester;
public CustomersController(IRequester requester)
{
this.requester = requester;
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(string id, CancellationToken ct)
{
var result = await this.requester.SendAsync(new CustomerFindOneQuery(id), ct);
return result.IsSuccess ? Ok(result.Value) : NotFound();
}
}Rejected because:
- More boilerplate (class with constructor, fields)
- Slower performance than Minimal APIs
- More "magic" (attribute-based routing discovery)
- Harder to see all endpoints at a glance
- Not aligned with .NET's modern direction
.Produces<Customer>(StatusCodes.Status200OK) // X Domain entityRejected because:
- Violates clean architecture layer boundaries
- Couples external API to domain implementation details
- Makes API versioning difficult
- Exposes internal domain structure to external consumers
- Prevents domain refactoring without breaking API
- May leak sensitive business logic or data
group.MapGet("/{id}", async (string id) => await GetCustomer(id));
// No .WithName, .WithSummary, .Produces, etc.Rejected because:
- OpenAPI generation is incomplete and low-quality
- Client generation produces poor results
- Developers can't discover API capabilities
- No documentation for external consumers
- Testing tools can't validate contracts
// In Program.cs
app.MapGet("/api/customers/{id}", async (string id) => { /* handler */ });
app.MapPost("/api/customers", async (CustomerModel model) => { /* handler */ });Rejected because:
Program.csbecomes massive and unmanageable- Endpoints not organized by module/resource
- Hard to discover all endpoints for a resource
- Can't easily test endpoint registration
- Doesn't scale for modular architecture
- ADR-0001: Clean Architecture enforces DTO exposure at boundaries
- ADR-0002: Result<T> pattern maps to HTTP responses via extensions
- ADR-0003: Each module registers its own endpoints
- ADR-0005: Endpoints use IRequester to send commands/queries
- ADR-0010: Mapster handles domain ↔ DTO mapping
- ASP.NET Core Minimal APIs
- OpenAPI Specification
- REST API Design Best Practices
- Martin Fowler - DTO Pattern
- Microsoft - Web API Documentation with Swagger
OpenAPI specification automatically generated at /openapi/v1.json and available via Swagger UI at /swagger.
Endpoints registered per module:
// In module's Module.cs
services.AddEndpoints<CustomerEndpoints>();- Unit Tests: Not typically needed for thin endpoint classes
- Integration Tests: Test endpoints end-to-end with
WebApplicationFactory - OpenAPI Validation: Automated tools validate OpenAPI spec correctness
- Endpoint Class:
<Resource>Endpoints(plural) - Endpoint Name:
<Module>.<Resource>.<Operation>(e.g.,CoreModule.Customers.GetById) - Route Group:
api/<module>/<resource>(lowercase) - Tags:
<Module>.<Resource>(e.g.,CoreModule.Customers)
.RequireAuthorization()applied at group level for all endpoints- Individual endpoints can override:
.AllowAnonymous() - Policy-based authorization:
.RequireAuthorization("PolicyName")
- Endpoint Classes:
src/Modules/<Module>/<Module>.Presentation/Web/Endpoints/ - DTO Models:
src/Modules/<Module>/<Module>.Application/Models/ - Mapping Configs:
src/Modules/<Module>/<Module>.Presentation/MapperRegister.cs - Integration Tests:
tests/Modules/<Module>/<Module>.IntegrationTests/Endpoints/
Search with complex filters (POST instead of GET for large filter payloads):
group.MapPost("search",
async ([FromServices] IRequester requester,
[FromBody] FilterModel filter, CancellationToken ct)
=> (await requester.SendAsync(new CustomerFindAllQuery { Filter = filter }, ct))
.MapHttpOkAll())
.WithName("CoreModule.Customers.Search");Partial update (specific field updates):
group.MapPut("/{id}/status",
async ([FromRoute] string id,
[FromBody] CustomerUpdateStatusRequestModel body, CancellationToken ct)
=> (await requester.SendAsync(new CustomerUpdateStatusCommand(id, body.Status), ct))
.MapHttpOk());Bulk operations:
group.MapPost("bulk",
async ([FromBody] IEnumerable<CustomerModel> models, CancellationToken ct)
=> (await requester.SendAsync(new CustomerBulkCreateCommand(models), ct))
.MapHttpOk());