Mert Özen Further With Every Line
Backend .NET Core Web API — #06

Controllers and Actions in ASP.NET Core: A CRUD Skeleton with [ApiController]

Mert Özen Nov 4, 2026 9 min 2 views
Controllers and Actions in ASP.NET Core: A CRUD Skeleton with [ApiController]

The difference between ControllerBase and Controller, the four behaviors [ApiController] adds, choosing between IActionResult and ActionResult<T>, and an in-memory CRUD skeleton, all with real curl output.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

The ProductsController we are about to write will carry two attributes at the top: [ApiController] and [Route("api/[controller]")]. Here is the question: if you delete the [ApiController] line and send a POST request with the body {"name":"USB-C Hub","price":890}, what happens? Does the app throw an exception, return 400, or does everything go fine? Keep your guess in the back of your mind; we will look at the real output halfway through the post.

What Is a Controller, Really?

When we talked about routing earlier, we saw how a URL gets matched to an action. Now we look at the other end of that match. A controller is a class that groups related endpoints together. Everything about products lives in ProductsController, everything about orders lives in OrdersController. The public methods inside the class are actions; each one is a small unit of work that handles a single request.

Picture a restaurant kitchen. A controller is a station: grill, desserts, salads. An action is a single recipe at that station. Routing is the waiter who takes the order to the right station and the right recipe.

ASP.NET Core has two base classes: ControllerBase and Controller. Controller derives from ControllerBase and adds Razor view support on top: things like View(), PartialView() and ViewBag. A Web API does not render HTML, so we need none of that. API controllers derive from ControllerBase; helpers like Ok(), NotFound() and CreatedAtAction() all live there already.

The framework finds controllers on its own. The two lines we saw in Program.cs are the whole story: builder.Services.AddControllers() registers the controller infrastructure, and app.MapControllers() turns attribute routes into endpoints. For a class to count as a controller it has to be a public, non-abstract class whose name ends with Controller or which carries the [Controller] attribute. ControllerBase already carries that attribute, so every class deriving from it is a controller, whatever its name. The Controllers folder is just a convention; the framework never looks at folders.

The First Controller: ProductsController

Our sample domain is a small ProductCatalog API. The model comes first:

namespace ProductCatalog.Models;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

Now the read side of the controller:

using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Models;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    // A temporary in-memory list until we have a database
    private static readonly List<Product> Products = new()
    {
        new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m },
        new Product { Id = 2, Name = "Wireless Mouse", Price = 520m }
    };

    [HttpGet]
    public ActionResult<IEnumerable<Product>> GetAll()
    {
        return Products;
    }

    [HttpGet("{id:int}")]
    public ActionResult<Product> GetById(int id)
    {
        Product? product = Products.FirstOrDefault(p => p.Id == id);
        if (product is null)
        {
            return NotFound();
        }

        return product;
    }
}

You remember the [controller] token in [Route("api/[controller]")] from the routing post; the Controller suffix is dropped from the class name and the route becomes api/Products. [HttpGet("{id:int}")] appends an {id} segment to that route, and the :int constraint rejects non-numeric values while routing is still running.

The list being static is deliberate. We will see why in the pitfalls section; for now, just treat this list as a stand-in for a database. We will build the real data layer in the EF Core post.

What [ApiController] Buys You

[ApiController] is a single line, but four behaviors hide behind it.

First, it makes attribute routing mandatory. If a controller with this attribute has an action without a route, the app stops while it is still starting up:

Unhandled exception. System.InvalidOperationException: Action 'ProductCatalog.Controllers.CategoriesController.GetAll (ProductCatalog)' does not have an attribute route. Action methods on controllers annotated with ApiControllerAttribute must be attribute routed.

Second, binding source inference. It works out where each action parameter should be read from by looking at its type and name: a complex type like Product comes from the body, an id named in the route template comes from the route, and the remaining simple types come from the query string. We will unpack these rules in the model binding post.

Third, the automatic 400. If model binding or validation produced errors, the action never runs; the framework returns 400 Bad Request straight away with a JSON body describing the errors. Validation gets its own post.

Fourth, it produces a ProblemDetails body for status codes of 400 and above. That is why NotFound() gives you the JSON you are about to see instead of an empty 404.

Back to the opening question. Without [ApiController], the real response to the same POST request is this:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/Products/3

{"id":3,"name":"","price":0}

No exception, no 400. The request looks successful, a new product was even added to the list, but the data you sent is gone. Without the attribute, a complex type is not read from the body by default; the framework looks at the form, the route and the query string, finds nothing in any of them and creates an empty Product. There is no automatic 400 either, so the action happily runs with that empty object. Even a broken body like "price":"abc" gives the same result, because nobody reads the body at all. A silent failure is always more expensive than a loud one, which is why [ApiController] is not up for negotiation on API controllers.

Action Return Types: Concrete Type, IActionResult, ActionResult<T>

You have three options for an action's return type:

// 1) Concrete type: simple, but it can only return 200
[HttpGet("concrete")]
public List<Product> GetConcrete()
{
    return Products;
}

// 2) IActionResult: can return any status code, but the data type is invisible
[HttpGet("iaction/{id:int}")]
public IActionResult GetIAction(int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return NotFound();
    }

    return Ok(product);
}

// 3) ActionResult<T>: the best of both
[HttpGet("{id:int}")]
public ActionResult<Product> GetById(int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return NotFound();
    }

    return product;
}

The concrete type is the simplest, but your hands are tied: there is no clean way to return 404 when the product is missing. IActionResult can return any kind of result, but nobody reading the signature can tell what comes back on success. Neither can tools like Swagger, so you end up declaring the response type on every action separately.

ActionResult<T> combines the two. The signature states the success type, and in the body you can return both NotFound() and a plain return product;. The latter works thanks to an implicit conversion: Product is quietly turned into ActionResult<Product> and written as JSON with 200. This is my default: ActionResult<T> for actions that return data, IActionResult for those that don't (PUT, DELETE).

Completing the CRUD Skeleton

The read side is done. Let's add the write actions to the same controller:

[HttpPost]
public ActionResult<Product> Create(Product product)
{
    product.Id = Products.Count == 0 ? 1 : Products.Max(p => p.Id) + 1;
    Products.Add(product);
    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}

[HttpPut("{id:int}")]
public IActionResult Update(int id, Product product)
{
    Product? existing = Products.FirstOrDefault(p => p.Id == id);
    if (existing is null)
    {
        return NotFound();
    }

    existing.Name = product.Name;
    existing.Price = product.Price;
    return NoContent();
}

[HttpDelete("{id:int}")]
public IActionResult Delete(int id)
{
    Product? existing = Products.FirstOrDefault(p => p.Id == id);
    if (existing is null)
    {
        return NotFound();
    }

    Products.Remove(existing);
    return NoContent();
}

There is no [FromBody] on the Create parameter; [ApiController] already knows to read a complex type from the body. Update mixes two sources: id from the route, product from the body. CreatedAtAction returns 201 and also builds the address of the new resource; we use nameof(GetById) so we never type the action name by hand and invite a typo. We will see how this helper works in detail in the next post.

Sending Requests and Reading the Results

Run the app with dotnet run and send these requests from a terminal, one after another. Your port may differ depending on your launchSettings.json:

curl -i http://localhost:5080/api/products/99
curl -i -X POST http://localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"USB-C Hub","price":890}'
curl -i -X PUT http://localhost:5080/api/products/3 -H "Content-Type: application/json" -d '{"name":"USB-C Hub Pro","price":990}'
curl -i -X DELETE http://localhost:5080/api/products/3

Here are the responses (I removed the Date and Server headers for brevity):

HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Not Found","status":404,"traceId":"00-39414595185de15ab0136802b17ea06a-6d5ff1e21dae1feb-00"}

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/Products/3

{"id":3,"name":"USB-C Hub","price":890}

HTTP/1.1 204 No Content

HTTP/1.1 204 No Content

Two things deserve attention. The 404 body is not empty; [ApiController] turned it into ProblemDetails and the content type became application/problem+json. On .NET 10 the same code writes the RFC 9110 address into the type field; we will get to that difference in the version posts later on. The Location header has Products with a capital P because the token is built from the class name. URL paths are case-insensitive in routing, so this address works just fine.

Common Pitfalls

Make the list an instance field instead of static and you end up with a controller like this:

[ApiController]
[Route("api/instance-products")]
public class InstanceListController : ControllerBase
{
    // A new controller per request, so a new, empty list every time
    private readonly List<Product> _products = new();

    [HttpPost]
    public ActionResult<Product> Create(Product product)
    {
        _products.Add(product);
        return product;
    }

    [HttpGet]
    public ActionResult<IEnumerable<Product>> GetAll()
    {
        return _products;
    }
}

The POST returns 200, and the GET you send right after returns []. The reason is simple: the framework creates a new controller instance for every request and lets it go when the request ends. State kept in a controller lives exactly as long as the request. A static list solves that, but it has its own price: List<T> is not thread-safe and two concurrent POSTs can corrupt it. Fine for a demo; the lasting fix is a database plus dependency injection.

The second pitfall is adding a public method to the controller as a little helper:

public string ToSlug(string name)
{
    return name.Trim().ToLowerInvariant().Replace(" ", "-");
}

It carries no HTTP attribute, so it looks harmless. It isn't. It inherits the controller-level route and becomes an action that accepts any HTTP method. The request curl -X PATCH "http://localhost:5080/api/products?name=USB%20Hub" returns 200 and usb-hub; your internal helper is now exposed to the world. Either make it private or mark it with [NonAction].

Third, returning a LINQ result directly from an action that returns ActionResult<IEnumerable<Product>>:

[HttpGet("expensive")]
public ActionResult<IEnumerable<Product>> GetExpensive()
{
    return Products.Where(p => p.Price > 1000);
}

The compiler refuses:

error CS0029: Cannot implicitly convert type 'System.Collections.Generic.IEnumerable<ProductCatalog.Models.Product>' to 'Microsoft.AspNetCore.Mvc.ActionResult<System.Collections.Generic.IEnumerable<ProductCatalog.Models.Product>>'

C# does not allow user-defined implicit conversions from an interface, and Where returns an interface. Add .ToList() at the end and you hold a concrete type, so the conversion works. That is also why return Products; works in GetAll: Products is already a List<Product>.

A Small Experiment

★☆☆ Write a Category model (Id, Name) and a CategoriesController that keeps two categories in memory. Make GET api/categories and GET api/categories/{id} work, returning 404 for an unknown id.

★★☆ Add a GET api/products/cheapest action to ProductsController that returns the cheapest product. Then remove the {id:int} constraint, send the same request again and watch what changes.

★★★ Temporarily remove the [ApiController] line and POST the body {"name":"USB-C Hub","price":"abc"}. Then put the attribute back and repeat the request. Put the two responses side by side and explain, in your own words, which behavior causes the difference.

In this post we used Ok, NotFound, NoContent and CreatedAtAction without opening most of them up. What exactly does that JSON in the 404 body say, how is the Location header built, and when should you return 409? In the next post we put status codes and responses on the table.