API Layer — Controllers, Minimal APIs, and Request/Response Mapping
How the API layer works in Clean Architecture: controllers as thin orchestrators, request/response DTOs, mapping to commands and queries, Problem Details for errors, and the production mistakes that happen when business logic leaks into controllers.
What the API Layer Does
The API layer is the entry point — it receives HTTP requests, maps them to commands or queries, calls the appropriate handler, and maps the Result back to an HTTP response.
API layer responsibilities:
✓ Receive and deserialize HTTP requests
✓ Map request DTOs to Application commands/queries
✓ Call command or query handlers
✓ Map Result to IActionResult (200, 201, 400, 404, 409...)
✓ Register all services in Program.cs
✓ Middleware pipeline configuration
API layer does NOT:
✗ Contain business logic
✗ Call the database directly
✗ Contain validation rules (those live in Application/FluentValidation)
✗ Instantiate domain entities Production issue I've seen: A controller had 200 lines of business logic — querying the DB via
DbContext, validating complex rules, sending emails. The endpoint worked, but it could not be tested without an HTTP request, a real database, and a real email server. Moving logic to handlers reduced the controller to 15 lines and made everything testable in isolation.
The Api.csproj
<!-- src/SystemForge.Api/SystemForge.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Scalar.AspNetCore" Version="2.*" />
<PackageReference Include="Serilog.AspNetCore" Version="9.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Application\SystemForge.Application.csproj" />
<ProjectReference Include="..\Infrastructure\SystemForge.Infrastructure.csproj" />
</ItemGroup>
</Project>Request and Response DTOs
// Api/Contracts/Patients/CreatePatientRequest.cs
namespace SystemForge.Api.Contracts.Patients;
public sealed record CreatePatientRequest(
string Name,
DateOnly DateOfBirth,
string MRN);
// Api/Contracts/Patients/AddPrescriptionRequest.cs
public sealed record AddPrescriptionRequest(
string MedicationCode,
decimal DosageAmount,
string DosageUnit,
string Frequency);
// Note: the API DTOs are distinct from Application response records
// CreatePatientRequest is the HTTP contract
// CreatePatientCommand is the Application layer record
// They may look similar but serve different purposes and change at different ratesA Thin Controller
// Api/Controllers/PatientsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SystemForge.Application.Patients.Commands.CreatePatient;
using SystemForge.Application.Patients.Commands.DeactivatePatient;
using SystemForge.Application.Patients.Commands.AddPrescription;
using SystemForge.Application.Patients.Queries.GetPatient;
using SystemForge.Application.Patients.Queries.GetPatientList;
using SystemForge.Api.Contracts.Patients;
using SystemForge.Domain.ValueObjects;
namespace SystemForge.Api.Controllers;
[ApiController]
[Route("api/patients")]
[Authorize]
public sealed class PatientsController : ControllerBase
{
private readonly CreatePatientCommandHandler _create;
private readonly DeactivatePatientCommandHandler _deactivate;
private readonly AddPrescriptionCommandHandler _addPrescription;
private readonly GetPatientQueryHandler _getPatient;
private readonly GetPatientListQueryHandler _listPatients;
public PatientsController(
CreatePatientCommandHandler create,
DeactivatePatientCommandHandler deactivate,
AddPrescriptionCommandHandler addPrescription,
GetPatientQueryHandler getPatient,
GetPatientListQueryHandler listPatients)
{
_create = create;
_deactivate = deactivate;
_addPrescription = addPrescription;
_getPatient = getPatient;
_listPatients = listPatients;
}
[HttpPost]
[Authorize(Roles = "Clinician,Admin")]
public async Task<IActionResult> Create(
CreatePatientRequest request, CancellationToken ct)
{
var command = new CreatePatientCommand(request.Name, request.DateOfBirth, request.MRN);
var result = await _create.Handle(command, ct);
return result.Match<IActionResult>(
id => CreatedAtAction(nameof(GetById), new { id = id.Value }, new { id = id.Value }),
err => err.ToProblemResult());
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var query = new GetPatientQuery(new PatientId(id));
var result = await _getPatient.Handle(query, ct);
return result.Match<IActionResult>(Ok, err => err.ToProblemResult());
}
[HttpGet]
public async Task<IActionResult> List(
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var query = new GetPatientListQuery(search, page, pageSize);
var result = await _listPatients.Handle(query, ct);
return result.Match<IActionResult>(Ok, err => err.ToProblemResult());
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var command = new DeactivatePatientCommand(new PatientId(id));
var result = await _deactivate.Handle(command, ct);
return result.Match<IActionResult>(_ => NoContent(), err => err.ToProblemResult());
}
[HttpPost("{id:guid}/prescriptions")]
[Authorize(Roles = "Clinician,Admin")]
public async Task<IActionResult> AddPrescription(
Guid id, AddPrescriptionRequest request, CancellationToken ct)
{
var command = new AddPrescriptionCommand(
new PatientId(id),
request.MedicationCode,
request.DosageAmount,
request.DosageUnit,
request.Frequency);
var result = await _addPrescription.Handle(command, ct);
return result.Match<IActionResult>(
rxId => CreatedAtAction(nameof(GetById), new { id }, new { prescriptionId = rxId.Value }),
err => err.ToProblemResult());
}
}Error-to-HTTP Mapping
// Api/Extensions/ErrorExtensions.cs
public static class ErrorExtensions
{
public static IActionResult ToProblemResult(this Error error)
{
var statusCode = error.Code switch
{
var c when c.EndsWith(".NotFound") => StatusCodes.Status404NotFound,
var c when c.EndsWith(".AlreadyExists") => StatusCodes.Status409Conflict,
var c when c.StartsWith("Validation.") => StatusCodes.Status422UnprocessableEntity,
var c when c.EndsWith(".Unauthorized") => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status400BadRequest,
};
var details = new ProblemDetails
{
Title = error.Code,
Detail = error.Description,
Status = statusCode,
};
return new ObjectResult(details) { StatusCode = statusCode };
}
}Program.cs — Wiring Everything Up
// Api/Program.cs
using SystemForge.Application;
using SystemForge.Infrastructure;
using SystemForge.Api.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddApplication()
.AddInfrastructure(builder.Configuration)
.AddApiServices(builder.Configuration);
builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.MapControllers();
app.Run();
// Api/Extensions/ApiServiceCollectionExtensions.cs
public static class ApiServiceCollectionExtensions
{
public static IServiceCollection AddApiServices(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddControllers();
services.AddOpenApi();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
var jwtSettings = configuration.GetSection("Jwt").Get<JwtSettings>()!;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtSettings.SecretKey)),
ClockSkew = TimeSpan.Zero,
};
});
services.AddAuthorization();
return services;
}
}PRO TIP — Middleware Order
Middleware order in
app.Use...matters. Authentication must precede Authorization. CORS (if present) must precede Authentication. Getting this wrong causes silent failures — requests that work in Postman but fail in the browser, or authenticated requests returning 401 from the wrong middleware.
// Correct order:
app.UseHttpsRedirection();
app.UseCors(); // 1. CORS
app.UseAuthentication(); // 2. Who are you?
app.UseAuthorization(); // 3. Are you allowed?
app.MapControllers(); // 4. Route the requestRed Flag Answers
Red flag: "My controller queries
_context.Patients.Where(...).ToList()directly."
The controller now has a direct EF Core dependency. You cannot test it without a real database. Business logic in controllers grows unchecked.
Green answer: "Controllers receive handlers through DI and call
Handle(command, ct). The return type isResult<T>, which is matched to the right HTTP status code. Controllers contain no business logic — they are HTTP adapters."
Key Takeaway
A thin controller is 10–20 lines. It maps an HTTP request to a command, calls a handler, and maps the result to a status code. If your controller is 100+ lines, business logic has leaked into the wrong layer. The test for this: can I test the business logic without sending an HTTP request? If yes, the logic is in the right place.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.