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.
On this page · 6 min · Beginner
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Beginner
- Updated
- Oct 29, 2026
Related tutorials
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#
// 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#
// 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();
}
/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#
- The controller and the minimal API group share one ProductCatalog from DI.
- For an existing product both return 200 with identical JSON.
- For a missing product the controller’s NotFound() (with [ApiController]) returns a problem-details body; TypedResults.NotFound() returns an empty 404.
- Results<Ok<ProductDto>, NotFound> documents both outcomes for OpenAPI.
- 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#
Without it, invalid models are not rejected automatically and complex parameters are not read from the body.
It hides the success type from OpenAPI and client generators. Prefer ActionResult<T> or typed results.
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?
Show solution
// 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#
Which return type keeps the success type visible while allowing NotFound()?
Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.