Day 2: Clean Code Practices for Maintainable Controllers & Minimal APIs
What we’re doing today (and why)
Yesterday (Day 1) we stood up two endpoints:
POST /api/v1/tasks
— create a taskGET /api/v1/tasks
— list tasks
Today we’ll refactor the project so those endpoints (and every new one we add) remain clean, readable, testable, and easy to evolve. No new business features yet — we’re investing in structure. Senior teams do this early to avoid “giant Program.cs” and “misc folder chaos.”
Specifically, we will:
- Establish project conventions that stick (naming, DTOs, folders, nullability).
- Modularize Minimal APIs (route groups, per-feature endpoint files, extension methods).
- Prepare for Controllers (without switching yet): consistent route tokens, attributes, result types.
- Make endpoints async-friendly with CancellationTokens, and production-friendly with clear status codes.
- Add editor & analyzer guardrails so style and warnings are enforced by tools, not memory.
By the end, your Task endpoints look the same to clients, but your codebase will feel enterprise-ready.
1) Repo hygiene & global conventions (the boring bits that pay forever)
1.1 Enable nullable context & warnings as errors
We want the compiler to help us keep contracts tight.
<!-- TaskManagementAPI.csproj -->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
1.2 Add a lightweight .editorconfig
This locks formatting/naming so the codebase reads the same no matter who touches it.
# .editorconfig (root of repo)
root = true
[*.cs]
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
dotnet_style_prefer_var_for_built_in_types = true:suggestion
dotnet_style_prefer_var_for_locals = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
csharp_style_var_for_built_in_types = true:suggestion
csharp_new_line_before_open_brace = all
csharp_prefer_braces = when_multiline:suggestion
dotnet_diagnostic.IDE0060.severity = warning # remove unused parameters
dotnet_diagnostic.CA1062.severity = warning # validate public method args
Tip: Add analyzers later (StyleCop.Analyzers, Roslynator) once the base settles.
1.3 Naming & folder conventions (capstone-specific)
TaskManagementAPI/
├─ Api/ # HTTP surface (Minimal API maps, controllers)
│ ├─ Tasks/ # feature folder (Vertical Slice-friendly)
│ │ ├─ TasksEndpoints.cs
│ │ └─ TasksController.cs (optional, for parity / future)
├─ Application/ # commands/queries/interfaces (Day 4+ CQRS prep)
├─ Domain/ # pure domain objects + rules (Day 7+)
├─ Infrastructure/ # EF Core, external APIs, persistence (Ch. 2+)
├─ Models/ # external-facing DTOs (kept small)
│ └─ TaskContracts.cs
├─ Program.cs
└─ TaskManagementAPI.csproj
Rules we’ll follow from today:
- DTOs get the suffix Request / Response (e.g.,
CreateTaskRequest
,TaskResponse
). - Public endpoints never expose EF entities (when we add EF).
- UTC in backend contracts (
DueDateUtc
). - Plural resources & versioned routes (
/api/v1/tasks
). - One feature per folder (Tasks, Users, Comments, …).
2) Refactor Minimal APIs into a modular, testable shape
On Day 1 we mapped endpoints directly in Program.cs
. That doesn’t scale. We’ll move those maps into a TasksEndpoints module and use route groups.
2.1 Keep DTOs as-is (from Day 1)
// Models/TaskContracts.cs
namespace TaskManagementAPI.Models;
public record CreateTaskRequest(string Title, string? Description, DateTime DueDateUtc);
public record TaskResponse(Guid Id, string Title, string? Description, DateTime DueDateUtc, bool IsCompleted);
2.2 Add our first endpoint module
// Api/Tasks/TasksEndpoints.cs
using Microsoft.AspNetCore.Mvc;
using TaskManagementAPI.Models;
namespace TaskManagementAPI.Api.Tasks;
public static class TasksEndpoints
{
// For now we simulate persistence here; Day 4 moves this behind a service
private static readonly List<TaskResponse> _tasks = [];
public static IEndpointRouteBuilder MapTasksEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/tasks")
.WithTags("Tasks")
.WithOpenApi(); // Swagger-friendly metadata
group.MapPost("/", CreateTask)
.WithName("CreateTask")
.Produces<TaskResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest);
group.MapGet("/", GetTasks)
.WithName("GetTasks")
.Produces<IEnumerable<TaskResponse>>(StatusCodes.Status200OK);
group.MapGet("/{id:guid}", GetTaskById)
.WithName("GetTaskById")
.Produces<TaskResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound);
return routes;
}
private static IResult CreateTask(
[FromBody] CreateTaskRequest input,
HttpContext httpContext, // easy access to headers/user if needed
CancellationToken ct) // accept cancellation now; pass down later
{
if (string.IsNullOrWhiteSpace(input.Title))
{
return Results.Problem(
title: "Invalid input",
detail: "Title is required.",
statusCode: StatusCodes.Status400BadRequest,
instance: httpContext.Request.Path
);
}
var task = new TaskResponse(
Guid.NewGuid(),
input.Title.Trim(),
string.IsNullOrWhiteSpace(input.Description) ? null : input.Description!.Trim(),
input.DueDateUtc,
false
);
_tasks.Add(task);
// (ct) reserved for future async persistence
return Results.Created($"/api/v1/tasks/{task.Id}", task);
}
private static IResult GetTasks(CancellationToken ct)
=> Results.Ok(_tasks);
private static IResult GetTaskById(Guid id, HttpContext ctx, CancellationToken ct)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
return task is null
? Results.Problem(title: "Task not found",
detail: $"No task with id = {id}",
statusCode: StatusCodes.Status404NotFound,
instance: ctx.Request.Path)
: Results.Ok(task);
}
}
2.3 Slim down Program.cs
// Program.cs
using TaskManagementAPI.Api.Tasks;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapTasksEndpoints();
app.Run();
Benefits you get immediately
- Program.cs stays tiny as features grow.
- TasksEndpoints is a single place to read for “everything Tasks.”
- Handlers are small functions with clear names & inputs.
- We’re already cancellation-token ready so long ops can stop gracefully.
Note: In Day 4, we’ll replace the in-module list with an injected
ITaskService
and move business logic out of the HTTP layer.
3) Minimal API clean-code patterns (we’re adopting them now)
3.1 Prefer route groups + per-feature Mappers
- One
MapXEndpoints
per feature. - Group under
/api/v{version}
early → avoids breaking changes later. - Call
.WithTags("Feature")
for Swagger discoverability.
3.2 Handlers are thin and deterministic
- Validate early; return
ProblemDetails
on bad input. - Avoid grabbing random services from
HttpContext.RequestServices
— inject parameters properly (we’ll inject interfaces Day 4). - Keep responses consistent (
201 Created
withLocation
,404
with helpful details).
3.3 Always accept CancellationToken
Even if we don’t use it yet, this pays off once we add EF Core / external I/O:
private static async Task<IResult> GetTasksAsync(ITaskService svc, CancellationToken ct)
=> Results.Ok(await svc.ListAsync(ct));
3.4 Don’t leak infrastructure concerns
No DB, no HTTP clients, no Polly in handlers. Handlers call services. Services handle I/O, policies, caching. (Day 4+)
4) Controller parity (optional today, useful tomorrow)
We’re staying Minimal for now, but let’s establish controller conventions that mirror our Minimal surface. This helps when we add filters/attributes that are easier with Controllers.
// Api/Tasks/TasksController.cs
using Microsoft.AspNetCore.Mvc;
using TaskManagementAPI.Models;
namespace TaskManagementAPI.Api.Tasks;
[ApiController]
[Route("api/v1/[controller]")] // -> /api/v1/tasks
[Tags("Tasks")]
public class TasksController : ControllerBase
{
// temp store; Day 4 inject a service
private static readonly List<TaskResponse> _tasks = [];
[HttpPost]
[ProducesResponseType(typeof(TaskResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public ActionResult<TaskResponse> Create([FromBody] CreateTaskRequest input)
{
if (string.IsNullOrWhiteSpace(input.Title))
return ValidationProblem(
detail: "Title is required.",
statusCode: StatusCodes.Status400BadRequest);
var task = new TaskResponse(Guid.NewGuid(),
input.Title.Trim(),
string.IsNullOrWhiteSpace(input.Description) ? null : input.Description!.Trim(),
input.DueDateUtc,
false);
_tasks.Add(task);
return CreatedAtAction(nameof(GetById), new { id = task.Id }, task);
}
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<TaskResponse>), StatusCodes.Status200OK)]
public ActionResult<IEnumerable<TaskResponse>> Get() => Ok(_tasks);
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(TaskResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<TaskResponse> GetById(Guid id)
{
var t = _tasks.FirstOrDefault(x => x.Id == id);
return t is null ? NotFound() : Ok(t);
}
}
We won’t use this controller yet, but keeping it next to Minimal endpoints clarifies parity and gives us a drop-in place once we add filters (Day 8), versioning attributes (Day 50), and model binding customizations.
5) DTOs, naming, and evolution rules (we’ll follow these all book long)
- Requests end with
Request
. Responses end withResponse
. - Prefer immutable records for DTOs; they serialize cleanly and discourage mutation.
- Never use EF entities or domain entities as response models.
- If a field is server-owned (
Id
, lifecycle timestamps, audit), compute it server-side. - Keep UTC in the backend surface (
DueDateUtc
). Apply locale at the client. - Avoid “maybe” fields without a plan — if it can be omitted, make it
string?
(nullable) and document the semantics in Swagger later.
6) HTTP semantics & status code discipline (applied now)
POST
returns 201 Created +Location
header + representationGET
returns 200 OK (empty array is fine, don’t invent 204 here)404
includes a ProblemDetails with a message that helps developers- Avoid 200 with error payloads — errors deserve error codes
- Don’t leak stack traces; use ProblemDetails consistently (Day 9 refines this globally)
7) Async & cancellation patterns (foundation today, payback later)
Even though our current handlers are in-memory, we accept CancellationToken
and keep bodies small so switching to:
await dbContext.Tasks.AsNoTracking().ToListAsync(ct)
await emailClient.SendAsync(request, ct)
…is a one-line change, not a refactor.
Also: never block async (no .Result
, .Wait()
); endpoints should be async Task<IResult>
once they hit I/O.
8) Example: turning the Day 1 code into a tidy module
Before (Program.cs, bloated):
// MapPost, MapGet, logic inline, shared list in Program.cs...
After (clean split):
Api/Tasks/TasksEndpoints.cs
owns all route mapping & handlers for TasksModels/TaskContracts.cs
owns all wire contractsProgram.cs
only wires modules:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapTasksEndpoints(); // <- one line per feature
app.Run();
This is the pattern we’ll repeat for Users, Comments, Labels, Auth, etc.
9) Practical checklists (use these in the repo)
Endpoint checklist
- Route is versioned (
/api/v1/...
) - Uses plural resource names
- Returns correct status codes (+
Location
for POST) - Accepts CancellationToken
- Validates input (today: simple; Day 8: filter/FluentValidation)
- No infrastructure inside handlers (Day 4: inject services)
Code org checklist
- One MapXEndpoints per feature
- Handlers are small functions
- Request/Response DTOs in Models
- Program.cs stays minimal
Hygiene checklist
<Nullable>enable</Nullable>
in csproj.editorconfig
committed- Warnings addressed, not suppressed
10) Common anti-patterns (and our fix)
- God Program.cs → We split per feature with route groups.
- Leaky endpoints (DB, HTTP calls inside handlers) → Day 4 we move logic behind
ITaskService
. - Unversioned routes → We started at
/api/v1
on Day 1 to protect clients. - Ad-hoc status codes → We standardized (
201
,200
,404
with ProblemDetails). - Ignoring cancellation → Our signatures accept
CancellationToken
now.
11) Where this sets us up
- Day 3: drop in a request logging middleware (correlation IDs, user, route).
- Day 4: extract logic to
ITaskService
and inject it; endpoints become thin wrappers. - Chapter 2: replace the in-memory list with EF Core; this structure lets us swap with minimal churn.
- Day 8/9/10/11/13/14: validators, ProblemDetails, exception policy, Serilog, Swagger metadata, and HTTP rigor all slot into this layout neatly.
Full current code snapshot (for clarity)
Program.cs
using TaskManagementAPI.Api.Tasks;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapTasksEndpoints();
app.Run();
Models/TaskContracts.cs
namespace TaskManagementAPI.Models;
public record CreateTaskRequest(string Title, string? Description, DateTime DueDateUtc);
public record TaskResponse(Guid Id, string Title, string? Description, DateTime DueDateUtc, bool IsCompleted);
Api/Tasks/TasksEndpoints.cs
using Microsoft.AspNetCore.Mvc;
using TaskManagementAPI.Models;
namespace TaskManagementAPI.Api.Tasks;
public static class TasksEndpoints
{
private static readonly List<TaskResponse> _tasks = [];
public static IEndpointRouteBuilder MapTasksEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/tasks")
.WithTags("Tasks")
.WithOpenApi();
group.MapPost("/", CreateTask)
.WithName("CreateTask")
.Produces<TaskResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest);
group.MapGet("/", GetTasks)
.WithName("GetTasks")
.Produces<IEnumerable<TaskResponse>>(StatusCodes.Status200OK);
group.MapGet("/{id:guid}", GetTaskById)
.WithName("GetTaskById")
.Produces<TaskResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound);
return routes;
}
private static IResult CreateTask([FromBody] CreateTaskRequest input, HttpContext ctx, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Title))
{
return Results.Problem(
title: "Invalid input",
detail: "Title is required.",
statusCode: StatusCodes.Status400BadRequest,
instance: ctx.Request.Path
);
}
var task = new TaskResponse(
Guid.NewGuid(),
input.Title.Trim(),
string.IsNullOrWhiteSpace(input.Description) ? null : input.Description!.Trim(),
input.DueDateUtc,
false
);
_tasks.Add(task);
return Results.Created($"/api/v1/tasks/{task.Id}", task);
}
private static IResult GetTasks(CancellationToken ct) => Results.Ok(_tasks);
private static IResult GetTaskById(Guid id, HttpContext ctx, CancellationToken ct)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
return task is null
? Results.Problem("Task not found", ctx.Request.Path, StatusCodes.Status404NotFound)
: Results.Ok(task);
}
}
(Controller parity exists in Api/Tasks/TasksController.cs
, optional for now.)
Wrap-up
Day 2 isn’t about flashy features; it’s about ensuring Day 200 won’t hurt. We established conventions, modularized our HTTP surface, and set patterns that make future chapters (validation, DI, EF Core, observability, versioning) straightforward instead of risky.
If you’re good with this, I’ll proceed to Day 3: Custom middleware to log request metadata — we’ll add correlation IDs, method/path, duration, and (later) user identity, all in a single, reusable middleware that plays nicely with Serilog.