ASP.NET Core Web API · Building APIs

Controllers and Endpoints

ASP.NET Core gives you two ways to write endpoints. Controllers group actions in classes; minimal APIs map lambdas or methods directly. We write the same small feature both ways so you can read either style — and choose sensibly.

Beginner 6 min Lesson 5 of 37Examples target .NET 9.
0/37 in ASP.NET Core Web API
On this page · 6 min · Beginner

In simple words#

An API controller is a counter with several windows; each action method is one window handling one kind of request.

[ApiController]
Enables API conventions like automatic 400s.
ControllerBase
Base class without view support.
[HttpGet] etc.
Map an action to a method.

Learning objectives#

  • Explain what [ApiController] adds
  • Choose between IActionResult, ActionResult<T> and plain return types
  • Write minimal API endpoints with MapGroup and TypedResults
  • Compare both styles side by side

Explanation#

[ApiController] on a ControllerBase class enables: attribute routing requirement, automatic 400 responses when model validation fails, binding-source inference ([FromBody] for complex types, [FromRoute]/[FromQuery] for simple ones), and problem details for error results.

Return types: a plain type (ProductDto) when the action always succeeds; ActionResult<ProductDto> when it can also return NotFound or BadRequest (keeps the success type for OpenAPI); IActionResult when there is no single success type (e.g. NoContent).

Minimal APIs: app.MapGroup("/api/v2/products") creates a route group; endpoints are lambdas or static methods with injected parameters. Results<Ok<ProductDto>, NotFound> with TypedResults makes the possible responses explicit and documented.

Both styles get the same DI, configuration, auth, filters (endpoint filters for minimal APIs) and OpenAPI support.

Rule of thumb: controllers for large APIs with many related actions and filters; minimal APIs for small services, prototypes and simple endpoints.

Syntax#

C# Illustrative
// Controller
[HttpGet("{id:int}")] public ActionResult<ProductDto> Get(int id) => … ? product : NotFound();
// Minimal API
var products = app.MapGroup("/api/v2/products");
products.MapGet("/{id:int}", Results<Ok<ProductDto>, NotFound> (int id) => … ? TypedResults.Ok(p) : TypedResults.NotFound());

Code example: The same endpoints, two styles#

C#Program.cs Illustrative
// Program.cs
using Microsoft.AspNetCore.Http.HttpResults;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                   // keep the demo output clean
builder.Services.AddControllers();
builder.Services.AddSingleton<ProductCatalog>();


var app = builder.Build();
app.MapControllers();                                               // style 1: controllers (/api/products)


var v2 = app.MapGroup("/api/v2/products");                          // style 2: minimal APIs
v2.MapGet("/", (ProductCatalog catalog) => catalog.All);
v2.MapGet("/{id:int}", Results<Ok<ProductDto>, NotFound> (int id, ProductCatalog catalog) =>
    catalog.Find(id) is { } product ? TypedResults.Ok(product) : TypedResults.NotFound());


app.Urls.Add("http://127.0.0.1:5205");
await app.StartAsync();


using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5205") };
foreach (var path in new[] { "/api/products/1", "/api/v2/products/1", "/api/products/7", "/api/v2/products/7", "/api/v2/products" })
{
    var response = await http.GetAsync(path);
    Console.WriteLine(
quot;
{path,-20} {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}".TrimEnd());
} await app.StopAsync(); // Services/ProductCatalog.cs public record ProductDto(int Id, string Name, decimal Price); public class ProductCatalog { public IReadOnlyList<ProductDto> All { get; } = [new(1, "Wireless Mouse", 799m), new(2, "USB-C Hub", 1499m)]; public ProductDto? Find(int id) => All.FirstOrDefault(p => p.Id == id); } // Controllers/ProductsController.cs using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/[controller]")] public class ProductsController(ProductCatalog catalog) : ControllerBase { [HttpGet] public IReadOnlyList<ProductDto> GetAll() => catalog.All; // always succeeds: plain type [HttpGet("{id:int}")] public ActionResult<ProductDto> Get(int id) => // success type + error results catalog.Find(id) is { } product ? product : NotFound(); }
Expected output
/api/products/1      200 {"id":1,"name":"Wireless Mouse","price":799}
/api/v2/products/1   200 {"id":1,"name":"Wireless Mouse","price":799}
/api/products/7      404 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"0HNPL7JQH9QRD:00000003"}
/api/v2/products/7   404
/api/v2/products     200 [{"id":1,"name":"Wireless Mouse","price":799},{"id":2,"name":"USB-C Hub","price":1499}]
(the traceId varies per request)

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. The controller and the minimal API group share one ProductCatalog from DI.
  2. For an existing product both return 200 with identical JSON.
  3. For a missing product the controller’s NotFound() (with [ApiController]) returns a problem-details body; TypedResults.NotFound() returns an empty 404.
  4. Results<Ok<ProductDto>, NotFound> documents both outcomes for OpenAPI.
  5. MapGroup gives all minimal endpoints a shared prefix, just like [Route] on a controller.

Real-world use case#

The ShopSmart API uses controllers for its large catalogue and order features, and a few minimal endpoints for /health, /api/status and webhook receivers.

Common mistakes#

Forgetting [ApiController]

Without it, invalid models are not rejected automatically and complex parameters are not read from the body.

IActionResult everywhere

It hides the success type from OpenAPI and client generators. Prefer ActionResult<T> or typed results.

Giant Program.cs with minimal APIs

Hundreds of MapGet lambdas in one file get unreadable. Use route groups and extension methods per feature.

Best practices#

  • [ApiController] on every API controller
  • ActionResult<T> or TypedResults for clear contracts
  • MapGroup + extension methods for minimal APIs
  • Pick one style per feature

Remember this#

[ApiController] = auto 400, binding inference, problem details.

ActionResult<T>: value or error result.

Minimal: MapGroup + TypedResults.

Practice exercise#

Your task: Move the minimal API endpoints into a static extension method MapProductsV2(this IEndpointRouteBuilder app) in its own file, and call it from Program.cs.

Need a hint?
public static RouteGroupBuilder MapProductsV2(this IEndpointRouteBuilder app) { var group = app.MapGroup(…); … return group; }
Show solution
C# Illustrative
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ProductCatalog>();
var app = builder.Build();
app.MapProductsV2();
app.Run();


// Endpoints/ProductEndpoints.cs
using Microsoft.AspNetCore.Http.HttpResults;


public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductsV2(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/v2/products");
        group.MapGet("/", (ProductCatalog catalog) => catalog.All);
        group.MapGet("/{id:int}", Results<Ok<ProductDto>, NotFound> (int id, ProductCatalog catalog) =>
            catalog.Find(id) is { } p ? TypedResults.Ok(p) : TypedResults.NotFound());
        return group;
    }
}


public record ProductDto(int Id, string Name);
public class ProductCatalog
{
    public IReadOnlyList<ProductDto> All { get; } = [new(1, "Wireless Mouse")];
    public ProductDto? Find(int id) => All.FirstOrDefault(p => p.Id == id);
}

Mini quiz#

Controllers and Endpoints quizMultiple choice Question 1 of 3

Which return type keeps the success type visible while allowing NotFound()?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.