DEV Community

Cover image for Clean Architecture in .NET 10: The API Layer — Controllers vs Minimal APIs
Brian Spann
Brian Spann

Posted on • Edited on

Clean Architecture in .NET 10: The API Layer — Controllers vs Minimal APIs

Part 5 of 7. Start from the beginning if you're new here.


The API layer is where HTTP meets your application. It's the thinnest layer—just translation between HTTP and commands/queries. If your controllers have business logic, something went wrong upstream.


What The API Layer Does

  • Receive HTTP requests — Parse JSON, validate models
  • Translate to commands/queries — Call MediatR
  • Return HTTP responses — Status codes, JSON bodies

What it does NOT do:

  • Business logic
  • Data access
  • Complex authorization decisions

Project Setup

Program.cs

using FluentValidation;
using MediatR;
using PromptVault.API.Middleware;
using PromptVault.Application.Commands.CreatePrompt;
using PromptVault.Application.Common.Behaviors;
using PromptVault.Infrastructure;
using Serilog;

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .WriteTo.Console()
    .CreateLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);
    builder.Host.UseSerilog();

    // Infrastructure (DbContext, Repositories)
    builder.Services.AddInfrastructure(builder.Configuration);

    // MediatR
    builder.Services.AddMediatR(cfg =>
        cfg.RegisterServicesFromAssembly(typeof(CreatePromptCommand).Assembly));

    // FluentValidation + Pipeline
    builder.Services.AddValidatorsFromAssembly(typeof(CreatePromptCommand).Assembly);
    builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

    // API
    builder.Services.AddControllers();
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new() { Title = "PromptVault API", Version = "v1" });
    });

    var app = builder.Build();

    app.UseMiddleware<ExceptionHandlingMiddleware>();
    app.UseSwagger();
    app.UseSwaggerUI();
    app.UseHttpsRedirection();
    app.MapControllers();
    app.MapHealthChecks("/health");

    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
    Log.CloseAndFlush();
}

public partial class Program { } // For integration tests
Enter fullscreen mode Exit fullscreen mode

Request/Response Contracts

Don't expose domain entities or application DTOs directly. Create API-specific contracts:

src/PromptVault.API/Contracts/Requests/CreatePromptRequest.cs

namespace PromptVault.API.Contracts.Requests;

public record CreatePromptRequest(
    string Title,
    string Content,
    string ModelType,
    List<string>? Tags = null
);
Enter fullscreen mode Exit fullscreen mode

src/PromptVault.API/Contracts/Requests/UpdatePromptRequest.cs

namespace PromptVault.API.Contracts.Requests;

public record UpdatePromptRequest(
    string? Title = null,
    string? Content = null,
    List<string>? Tags = null
);
Enter fullscreen mode Exit fullscreen mode

src/PromptVault.API/Contracts/Responses/PromptResponse.cs

using PromptVault.Application.DTOs;

namespace PromptVault.API.Contracts.Responses;

public record PromptResponse(
    Guid Id,
    string Title,
    string Content,
    string ModelType,
    List<string> Tags,
    int VersionCount,
    DateTime CreatedAt,
    DateTime? UpdatedAt,
    List<PromptVersionResponse>? Versions = null
)
{
    public static PromptResponse FromDto(PromptDto dto) => new(
        dto.Id, dto.Title, dto.Content, dto.ModelType,
        dto.Tags, dto.VersionCount, dto.CreatedAt, dto.UpdatedAt,
        dto.Versions?.Select(PromptVersionResponse.FromDto).ToList()
    );
}

public record PromptVersionResponse(
    Guid Id, int VersionNumber, string Content, DateTime CreatedAt, string? CreatedBy
)
{
    public static PromptVersionResponse FromDto(PromptVersionDto dto) => new(
        dto.Id, dto.VersionNumber, dto.Content, dto.CreatedAt, dto.CreatedBy
    );
}

public record CreatePromptResponse(Guid Id);
Enter fullscreen mode Exit fullscreen mode

Real-World Callout: The DTO Mapping Tax

You now have:

  • Domain entities (Prompt)
  • Application DTOs (PromptDto)
  • API response objects (PromptResponse)

Three representations of the same thing. Is this necessary?

For: Each layer has different concerns. API versioning becomes easier.

Against: Boilerplate explosion. Most changes touch all three anyway.

💡 My take: For public APIs, the separation is worth it. For internal APIs, PromptDto can BE your API response.


The Controller

src/PromptVault.API/Controllers/PromptsController.cs

using MediatR;
using Microsoft.AspNetCore.Mvc;
using PromptVault.API.Contracts.Requests;
using PromptVault.API.Contracts.Responses;
using PromptVault.Application;
using PromptVault.Application.Commands.CreatePrompt;
using PromptVault.Application.Commands.UpdatePrompt;
using PromptVault.Application.Commands.DeletePrompt;
using PromptVault.Application.Queries.GetPromptById;
using PromptVault.Application.Queries.SearchPrompts;

namespace PromptVault.API.Controllers;

[ApiController]
[Route("api/[controller]")]
public class PromptsController : ControllerBase
{
    private readonly IMediator _mediator;

    public PromptsController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(PromptResponse), 200)]
    [ProducesResponseType(404)]
    public async Task<IActionResult> GetById(Guid id, [FromQuery] bool includeVersions = false)
    {
        var result = await _mediator.Send(new GetPromptByIdQuery(id, includeVersions));

        return result.ErrorType switch
        {
            ErrorType.None => Ok(PromptResponse.FromDto(result.Value!)),
            ErrorType.NotFound => NotFound(new { error = result.Error }),
            _ => BadRequest(new { error = result.Error })
        };
    }

    [HttpGet]
    [ProducesResponseType(typeof(PagedResponse<PromptResponse>), 200)]
    public async Task<IActionResult> Search(
        [FromQuery] string? q = null,
        [FromQuery] string? tag = null,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20)
    {
        var result = await _mediator.Send(new SearchPromptsQuery(q, tag, page, pageSize));

        if (!result.IsSuccess)
            return BadRequest(new { error = result.Error });

        var value = result.Value!;
        return Ok(new PagedResponse<PromptResponse>(
            value.Items.Select(PromptResponse.FromDto).ToList(),
            value.TotalCount, value.Page, value.PageSize,
            value.TotalPages, value.HasNextPage, value.HasPreviousPage
        ));
    }

    [HttpPost]
    [ProducesResponseType(typeof(CreatePromptResponse), 201)]
    [ProducesResponseType(400)]
    [ProducesResponseType(409)]
    public async Task<IActionResult> Create([FromBody] CreatePromptRequest request)
    {
        var command = new CreatePromptCommand(
            request.Title, request.Content, request.ModelType, request.Tags
        );

        var result = await _mediator.Send(command);

        return result.ErrorType switch
        {
            ErrorType.None => CreatedAtAction(
                nameof(GetById),
                new { id = result.Value },
                new CreatePromptResponse(result.Value!)),
            ErrorType.Conflict => Conflict(new { error = result.Error }),
            _ => BadRequest(new { error = result.Error })
        };
    }

    [HttpPut("{id:guid}")]
    [ProducesResponseType(204)]
    [ProducesResponseType(404)]
    [ProducesResponseType(409)]
    public async Task<IActionResult> Update(Guid id, [FromBody] UpdatePromptRequest request)
    {
        var command = new UpdatePromptCommand(id, request.Title, request.Content, request.Tags);
        var result = await _mediator.Send(command);

        return result.ErrorType switch
        {
            ErrorType.None => NoContent(),
            ErrorType.NotFound => NotFound(new { error = result.Error }),
            ErrorType.Conflict => Conflict(new { error = result.Error }),
            _ => BadRequest(new { error = result.Error })
        };
    }

    [HttpDelete("{id:guid}")]
    [ProducesResponseType(204)]
    [ProducesResponseType(404)]
    public async Task<IActionResult> Delete(Guid id)
    {
        var result = await _mediator.Send(new DeletePromptCommand(id));

        return result.ErrorType switch
        {
            ErrorType.None => NoContent(),
            ErrorType.NotFound => NotFound(new { error = result.Error }),
            _ => BadRequest(new { error = result.Error })
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice how thin this is. Each method:

  1. Maps request to command/query
  2. Calls MediatR
  3. Maps result to HTTP response

No business logic. No database calls. Just translation.


Minimal APIs Alternative

If controllers feel heavy, Minimal APIs offer less ceremony:

src/PromptVault.API/Endpoints/PromptEndpoints.cs

using MediatR;
using PromptVault.API.Contracts.Requests;
using PromptVault.API.Contracts.Responses;
using PromptVault.Application.Commands.CreatePrompt;
using PromptVault.Application.Queries.GetPromptById;

namespace PromptVault.API.Endpoints;

public static class PromptEndpoints
{
    public static void MapPromptEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/prompts").WithTags("Prompts");

        group.MapGet("/{id:guid}", async (Guid id, bool includeVersions, IMediator mediator) =>
        {
            var result = await mediator.Send(new GetPromptByIdQuery(id, includeVersions));
            return result.IsSuccess
                ? Results.Ok(PromptResponse.FromDto(result.Value!))
                : Results.NotFound(new { error = result.Error });
        })
        .WithName("GetPromptById")
        .Produces<PromptResponse>(200)
        .Produces(404);

        group.MapPost("/", async (CreatePromptRequest request, IMediator mediator) =>
        {
            var command = new CreatePromptCommand(
                request.Title, request.Content, request.ModelType, request.Tags);
            var result = await mediator.Send(command);

            return result.IsSuccess
                ? Results.CreatedAtRoute("GetPromptById", 
                    new { id = result.Value }, 
                    new CreatePromptResponse(result.Value!))
                : Results.BadRequest(new { error = result.Error });
        })
        .Produces<CreatePromptResponse>(201)
        .Produces(400);

        // ... more endpoints
    }
}
Enter fullscreen mode Exit fullscreen mode

Register in Program.cs:

app.MapPromptEndpoints();
Enter fullscreen mode Exit fullscreen mode

Controllers vs Minimal APIs

Aspect Controllers Minimal APIs
Boilerplate More Less
Organization Class-based Function-based
Filters Built-in Requires setup
Swagger Easy Needs annotations
Team familiarity High Lower

💡 Recommendation: Controllers for larger teams. Minimal APIs for microservices or when you want less ceremony.


Global Error Handling

Catch validation exceptions and return consistent JSON:

src/PromptVault.API/Middleware/ExceptionHandlingMiddleware.cs

using System.Net;
using System.Text.Json;
using FluentValidation;

namespace PromptVault.API.Middleware;

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException ex)
        {
            await HandleValidationException(context, ex);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");
            await HandleException(context, ex);
        }
    }

    private static async Task HandleValidationException(HttpContext context, ValidationException ex)
    {
        context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
        context.Response.ContentType = "application/json";

        var errors = ex.Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());

        var response = new
        {
            type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
            title = "Validation failed",
            status = 400,
            errors
        };

        await context.Response.WriteAsync(
            JsonSerializer.Serialize(response, new JsonSerializerOptions 
            { 
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase 
            }));
    }

    private async Task HandleException(HttpContext context, Exception ex)
    {
        context.Response.StatusCode = 500;
        context.Response.ContentType = "application/json";

        var isDev = context.RequestServices.GetRequiredService<IHostEnvironment>().IsDevelopment();

        var response = new
        {
            type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
            title = "An error occurred",
            status = 500,
            detail = isDev ? ex.Message : "An unexpected error occurred"
        };

        await context.Response.WriteAsync(
            JsonSerializer.Serialize(response, new JsonSerializerOptions 
            { 
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase 
            }));
    }
}
Enter fullscreen mode Exit fullscreen mode

HTTP Status Code Mapping

Map your Result errors to appropriate status codes:

ErrorType HTTP Status
None (success) 200/201/204
Validation 400 Bad Request
NotFound 404 Not Found
Conflict 409 Conflict
Unauthorized 401 Unauthorized
Forbidden 403 Forbidden

API Project Structure

src/PromptVault.API/
├── Controllers/
│   ├── PromptsController.cs
│   └── CollectionsController.cs
├── Contracts/
│   ├── Requests/
│   │   ├── CreatePromptRequest.cs
│   │   └── UpdatePromptRequest.cs
│   └── Responses/
│       ├── PromptResponse.cs
│       └── PagedResponse.cs
├── Middleware/
│   └── ExceptionHandlingMiddleware.cs
├── Program.cs
└── appsettings.json
Enter fullscreen mode Exit fullscreen mode

Testing the API

Run and open Swagger:

cd src/PromptVault.API
dotnet run
# Open https://localhost:5001/swagger
Enter fullscreen mode Exit fullscreen mode

Example: Create a Prompt

curl -X POST https://localhost:5001/api/prompts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Code Review Assistant",
    "content": "You are a senior developer...",
    "modelType": "gpt-4",
    "tags": ["code-review", "development"]
  }'
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Controllers should be thin — Just translate HTTP to commands/queries
  2. API contracts ≠ application DTOs — Separate for flexibility
  3. Minimal APIs are valid — Less ceremony, same result
  4. Global error handling — Don't let exceptions bubble unhandled
  5. Map errors to status codes consistently — Users will thank you

Coming Up

In Part 6, we'll add production polish:

  • Logging behavior in the MediatR pipeline
  • Caching for read-heavy queries
  • Health checks and configuration

👉 Part 6: Validation, Logging, and Production Polish


Full source: promptvault

Top comments (0)