Building a Web API with ASP.NET Core: Controllers and Routing

Build an ASP.NET Core Web API end to end: controllers, attribute routing, model binding and validation, DTOs, status codes, and OpenAPI docs.

Executive Summary: An ASP.NET Core Web API with controllers is three plain pieces: a class per resource with attributes describing its routes, DTOs (data transfer objects) defining the contract in and out, and model binding plus validation turning HTTP into checked C# objects. The [ApiController] attribute is what makes the style feel automatic: it validates models before your code runs, infers binding sources, and standardizes error responses.

The question that brings people to this page sounds like: “I need to build a REST API for our orders/tasks/invoices; how do I structure it without inventing my own conventions?” The answer in ASP.NET Core is controllers plus the [ApiController] conventions, the same pattern used by every codebase that needs routing, validation, and documentation to behave identically across dozens of endpoints.

Building a Web API with ASP.NET Core here means the controller style: a class per resource, attribute routing, model binding and validation, DTOs versus entities, correct status codes, and OpenAPI documentation. The official Web API documentation is the reference; this page is the runnable version.

The examples assume .NET SDK 10.0 with ASP.NET Core 10 and build the TaskApi. An API for tracking tasks in projects, with in-memory storage so every file runs with zero setup. The request pipeline around it is covered in the guide on ASP.NET Core middleware and the request pipeline. This page starts where the pipeline hands off to your code.

For teams, I hold a firm view: controllers are the right default, not the old-fashioned choice. Minimal APIs win for small, focused services (the comparison table below is fair about that), but a team building dozens of resource endpoints gets more from the conventions and testability of controllers than from deleting attributes. If you’re looking at Web APIs from the business side rather than the code, I’ve written a separate practical guide to ASP.NET Core Web API for business services that covers authentication, documentation and hosting.

The complete TaskApi project

Start with the controller template so you get the full scaffolding (in the .NET 10 SDK the option is -controllers; older SDKs spell it --use-controllers):

dotnet new webapi -o TaskApi -controllers
cd TaskApi
dotnet run

Two files run the whole application. First, TaskApi.csproj, which shows the OpenAPI package the template now includes:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.*" />
  </ItemGroup>

</Project>

Second, Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddOpenApi();

builder.Services.AddSingleton<TaskStore>();

var app = builder.Build();

app.MapOpenApi();
app.MapControllers();

app.Run();

Read it as two halves, the same split as the middleware guide: builder.Services registers what exists (controllers, the OpenAPI document generator, and a TaskStore holding tasks in memory, registered through the built-in container; the dependency injection guide covers registration and lifetimes in depth). The app lines run per request. When you swap the in-memory store for a database later, the EF Core code-first guide slots in exactly here without touching the controllers.

The domain model, small and honest

One entity, one store, all in Models/TaskItem.cs and Services/TaskStore.cs:

namespace TaskApi.Models;

public class TaskItem
{
    public Guid Id { get; set; }
    public required string Title { get; set; }
    public bool Done { get; set; }
    public string? Project { get; set; }
    public DateTime CreatedAtUtc { get; set; }
}
namespace TaskApi.Services;

public sealed class TaskStore
{
    private readonly Dictionary<Guid, TaskItem> _tasks = new();

    public IEnumerable<TaskItem> All => _tasks.Values;

    public TaskItem? Find(Guid id) => _tasks.GetValueOrDefault(id);

    public void Add(TaskItem task) => _tasks[task.Id] = task;

    public void Remove(Guid id) => _tasks.Remove(id);
}

The required keyword and string? annotations come from the language track. The compiler refuses to build a task without a title, and a project may be absent. Nullable annotations are covered in the nullable reference types guide.

DTOs: the contract is not the entity

The most consequential decision in the file structure is this: clients never see TaskItem. They see request and response records, in Contracts/TaskContracts.cs:

namespace TaskApi.Contracts;

public record CreateTaskRequest(
    [Required] string Title,
    [Length(minimumLength: 1, maximumLength: 200)] string? Project);

public record TaskResponse(
    Guid Id,
    string Title,
    bool Done,
    string? Project,
    DateTime CreatedAtUtc);

Three reasons, each one a production lesson. First, over-posting: returning the entity lets clients set fields they should not touch. A request DTO with exactly the writable fields makes that structurally impossible. Second, contract stability: you can rename TaskItem.CreatedAtUtc tomorrow and the wire format is untouched, because clients bind to TaskResponse. Third, relationships: once a task holds a project and a project holds tasks, serializing entities loops or requires per-endpoint trimming. Records serialize flat by design. Entities model storage; DTOs model the contract.

The controller: full CRUD with clean routes

Here is the entire Controllers/TasksController.cs, the heart of a working ASP.NET Core Web API:

using Microsoft.AspNetCore.Mvc;
using TaskApi.Contracts;
using TaskApi.Models;
using TaskApi.Services;

namespace TaskApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public sealed class TasksController(TaskStore store) : ControllerBase
{
    [HttpGet]
    public ActionResult<IEnumerable<TaskResponse>> GetAll(bool? done = null)
    {
        var tasks = store.All
            .Where(t => done is null || t.Done == done)
            .Select(t => ToResponse(t));

        return Ok(tasks);
    }

    [HttpGet("{id:guid}")]
    public ActionResult<TaskResponse> GetById(Guid id)
    {
        var task = store.Find(id);
        return task is null ? NotFound() : Ok(ToResponse(task));
    }

    [HttpPost]
    public ActionResult<TaskResponse> Create(CreateTaskRequest request)
    {
        var task = new TaskItem
        {
            Id = Guid.NewGuid(),
            Title = request.Title,
            Project = request.Project,
            CreatedAtUtc = DateTime.UtcNow
        };

        store.Add(task);

        return CreatedAtAction(nameof(GetById), new { id = task.Id }, ToResponse(task));
    }

    [HttpPatch("{id:guid}")]
    public IActionResult ToggleDone(Guid id)
    {
        var task = store.Find(id);
        if (task is null)
        {
            return NotFound();
        }

        task.Done = !task.Done;
        return Ok(ToResponse(task));
    }

    [HttpDelete("{id:guid}")]
    public IActionResult Delete(Guid id) =>
        store.Remove(id) ? NoContent() : NotFound();

    private static TaskResponse ToResponse(TaskItem task) =>
        new(task.Id, task.Title, task.Done, task.Project, task.CreatedAtUtc);
}

Reading the pieces: [Route("api/[controller]")] is the attribute routing template (the token becomes tasks); each [Http*] attribute declares a verb and route. {id:guid} adds a route constraint so a non-GUID id never reaches the method, matching by pattern before matching by value. The routing to controller actions reference documents the full template grammar.

Model binding and validation: HTTP becomes checked objects

Model binding is the machinery that populates your method parameters: complex types from the JSON body ([FromBody] is inferred for POST/PUT/PATCH), simple types and records from route values and query strings. With [ApiController] on the class, binding sources are inferred by convention, and every model with validation attributes is checked before your method runs. This request fails validation without a line of your code:

curl -X POST http://localhost:5092/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"project": "inbox"}'
{
  "type": "https://tools.ietf.org/html/rfc9457",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Title": ["The Title field is required."]
  }
}

That 400 body is a Problem Details document, the standard error format that makes client error handling uniform across your API. The response shape, the exception-to-response mapping, and the trace id behavior are covered in full in the guide on error handling and Problem Details, which owns the topic. From a controller author’s view, the contract is: annotate the DTO, and validation is enforced for free.

Status codes: the vocabulary of the API

Each return in the controller above chose a status deliberately. The table is the working vocabulary:

Status Meaning Helper
200 OK Read or update succeeded, with a body Ok(result)
201 Created Resource created, with a Location header CreatedAtAction(...)
204 No Content Delete succeeded, no body NoContent()
400 Bad Request Validation or binding failure automatic with [ApiController]
401 Unauthorized Caller identity missing or invalid [Authorize] middleware
404 Not Found No resource at that id NotFound()
409 Conflict Duplicate or state conflict Conflict()

The two most underused: CreatedAtAction, which returns 201 plus a Location header pointing at the new resource so clients do not guess URLs, and 204 for deletes, which communicates success without a pointless empty body. Guarding endpoints with [Authorize] belongs to the authentication and authorization guide; it is one attribute here because the pipeline does the work.

Controllers vs minimal APIs: one table

The minimal API style does the same job with inline handlers (app.MapGet("/api/tasks", ...)). The fair comparison:

Aspect Controllers Minimal APIs
Ceremony Class plus attributes per resource One line per endpoint
Automatic validation (400 on bad models) Yes, from [ApiController] Manual or with filters
Model binding conventions Full, inferred by attribute Explicit parameters per handler
OpenAPI metadata Derived from the controller shape Derived from the handler shape
Best fit Team APIs, many resources, uniform conventions Small services, microservices, prototypes

OpenAPI: the contract becomes a document

AddOpenApi() plus MapOpenApi() serve the OpenAPI document describing your routes, models, and response types at /openapi/v1.json in the development environment. The OpenAPI overview covers the options. Tools read that document to generate clients and docs: import it into Postman, generate a TypeScript client, or serve interactive docs with a UI package such as Scalar or Swashbuckle. The document is only as honest as your DTOs and return types, which is one more reason actions return ActionResult<TaskResponse> rather than bare objects.

How real systems do this

  • Thin controllers over services. The controller binds, validates, delegates, and shapes the response. Rules live in services injected through the constructor, which keeps endpoints testable as the logic grows (the registration patterns are in the dependency injection guide).
  • Contracts versioned deliberately. Additive changes (new optional fields) ship without ceremony. Breaking changes go to a new route like /api/v2/tasks, so old clients keep working while clients migrate.
  • Integration tests pin the contract. Teams test controllers through the full pipeline, not by instantiating the class, so routing, validation, and serialization failures are caught the way clients would see them. The testing track in this series covers the pattern end to end.

A mistake I have seen in production is a controller that returned the entity directly because it was convenient. A mobile client, following the OpenAPI document generated from that entity, began sending an id and createdAtUtc on create. The API quietly accepted them, letting the client decide creation timestamps. It became a data quality bug: reports showed tasks “created” weeks before their user signed up. The fix was a request DTO with exactly three fields. What I took from it: the response you return becomes the contract, and an entity is not a contract, it is an accident waiting to be depended on.

Decision framework: shape the API deliberately

  1. How many endpoints and resources? One or two endpoints: minimal APIs honestly. A team API with several resources: controllers win on uniformity.
  2. What does the client need to do? Model the request DTO on the use case (create, toggle, assign), not on the entity. Writable fields are the ones in the use case.
  3. Could the wire format change? Add a response DTO now. Renaming an entity property later then touches one mapping line instead of every client.
  4. Which status does a machine need? 201 with Location for creates, 204 for deletes, 404 for absent ids; a 200-for-everything API forces clients to parse bodies for errors.
  5. Should the model binding accept this value? Route constraints ({id:guid}) and validation attributes reject nonsense before the method body runs.

When NOT to use controllers

  • A microservice with two endpoints. Minimal APIs express a two-endpoint service with less ceremony and no convention mismatch. The comparison table above is the decision record.
  • A prototype you will throw away. The value of [ApiController] conventions compounds across many endpoints. For a weekend spike, the ceremony slows you down without paying.
  • Endpoints that are pure routing to another system. A pass-through proxy endpoint has no models to validate and no rules to centralize. Minimal API handlers keep the code proportional to the job.

Common mistakes

  • Returning entities from actions. It locks the wire format to storage, invites over-posting, and breaks the moment relationships loop; return DTOs.
  • 200 for everything. Clients then check bodies for error hints. Use 400, 404, and 409 so their logic is one status check.
  • Missing [ApiController]. Without it, invalid models arrive as null-checked arguments instead of automatic 400 Problem Details, and binding sources need explicit attributes.
  • Business rules in controllers. The controller grows into a rule book. Keep binding, delegation, and response shaping, and move rules into services.
  • Guessing the id route. Ambiguous routes such as two parameters with the same constraint produce 500s at request time with cryptic errors. Keep route templates unique and constrained.
  • Skipping OpenAPI maintenance. Hand-edited docs drift from the code within a sprint. The generated document, honest return types included, is the contract.

Key takeaways

  • dotnet new webapi -controllers plus MapControllers() wires controller routing; add AddOpenApi()/MapOpenApi() for the generated contract document.
  • [ApiController] gives automatic 400 validation responses, inferred binding sources, and Problem Details consistency across the API.
  • Attribute routing templates plus constraints ({id:guid}) decide what reaches the method; the [Http*] attributes declare the verb and route.
  • Expose DTOs, never entities: request records define writable fields, response records freeze the wire format.
  • Return accurate statuses: 200 with body, 201 with Location via CreatedAtAction, 204 for deletes, 404 for absent ids.
  • Controllers for team APIs, minimal APIs for small services. Both are first-class styles and the table above is the fair comparison.

FAQ

What is the difference between controllers and minimal APIs in ASP.NET Core?

Controllers organize endpoints as classes with attribute routing, automatic model validation through ApiController, and inferred binding conventions. Minimal APIs register inline handlers with a Map call per endpoint. Controllers suit team APIs with many resources; minimal APIs suit small services with few endpoints.

What does the [ApiController] attribute actually do?

It enables Web API conventions for the class: invalid models automatically produce a 400 Problem Details response before your code runs, binding sources are inferred (complex types from the body, primitives from route and query), and attribute routing is required. Without it you must check ModelState manually and annotate binding sources explicitly.

How does attribute routing work in ASP.NET Core?

Each controller sets a route template, like api/[controller], and each action adds its segment and HTTP verb with an Http attribute, like [HttpGet(“{id:guid}”)]. The routing middleware matches the request URL against those templates at request time; route constraints such as :guid reject non-matching values before the action runs.

How does model binding and validation work in ASP.NET Core?

Model binding maps request data, the JSON body, route values, and query string, onto the action parameters. Validation attributes like Required and Length are then evaluated against the bound object, and with ApiController any failure short-circuits into an automatic 400 response listing the errors. Your method body only runs for a valid, fully bound model.

Should a Web API return entities or DTOs?

DTOs. Returning entities exposes storage structure to clients, invites over-posting of fields clients should not set, and makes the wire format brittle to refactoring. Request DTOs define exactly the writable fields, response DTOs freeze the public contract, and mapping happens in one place inside the controller or a mapping service.

Thin controllers, honest contracts

A controller earns its keep when it is boring: bind, validate, delegate, respond with the right status. The interesting decisions all live in the contracts. This is because the DTOs are what clients build against and the OpenAPI document is generated from your honest types. Next, dependency injection in ASP.NET Core is the natural companion piece, since the services your controllers delegate to are the things the container builds. Every guide in the series lives on the .NET series index. The rule of thumb: if the controller is exciting, move that excitement into a service and keep the contract honest.

Last updated on 25 October 2026

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *