Building a Web API

A complete example using both minimal APIs and controller-based [ApiController] routing.

Two ways to build the same API

ASP.NET Core supports two equally valid styles for building HTTP APIs. Real projects pick one and stay consistent — but understanding both, and translating between them, is a core skill:

  • Minimal APIs — endpoints defined as inline lambdas directly against WebApplication. Less ceremony, great for small services and microservices.
  • Controllers ([ApiController]) — classes with attribute-routed action methods. More structure, better suited to large APIs with many related endpoints, filters, and conventions.

Both compile down to the same underlying routing/endpoint system — there's no performance difference that matters in practice.

We'll build the same small API — a Product catalog — both ways.

Shared model and "database"

C#
public record Product(int Id, string Name, decimal Price);

// A tiny in-memory stand-in for a real database, for this example only
public static class ProductStore
{
    public static List<Product> Products { get; } = new()
    {
        new Product(1, "Keyboard", 49.99m),
        new Product(2, "Monitor", 199.99m),
    };
}

Style 1: Minimal APIs

C#
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/products", () => ProductStore.Products);

app.MapGet("/products/{id:int}", (int id) =>
{
    var product = ProductStore.Products.FirstOrDefault(p => p.Id == id);
    return product is not null
        ? Results.Ok(product)
        : Results.NotFound(new { message = $"Product {id} not found" });
});

app.MapPost("/products", (Product input) =>
{
    ProductStore.Products.Add(input);
    return Results.Created($"/products/{input.Id}", input);
});

app.Run();

Key points:

  • Route parameters ({id:int}) are bound automatically to the matching method parameter — :int is a route constraint that rejects non-numeric values with a 404 before your code even runs.
  • The request body is bound automatically too: a POST handler with a non-primitive parameter (Product input) tells ASP.NET Core to deserialize the JSON body into it — no explicit [FromBody] needed for a single complex-type parameter (though you can add it for clarity).
  • IResult (Results.Ok(...), Results.NotFound(...), Results.Created(...)) is the minimal API way of controlling the exact HTTP status code and response shape. Returning a plain object (as in the GET /products example) implicitly serializes it as 200 OK JSON.

Style 2: Controller-based

C#
[ApiController]
[Route("api/[controller]")]     // -> /api/products
public class ProductsController : ControllerBase
{
    [HttpGet]
    public ActionResult<IEnumerable<Product>> GetAll()
    {
        return Ok(ProductStore.Products);
    }

    [HttpGet("{id:int}")]
    public ActionResult<Product> GetById(int id)
    {
        var product = ProductStore.Products.FirstOrDefault(p => p.Id == id);
        if (product is null)
        {
            return NotFound(new { message = $"Product {id} not found" });
        }

        return Ok(product);
    }

    [HttpPost]
    public ActionResult<Product> Create([FromBody] Product input)
    {
        ProductStore.Products.Add(input);
        return CreatedAtAction(nameof(GetById), new { id = input.Id }, input);
    }
}

Wiring it up in Program.cs:

C#
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();   // wires every [ApiController] into the routing pipeline

app.Run();

Key points:

  • [ApiController] enables a set of API-specific conventions: automatic 400 responses for invalid model binding, requiring attribute routing, and inferring parameter binding sources.
  • [Route("api/[controller]")][controller] is a token replaced with the class name minus the Controller suffix, so ProductsController maps to /api/products.
  • ActionResult<T> lets an action either return the typed value directly (implicitly 200 OK) or an explicit result like NotFound()/CreatedAtAction() — the controller equivalent of IResult.
  • CreatedAtAction generates the correct Location header by referencing another action method (GetById) rather than hand-building a URL string, so it stays correct if the route ever changes.

Minimal APIs vs controllers — when to use which

Minimal APIs Controllers
Ceremony Low — a lambda per endpoint Higher — a class, attributes, conventions
Best for Small APIs, microservices, simple CRUD Large APIs, many related endpoints, shared conventions/filters
Model binding Automatic, inferred from parameter type Automatic, [ApiController]-driven
Validation Manual, or via a library (e.g. FluentValidation, or endpoint filters) Automatic 400 on invalid ModelState via [ApiController]
Testability Slightly more awkward (top-level lambdas) Very testable — plain classes with injected dependencies

Both can be mixed in the same project. Many real ASP.NET Core apps use minimal APIs for small utility endpoints (health checks, webhooks) and controllers for the bulk of a large domain API.

Common mistakes

  • Forgetting builder.Services.AddControllers() and/or app.MapControllers() — controllers exist as classes but are never reachable without both.
  • Returning a bare object instead of using Results.NotFound()/NotFound() for a missing resource — clients then see a misleading 200 OK with an empty or null body instead of a proper 404.
  • Mixing [FromBody], [FromRoute], and [FromQuery] up on a controller action — ASP.NET Core infers these in most cases, but a wrong explicit attribute silently breaks binding rather than erroring loudly.

Interview questions

Q: What's the practical difference between IResult and ActionResult<T>? They're the minimal-API and controller-based equivalents of the same idea — a way to return either a typed value (implicit 200 OK) or an explicit HTTP result (NotFound(), Created(), etc.) from an endpoint. IResult comes from Results.* static methods used in minimal APIs; ActionResult<T> is the controller-action return type with equivalent helper methods (Ok(), NotFound()) inherited from ControllerBase.

Q: How does ASP.NET Core know to deserialize a POST body into your model? For minimal APIs, a non-primitive parameter type with no route/query match is inferred as coming from the request body and deserialized as JSON automatically. For controllers, [ApiController] applies the same inference, or you can be explicit with [FromBody]. Both use System.Text.Json by default.

Q: Why prefer CreatedAtAction over manually building the Location header string? It generates the URL by referencing the actual route (via the action method name), so if that route's URL template ever changes, the generated Location header stays correct automatically instead of silently pointing at a stale hand-built path.