Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETAPI LayerControllersProblem Details
Share:𝕏

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

XML
<!-- 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

C#
// 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 rates

A Thin Controller

C#
// 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

C#
// 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

C#
// 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.

C#
// 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 request

Red 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 is Result<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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.