← Open in the full interactive course (progress tracking, search & more)

The classic, class-based way to build APIs — and the formal introduction it never got until now.

Every ASP.NET Core tutorial you'll find online eventually shows a class ending in Controller, decorated with a stack of attributes, with methods like Get, Post, and GetById. Until this lesson, you'd never formally seen this style — the lesson 161/162 project deliberately used Minimal APIs instead, so you'd get hands-on practice without controller ceremony obscuring the fundamentals.

Now that you understand the pipeline (253), middleware (254), and how filters plug into MVC (255), you have everything you need to understand Controllers properly — not as "the way ASP.NET Core APIs are built," but as one deliberate, structured style among two valid ones, with real, specific advantages for the situations where structure pays for itself.

In this lesson, you'll learn the [ApiController] attribute and exactly what it does for you, attribute routing, the difference between ControllerBase and Controller, and an honest comparison against Minimal APIs.

What Is It?

The Simple Explanation

A Controller is a class that groups together a set of related HTTP endpoints as methods, called actions. Instead of one route mapped to one standalone function (as in Minimal APIs), you get one class — say, OrdersController — containing several methods, each one handling a different route or HTTP verb for that same resource.

The Technical Definition

A Controller is a class — conventionally suffixed Controller — that derives from ControllerBase (or Controller), is discovered automatically by ASP.NET Core's MVC infrastructure when you call builder.Services.AddControllers(), and is mapped into the routing table when you call app.MapControllers(). Each public method decorated with an HTTP-verb attribute ([HttpGet], [HttpPost], etc.) becomes an action — an individually routable endpoint, invoked through the MVC action-invocation pipeline you learned about in lesson 255, filters and all.

Why Does It Exist?

The Problem — Large APIs Need Shared Structure

A single resource, like "orders," might need five, ten, or more related endpoints: list, get by id, create, update, delete, plus more specific ones like "mark as shipped" or "get order history for a customer." As an API grows to dozens of resources, keeping all of this consistent, discoverable, and testable by convention alone becomes genuinely valuable — and applying shared cross-cutting behavior (validation, response shaping, authorization) uniformly across a whole family of related actions is exactly the kind of thing a shared base class and a filter pipeline (lesson 255) were built for.

The Solution — A Class-Based, Convention-Driven Model

Controllers predate Minimal APIs by many years — they're the original ASP.NET Core (and, in spirit, ASP.NET MVC before it) way of structuring a web application. They bring real structure: one class per resource, a shared base class with useful helpers, a rich filter ecosystem, and — through [ApiController] — a set of automatic, sensible defaults specifically tuned for building HTTP APIs.

Big Picture

A CONTROLLER, ANNOTATED
[ApiController]                          ← enables API-specific defaults (auto 400, binding inference, ProblemDetails)
[Route("api/[controller]")]              ← attribute routing: "[controller]" becomes "orders"
public class OrdersController : ControllerBase   ← ControllerBase: API base, no view-rendering support
{
    [HttpGet("{id:int}")]                ← action: GET /api/orders/{id}
    public IActionResult GetById(int id) { ... }

    [HttpPost]                           ← action: POST /api/orders
    public IActionResult Create(CreateOrderRequest request) { ... }
}

Every action inside this one class shares the same route prefix, the same base class helpers, and any filters applied at the class level — that shared context is precisely what a Controller buys you over a scattered set of independent Minimal API delegates.

How It Works

[ApiController] — WHAT IT ACTUALLY DOES
1. AUTOMATIC HTTP 400 ON INVALID MODEL STATE
2. BINDING SOURCE INFERENCE
3. PROBLEM-DETAILS-SHAPED ERROR RESPONSES
4. REQUIRES ATTRIBUTE ROUTING
ATTRIBUTE ROUTING
CLASS-LEVEL PREFIX + ACTION-LEVEL TEMPLATE
[Route("api/[controller]")]   // "[controller]" resolves to "Orders" (from OrdersController) → "api/orders"
public class OrdersController : ControllerBase
{
    [HttpGet]                 // GET api/orders
    [HttpGet("{id:int}")]     // GET api/orders/{id}
    [HttpPost]                // POST api/orders
}

Simple Example

The same product API from the previous lesson, rebuilt as a Controller, side by side in spirit with its Minimal API twin:

[ApiController]
[Route("api/[controller]")]
public class ProductsController(IProductRepository repo) : ControllerBase
{
    [HttpGet]
    public IActionResult GetAll() => Ok(repo.GetAll());

    [HttpGet("{id:int}")]
    public IActionResult GetById(int id) =>
        repo.GetById(id) is { } product ? Ok(product) : NotFound();

    [HttpPost]
    public IActionResult Create(CreateProductRequest request)
    {
        var product = repo.Add(request.Name, request.Price);
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
    }
}

// Program.cs
builder.Services.AddControllers();
// ...
app.MapControllers();

What's different from the Minimal API version: repo is now injected through the class's primary constructor — ordinary constructor injection, exactly like any other DI-resolved service, since a Controller instance is itself created by the container per request. Ok, NotFound, and CreatedAtAction are helper methods provided by ControllerBase (the Minimal API equivalents were static Results.* methods). Because [ApiController] is applied, an invalid CreateProductRequest (say, missing a required field) never even reaches the Create method body — the framework already returned 400 automatically.

Real-World Example

ControllerBase vs. Controller matters in practice the moment your project needs to render HTML views alongside — or instead of — pure JSON APIs:

ControllerBase

Controller

An e-commerce company running a JSON API consumed by a mobile app and a separate JavaScript front-end would build every controller on ControllerBase — there's no HTML being rendered server-side anywhere in that system. A different team building a traditional server-rendered admin dashboard, where the server itself produces the HTML pages, would use Controller for those specific controllers, to get access to View().

Under the Hood

FROM AddControllers() TO A RUNNING ACTION
1. CONTROLLER DISCOVERY
2. MapControllers() REGISTERS THEM AS ENDPOINTS
3. THE CONTROLLER INSTANCE ITSELF IS DI-RESOLVED, PER REQUEST
4. THE ACTION INVOKER RUNS THE FILTER PIPELINE, THEN THE ACTION

Common Confusion

"[ApiController] does validation for me" — partially true, and worth being precise about

It automates the response when validation fails — you don't need to check ModelState.IsValid and return 400 by hand. But it doesn't invent validation rules for you. Your model still needs data annotations like [Required] or a custom IValidatableObject implementation to define what "invalid" even means for that type. [ApiController] automates the enforcement of validation results you've already declared elsewhere — it isn't a validation engine itself.

"Controllers are legacy; Minimal APIs replaced them" — not accurate

Both are actively supported, current, first-class ways to build ASP.NET Core APIs. Minimal APIs are newer, but "newer" here means "an additional option," not "a deprecation of the older one." Plenty of production systems — including large, actively maintained ones — are built entirely on Controllers, for good reasons covered below.

Common Mistakes

Mistake 1 — Forgetting [ApiController] and wondering why nothing is automatic

Writing a controller class without [ApiController], then being confused why invalid model state doesn't produce an automatic 400, or why binding source inference doesn't seem to be happening.

For API controllers, [ApiController] is effectively mandatory practice — it's what turns on all the API-specific conveniences this lesson covers.

Mistake 2 — Forgetting an attribute route and getting a 404

Adding an action method without an [HttpGet]/[HttpPost]/etc. attribute (or with one but no class-level [Route]), then being surprised the endpoint returns 404 for every request.

Every action needs an explicit HTTP-verb attribute, and the controller needs a [Route] template (commonly "api/[controller]") establishing its base path.

Mistake 3 — Deriving from Controller in a pure API project

Using Controller instead of ControllerBase in a project that only ever returns JSON, never renders a view.

Use ControllerBase for API-only controllers — Controller's view-rendering support is dead weight there, and reaching for it out of habit signals a misunderstanding of which base class is meant for what.

When Should I Use It?

Controllers earn their keep when

Minimal APIs remain simpler when

Neither is universally correct. Both compile down to entries in the same endpoint routing table, running on the same pipeline. This is a genuine, defensible engineering tradeoff about structure versus ceremony — not a case of one approach being objectively better.

Mental Model

A Controller = a class grouping related actions, each individually routable, all sharing base-class helpers and filters
[ApiController] = "turn on API-specific defaults": auto-400, binding inference, ProblemDetails, required attribute routing
ControllerBase = the lean API base · Controller = ControllerBase + view rendering

Remember:
· A Controller instance is DI-resolved fresh per request, exactly like any other service.
· Attribute routing ([Route], [HttpGet]) is required alongside [ApiController].
· Structure and filters vs. leanness and directness — that's the real tradeoff against Minimal APIs.

Key Takeaway


Check Your Understanding

You've now formally learned Controllers, and can compare them honestly against Minimal APIs. Let's check the details.

1. A controller action takes a CreateOrderRequest parameter with a [Required] property that's missing from the incoming JSON. With [ApiController] applied, what happens?

Show answer

Correct: B

Why B is correct: This is exactly the automatic model-state validation behavior [ApiController] provides — once binding produces an invalid model, the framework short-circuits with a 400 before the action method runs, formatted as ProblemDetails.

Why A is incorrect: This is precisely what [ApiController] exists to prevent — you don't need to manually guard against invalid state inside the action, because it never reaches the action at all.

Why C, D are incorrect: This is a normal, expected, handled outcome — not an unhandled exception or a hang.

Reinforcement: Automatic 400-on-invalid-model-state is one of [ApiController]'s signature, documented behaviors.

2. What's the key functional difference between ControllerBase and Controller?

Show answer

Correct: B

Why B is correct: Controller derives from ControllerBase and adds Razor view-rendering support — relevant only when the server is producing HTML views, not needed for pure JSON APIs.

Why A is incorrect: DI works identically for both — constructor injection is unrelated to which base class is chosen.

Why C is incorrect: [ApiController] can be applied to controllers deriving from either base class; it isn't restricted to ControllerBase.

Why D is incorrect: The view-rendering capability is a real, meaningful functional difference, not just a naming difference.

Reinforcement: Choose ControllerBase for pure APIs; reach for Controller only when you actually need to render views.

3. How does a Controller instance's dependencies typically get supplied?

Show answer

Correct: A

Why A is correct: A Controller is DI-activated per request, just like any other service — its constructor parameters are resolved by the container the same way lesson 124 described for any class.

Why B is incorrect: [FromServices] on an action parameter is a valid, separate option (lesson 258), but ordinary constructor injection is the standard, more common approach for controller-wide dependencies.

Why C is incorrect: DI is fully supported and is the standard pattern for Controllers, exactly as it is everywhere else in ASP.NET Core.

Why D is incorrect: A new controller instance is constructed for each request by default — controllers are not shared as singletons.

Reinforcement: Nothing about DI changes for Controllers — it's the identical container mechanics from lesson 124.

4. A small, single-purpose microservice with four endpoints and no need for classic MVC filters is being built. Based on this lesson's honest guidance, which statement is most accurate?

Show answer

Correct: B

Why B is correct: This is exactly the scenario the lesson identifies as favoring Minimal APIs — small scope, no reliance on classic filter infrastructure, where Controller ceremony would add ceremony without added benefit.

Why A, C are incorrect: Both directly contradict the lesson's balanced framing — neither approach is mandatory or disqualified; both are current, production-ready options.

Why D is incorrect: Controllers and Minimal APIs can be freely mixed within the same ASP.NET Core application.

Reinforcement: Scale and reliance on classic filters are the deciding factors, not a fixed universal ranking.

You now have both handler styles formally covered — and a genuine basis for choosing between them on your next project.


dotnetmadeeasy.com — Learn C# and .NET, the right way.