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
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
);
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
);
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);
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,
PromptDtocan 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 })
};
}
}
Notice how thin this is. Each method:
- Maps request to command/query
- Calls MediatR
- 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
}
}
Register in Program.cs:
app.MapPromptEndpoints();
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
}));
}
}
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
Testing the API
Run and open Swagger:
cd src/PromptVault.API
dotnet run
# Open https://localhost:5001/swagger
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"]
}'
Key Takeaways
- Controllers should be thin — Just translate HTTP to commands/queries
- API contracts ≠ application DTOs — Separate for flexibility
- Minimal APIs are valid — Less ceremony, same result
- Global error handling — Don't let exceptions bubble unhandled
- 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)