Controller‐based APIs vs Minimal APIs - trinhle12/OnlyWiki GitHub Wiki

🏠 Overview

ASP.NET Core supports two approaches to creating APIs: a controller-based approach and minimal APIs. Controllers in an API project are classes that derive from ControllerBase. Minimal APIs define endpoints with logical handlers in lambdas or methods. The main differences between these two methodologies lay within their request-response handling mechanism, which includes:

  • Routing
  • Model binding
  • Validation
  • Response formatting (e.g., returning JSON)
  • Helper methods like Ok(), BadRequest(), etc.

This guide compares how these key features work in ASP.NET Core ControllerBase vs in .NET 8 Minimal APIs, highlighting differences in syntax, behavior, and developer experience.

🧭 Routing

  • ControllerBase:

Attribute routing: Placing [Route], [HttpGet], [HttpPost], etc., on controllers/actions defines the URL patterns. For instance, [Route("api/[controller]")] on the class and [HttpGet("{id}")] on Get(int id) will match GET /api/products/3 to Get(3). The [ApiController] attribute (common on Web API controllers) requires attribute routing and disables conventional routes

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public ActionResult<Product> Get(int id) => Ok(_db.GetProduct(id));
}

Endpoint Mapping: In Program.cs or Startup, calling app.MapControllers() enables the attribute-defined routes. For example:

app.UseAuthorization();
app.MapControllers();
app.Run();
  • Minimal APIs:

Minimal APIs define routes directly in Program.cs using methods like MapGet and MapPost. You pass the URL template and a handler delegate. For example:

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

app.MapGet("/api/products/{id}", (int id) =>
{
    // handler code
    return Results.Ok(database.GetProduct(id));
});

app.Run();

This maps GET /api/products/{id} to the lambda, automatically binding {id} to the int id parameter. Minimal APIs can also use route groups (via MapGroup) to share prefixes and metadata across endpoints, introduced in .NET 8. Using MapGroup reduces repetition for common path prefixes. For example:

var products = app.MapGroup("/api/products");
products.MapGet("/{id}", (int id) => /* ... */);
products.MapPost("/", (ProductDto dto) => /* ... */);

🧩 Model Binding

  • ControllerBase:

In controllers, parameters on action methods are bound via binding sources, which is inferred automatically by ASP.NET Core. By default, simple types (e.g. int, string) come from route or query string, and complex types come from the request body (with [ApiController], complex types assume [FromBody]). You can override with attributes like [FromQuery], [FromBody], [FromRoute], [FromHeader], and [FromServices]. For example:

[HttpGet("{id}")]
public ActionResult<Product> Get(
    [FromRoute] int id,
    [FromQuery(Name = "p")] int page,
    [FromHeader(Name = "X-Auth")] string authToken)
{
    // id from route, page from query ?p=, authToken from header
    // ...
}

All binding results (successful or not) and validation results are stored in the controller’s ModelState property. For example, ModelState.IsValid can be checked to see if binding succeeded. The ModelState dictionary contains any conversion or validation errors.

💡 Note:
If model binding fails (e.g., wrong data type or missing required fields):
No exception thrown, ASP.NET populates ModelState with errors. 
If [ApiController] is applied (which is typical for Web APIs), it automatically returns 400 Bad Request if ModelState.IsValid == false.
  • Minimal APIs:

Binding in minimal APIs is done by declaring parameters in the route handler. The framework infers sources, or you can use the same attributes. By convention, route parameters bind to matching arguments, [FromQuery] binds from query string, [FromHeader], [FromBody], and [FromServices] work similarly. For example:

app.MapGet("/api/products/{id}", ([FromRoute] int id, [FromQuery] int page, [FromHeader(Name="X-Auth")] string authToken) =>
{
    // id from route, page from ?page=, authToken from header
    return Results.Ok(...);
});

For a POST with a JSON body, you simply take a complex type parameter and it binds from the body by default (no [FromBody] needed for POST)

app.MapPost("/api/products", (ProductCreateDto product) =>
{
    // 'product' is deserialized from JSON body
    return Results.Created($"/api/products/{product.Id}", product);
});

In summary, they support the same binding sources as controllers but without a controller class or ModelState

💡 Note:
If model binding fails (e.g., wrong data type or missing required fields):
Minimal API will NOT throw exceptions — binding simply sets values to default (null or 0), and no automatic 400 is returned.
Hence, system must manually check for null or invalid values if needed.

🔬 Validation

  • ControllerBase:

Controllers with the [ApiController] attribute automatically validate models decorated with data annotations ([Required], etc.) and returns a 400 Bad Request with details if the model is invalid. For example, if an action takes a [FromBody] ProductDto, and its properties are annotated, the framework checks validation before entering the method.

If custom validation handling is needed, you can still check ModelState and return BadRequest(ModelState) or use ValidationProblem(ModelState) to produce a similar standardized error response.

💡 Note: If validation fails, controllers behave the same as binding fail
  • Minimal APIs:

Minimal APIs do not have a built-in ModelState or automatic validation pipeline. There’s no implicit 400 response on data-annotation failure. You must explicitly validate. Common approaches:

    • Manual validation: Use Validator.TryValidateObject on the object and return Results.BadRequest(...) if invalid.
    • Endpoint filters or middleware: You can plug in custom code (or libraries like FluentValidation) to run before the handler. For example, an endpoint filter could inspect the parameters and apply data-annotation validation.
    • FluentValidation: Inject a validator service and call it in your handler, returning Results.BadRequest(errors) if needed.

For example, manual validation in a minimal endpoint might look like:

app.MapPost("/users", ([FromBody] UserDto user) =>
{
    var results = new List<ValidationResult>();
    if (!Validator.TryValidateObject(user, new ValidationContext(user), results, true))
    {
        return Results.BadRequest(new { Errors = results });
    }
    return Results.Ok(...);
});

🛠️ Response Formatting

  • ControllerBase:

ControllerBase actions return data that is automatically serialized (formatted) into the response body based on the result type of the action.

    • IActionResult:

By default, ASP.NET Core uses JSON and supports content negotiation based on request headers. The framework wraps returned value (object, string, int, bool...) in an ObjectResult and serializes the object to JSON. System.Text.Json is the default serializer. If the is null, the framework returns a 204 No Content response by default. For example:

[HttpGet("{id}")]
public TodoItem? GetById(long id) => _store.GetById(id);

Content Negotiation: Clients can request a specific response format via the Accept header. ASP.NET Core examines the Accept header and chooses a matching output formatter.

By default, ASP.NET Core supports media types: application/json, text/json, and text/plain (require no additional config).

If the client explicitly requests XML and the XML formatter is added (AddXmlSerializerFormatters()), ASP.NET Core will serialize to XML.

If no formatter matches Accept header and MvcOptions.ReturnHttpNotAcceptable is true, a 406 response is returned. Otherwise, the first formatter that can produce a response will be used (usually JSON)

Customize Serialization: You can add or change formatters in Program.cs. For instance, calling AddXmlSerializerFormatters() enables XML. To use Newtonsoft.Json instead of System.Text.Json, call .AddNewtonsoftJson() when adding controllers.

Restricting Formats: The [Produces] attribute on a controller or action can enforce a specific response format. For example:

[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class TodoItemsController : ControllerBase { … }

This tells ASP.NET Core to always use JSON for responses from this controller, even if other formatters are available.

    • JsonResult:

Always return JSON-formatted data, ignoring a client's request for a different format

    • ContentResult:

Always return plain-text-formatted string data, ignoring a client's request for a different format

  • Minimal APIs:

Route handlers return values directly. If you return a plain string, the response is text/plain. If you return any other type T, the runtime JSON-serializes it (with content-type application/json).

Minimal APIs do not do full content negotiation by default. Instead, the first matching rule above applies. For example:

app.MapGet("/hello", () => "Hello World"); 
// Returns 200 OK, Content-Type: text/plain (the string directly).

app.MapGet("/data", () => new { Name = "Alice" }); 
// Returns 200 OK, Content-Type: application/json {"name":"Alice"}

You can also return a custom result or use Results.Json(...) to control JSON output or status code explicitly. But by default minimal APIs always use JSON for objects. In contrast to controllers, there’s no built-in support for XML or other formats unless you write custom logic.

📦 Helper Methods

  • ControllerBase:

ControllerBase includes many helper methods that create IActionResult objects with specific status codes. These simplify returning consistent responses:

    • Ok() / Ok(object) Returns 200 OK. Ok() produces an empty 200, while Ok(value) returns 200 with a JSON body.
    • BadRequest() / BadRequest(object) Returns 400 Bad Request. Commonly used to return model state errors (e.g. BadRequest(ModelState)).
    • NotFound() / NotFound(object) Returns 404 Not Found. NotFound() yields 404, and NotFound(value) yields 404 with a response body.
    • Created() / CreatedAtAction() / CreatedAtRoute() Returns 201 Created. Often used after a successful POST. For example, CreatedAtAction(nameof(GetById), new { id = item.Id }, item) returns 201 with a Location header pointing to the new resource’s URL.
    • NoContent() Returns 204 No Content (success with no body).
    • Conflict() Returns 409 Conflict, useful for indicating a resource conflict.
    • File / PhysicalFile Returns file content with appropriate headers (200 or 206 for range requests).
    • Other Results There are helpers for common scenarios: Accepted() (202), Unauthorized(), Forbid(), Redirect(), and LocalRedirect(). The Problem() method returns a standardized problem details response (useful for errors).

For example, a typical GET action might use these helpers as follows:

[HttpGet("{id}")]
public IActionResult Get(long id)
{
    var item = _store.GetById(id);
    if (item == null) return NotFound();
    return Ok(item);
}
  • Minimal APIs:

Minimal APIs use the Results (or TypedResults) static classes to achieve the same. For example, Results.Ok(value) and Results.NotFound() produce the analogous responses. These return an IResult that the framework executes. For example:

app.MapGet("/items/{id}", (int id) =>
{
    var item = db.Find(id);
    return item != null 
        ? Results.Ok(item)        // 200 with JSON body
        : Results.NotFound();     // 404 Not Found
});

There are helpers for all common statuses: Results.BadRequest(), Results.Created(uri, obj), Results.NoContent(), Results.ValidationProblem(...), etc.

The TypedResults class provides strongly-typed versions (e.g. TypedResults.Ok(product)) that encode the response type in the return type (useful for OpenAPI metadata).

app.MapGet("/orders/{id}", (int id) =>
    id > 999 ? TypedResults.BadRequest() : TypedResults.Ok(new Order(id))
);

The built-in helpers cover essentially the same cases as ControllerBase methods. Under the hood, they produce the same HTTP status codes and content.

🧠 Why use Minimal APIs

Minimal APIs in ASP.NET Core can offer better performance compared to traditional API Controllers for several architectural and technical reasons:

  • Lower Abstraction Overhead

Minimal APIs avoid the MVC controller infrastructure, which includes filters, validation (reflection), and action selectors (reflection). This results in fewer layers and less code being executed per request.

  • Streamlined Routing

Minimal APIs use endpoint routing directly, allowing the ASP.NET Core middleware to quickly match and invoke endpoints. Controllers use attribute routing or conventional routing, which requires more work to resolve the target action method.

⚠️ **GitHub.com Fallback** ⚠️