DEV_NET_CORE
GET_STARTED
.NETAPI design and implementation

Controllers vs Minimal APIs

Overview

ASP.NET Core supports two main styles for building HTTP APIs: controller-based APIs and Minimal APIs. Both run on the same ASP.NET Core request pipeline, use endpoint routing, dependency injection, middleware, authorization, OpenAPI support, model binding concepts, and common HTTP result patterns. The difference is mainly how endpoints are declared, organized, extended, and maintained.

Controller-based APIs organize endpoints inside controller classes. A controller usually derives from ControllerBase, uses attributes such as [ApiController], [Route], [HttpGet], [HttpPost], and exposes action methods. Controllers are familiar, convention-friendly, and especially useful when an application benefits from MVC-style features such as filters, model binding extensibility, application model conventions, OData, JSON Patch, or large team organization.

Minimal APIs define endpoints directly with route mapping methods such as MapGet, MapPost, MapPut, and MapDelete. They are designed to reduce boilerplate and make small HTTP APIs, microservices, internal services, and vertical-slice endpoint modules easier to build. Minimal APIs can still use dependency injection, authorization, route groups, endpoint filters, typed results, validation, OpenAPI metadata, and middleware.

This topic matters because API style affects maintainability, testability, performance, team conventions, discoverability, and how easily cross-cutting concerns can be applied. Interviewers often ask this topic to check whether a developer understands ASP.NET Core architecture beyond syntax. A strong answer should not say that one approach is always better. Instead, it should explain trade-offs and choose based on endpoint complexity, framework feature needs, team structure, project size, validation requirements, and long-term maintainability.

Core Concepts

What Controller-Based APIs Are

A controller-based API uses classes to group related HTTP actions. In ASP.NET Core Web API projects, controllers usually inherit from ControllerBase instead of Controller because Controller includes MVC view support, which is usually unnecessary for APIs.

Code
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/products")]
public sealed class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken cancellationToken)
    {
        ProductDto? product = await _productService.GetByIdAsync(id, cancellationToken);

        if (product is null)
        {
            return NotFound();
        }

        return Ok(product);
    }

    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult<ProductDto>> Create(
        CreateProductRequest request,
        CancellationToken cancellationToken)
    {
        ProductDto product = await _productService.CreateAsync(request, cancellationToken);

        return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
    }
}

Controllers are commonly registered and mapped like this:

Code
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Important controller concepts include:

  • ControllerBase: base class for API controllers without view support.
  • [ApiController]: enables API-specific behavior such as automatic 400 responses for model validation errors, binding source inference, and Problem Details behavior.
  • Attribute routing: maps controller actions to HTTP routes.
  • Action methods: methods that handle HTTP requests.
  • ActionResult<T>: allows an action to return either a typed response body or an HTTP result such as NotFound().
  • Filters: extension points for cross-cutting behavior such as authorization, exception handling, action execution, and result handling.
  • Model binding and validation: maps request data to action parameters and validates request models.

What Minimal APIs Are

Minimal APIs define endpoints directly on WebApplication or route groups. The route handler is often a lambda expression or a method group.

Code
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

app.MapGet("/api/products/{id:int}", async (
    int id,
    IProductService productService,
    CancellationToken cancellationToken) =>
{
    ProductDto? product = await productService.GetByIdAsync(id, cancellationToken);

    return product is null
        ? Results.NotFound()
        : Results.Ok(product);
});

app.Run();

Minimal APIs can be organized with route groups and extension methods to avoid putting every endpoint directly in Program.cs.

Code
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder group = app.MapGroup("/api/products")
            .WithTags("Products")
            .RequireAuthorization();

        group.MapGet("/{id:int}", GetById)
            .WithName("GetProductById")
            .Produces<ProductDto>(StatusCodes.Status200OK)
            .Produces(StatusCodes.Status404NotFound);

        group.MapPost("/", Create)
            .Produces<ProductDto>(StatusCodes.Status201Created)
            .ProducesValidationProblem();

        return group;
    }

    private static async Task<IResult> GetById(
        int id,
        IProductService productService,
        CancellationToken cancellationToken)
    {
        ProductDto? product = await productService.GetByIdAsync(id, cancellationToken);

        return product is null
            ? Results.NotFound()
            : Results.Ok(product);
    }

    private static async Task<IResult> Create(
        CreateProductRequest request,
        IProductService productService,
        LinkGenerator linkGenerator,
        HttpContext httpContext,
        CancellationToken cancellationToken)
    {
        ProductDto product = await productService.CreateAsync(request, cancellationToken);

        string? uri = linkGenerator.GetPathByName(
            httpContext,
            "GetProductById",
            new { id = product.Id });

        return Results.Created(uri ?? $"/api/products/{product.Id}", product);
    }
}
Code
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

app.MapProductEndpoints();

app.Run();

Important Minimal API concepts include:

  • MapGet, MapPost, MapPut, MapDelete: route mapping methods.
  • Route handlers: delegates that handle requests.
  • Parameter binding: values are bound from route values, query strings, headers, body, services, and special framework types.
  • Route groups: organize endpoints under common prefixes and shared metadata.
  • Endpoint filters: run logic before and after endpoint handlers.
  • Results and TypedResults: helper APIs for HTTP responses.
  • Endpoint metadata: supports OpenAPI, authorization, filters, tags, response documentation, and conventions.

Shared Foundation: Middleware, Routing, DI, and Endpoint Metadata

Controllers and Minimal APIs both run inside the ASP.NET Core pipeline. Middleware still handles cross-cutting infrastructure before requests reach endpoints.

Code
var app = builder.Build();

app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.MapProductEndpoints();

app.Run();

This means both styles can share:

  • authentication and authorization middleware
  • exception handling middleware
  • CORS middleware
  • rate limiting middleware
  • request logging middleware
  • dependency injection
  • configuration and options
  • health checks
  • OpenAPI metadata
  • integration testing with WebApplicationFactory

The choice between controllers and Minimal APIs is not a choice between different web servers. It is a choice between different endpoint programming models.

Binding and Validation

Model binding maps incoming request data to .NET parameters and models. Validation checks whether those models satisfy rules such as required fields, ranges, custom validation attributes, or complex validation logic.

Controllers have long-established binding and validation features. With [ApiController], invalid model state can automatically produce a 400 response.

Code
public sealed record CreateProductRequest(
    [property: Required] string Name,
    [property: Range(0.01, 10_000)] decimal Price);

[ApiController]
[Route("api/products")]
public sealed class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(CreateProductRequest request)
    {
        // With [ApiController], invalid models can be rejected automatically
        // before this action executes.
        return Created("/api/products/1", request);
    }
}

Minimal APIs also support binding and validation, but the exact feature set depends on the ASP.NET Core version and packages used. In modern ASP.NET Core, Minimal APIs can validate request data using validation services and endpoint filters. For many applications, this is enough. For advanced MVC-style validation extensibility, controllers may still be the better fit.

Code
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddValidation();

var app = builder.Build();

app.MapPost("/api/products", (CreateProductRequest request) =>
{
    return TypedResults.Created($"/api/products/1", request);
});

app.Run();

Use controllers when you need mature MVC model-binding extension points, custom model binders, model binder providers, model validation providers, or controller conventions. Use Minimal APIs when the binding model is straightforward and endpoint-local code is easier to read.

Return Types and HTTP Results

Controllers commonly return IActionResult, ActionResult<T>, or a specific type.

Code
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id)
{
    ProductDto? product = await _productService.GetByIdAsync(id);

    return product is null
        ? NotFound()
        : product;
}

Minimal APIs commonly return IResult, Results, TypedResults, or typed result unions.

Code
app.MapGet("/api/products/{id:int}", async Task<Results<Ok<ProductDto>, NotFound>> (
    int id,
    IProductService productService) =>
{
    ProductDto? product = await productService.GetByIdAsync(id);

    return product is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(product);
});

TypedResults can improve type information for testing and OpenAPI metadata. Results is simple but less strongly typed. In interviews, it is useful to mention that clear HTTP response modeling matters more than the chosen style.

Filters vs Endpoint Filters

Controllers use MVC filters. Filters are powerful extension points that can run at different stages of the MVC pipeline.

Common controller filters include:

  • authorization filters
  • resource filters
  • action filters
  • exception filters
  • result filters

Example action filter:

Code
public sealed class AuditActionFilter : IActionFilter
{
    private readonly ILogger<AuditActionFilter> _logger;

    public AuditActionFilter(ILogger<AuditActionFilter> logger)
    {
        _logger = logger;
    }

    public void OnActionExecuting(ActionExecutingContext context)
    {
        _logger.LogInformation("Executing {Action}", context.ActionDescriptor.DisplayName);
    }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        _logger.LogInformation("Executed {Action}", context.ActionDescriptor.DisplayName);
    }
}

Minimal APIs use endpoint filters. Endpoint filters can inspect arguments, run logic before and after the handler, modify results, and implement endpoint-level validation, logging, or authorization-related behavior.

Code
app.MapPost("/api/products", async (
    CreateProductRequest request,
    IProductService productService) =>
{
    ProductDto product = await productService.CreateAsync(request);
    return TypedResults.Created($"/api/products/{product.Id}", product);
})
.AddEndpointFilter(async (context, next) =>
{
    var request = context.GetArgument<CreateProductRequest>(0);

    if (string.IsNullOrWhiteSpace(request.Name))
    {
        return TypedResults.ValidationProblem(new Dictionary<string, string[]>
        {
            [nameof(request.Name)] = ["Name is required."]
        });
    }

    return await next(context);
});

Choose controllers when your application already relies heavily on MVC filters, conventions, and the MVC application model. Choose Minimal APIs when endpoint filters and middleware are enough.

Organization and Maintainability

A common mistake is assuming Minimal APIs must place all code in Program.cs. That is only true for very small examples. Production Minimal API projects should usually split endpoints by feature.

Code
Features/
  Products/
    ProductEndpoints.cs
    ProductService.cs
    ProductQueries.cs
    ProductCommands.cs
  Orders/
    OrderEndpoints.cs
    OrderService.cs

Minimal API organization pattern:

Code
public static class OrderEndpoints
{
    public static RouteGroupBuilder MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder group = app.MapGroup("/api/orders")
            .WithTags("Orders");

        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create);

        return group;
    }

    private static async Task<IResult> GetById(int id, IOrderService orderService)
    {
        OrderDto? order = await orderService.GetByIdAsync(id);
        return order is null ? Results.NotFound() : Results.Ok(order);
    }

    private static async Task<IResult> Create(CreateOrderRequest request, IOrderService orderService)
    {
        OrderDto order = await orderService.CreateAsync(request);
        return Results.Created($"/api/orders/{order.Id}", order);
    }
}

Controller organization pattern:

Code
Controllers/
  ProductsController.cs
  OrdersController.cs
Application/
  Products/
  Orders/

Controllers give a clear and familiar structure by default. Minimal APIs need deliberate organization once the app grows. In a large system, both styles should delegate business logic to application services, use cases, commands, queries, or handlers rather than placing business rules directly in endpoint code.

Performance Considerations

Minimal APIs generally have less framework overhead and less boilerplate than controllers. They can be a good fit for high-throughput endpoints, small services, and APIs where simple request handling dominates.

However, performance should rarely be the only reason to choose one style. Most real-world API latency comes from database calls, network calls, serialization, authentication, external services, and business logic. A well-designed controller API can perform well, and a poorly designed Minimal API can still be slow.

Practical performance guidance:

  • Avoid blocking calls such as .Result and .Wait().
  • Use async database and I/O operations.
  • Keep endpoint handlers thin.
  • Use pagination for large collections.
  • Avoid unnecessary serialization work.
  • Measure with realistic load tests before making performance claims.
  • Choose Minimal APIs for lower overhead when the feature requirements fit.

OpenAPI and Documentation

Both controllers and Minimal APIs can produce OpenAPI metadata. Controllers commonly use attributes such as [ProducesResponseType]. Minimal APIs commonly use endpoint metadata methods such as .Produces<T>(), .ProducesValidationProblem(), .WithName(), .WithTags(), and .WithOpenApi() depending on the package and framework version.

Minimal API example:

Code
group.MapGet("/{id:int}", GetById)
    .WithName("GetProductById")
    .WithTags("Products")
    .Produces<ProductDto>(StatusCodes.Status200OK)
    .Produces(StatusCodes.Status404NotFound);

Controller example:

Code
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> GetById(int id)
{
    ProductDto? product = await _productService.GetByIdAsync(id);
    return product is null ? NotFound() : Ok(product);
}

For interviews, mention that API documentation should be explicit regardless of style. Do not rely on default inference for complex APIs where client contracts matter.

Authorization, Authentication, and Cross-Cutting Concerns

Both styles support ASP.NET Core authentication and authorization.

Controller example:

Code
[Authorize]
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id)
    {
        return Ok();
    }
}

Minimal API example:

Code
RouteGroupBuilder group = app.MapGroup("/api/orders")
    .RequireAuthorization();

group.MapGet("/{id:int}", (int id) => Results.Ok());

Cross-cutting concerns that apply to all requests usually belong in middleware. Cross-cutting concerns that apply to a subset of endpoints can be implemented with controller filters, endpoint filters, route group metadata, or custom conventions depending on the chosen style.

Testing Controllers and Minimal APIs

Both styles can be tested with unit tests and integration tests.

For controllers, you can instantiate the controller class and mock dependencies.

Code
[Fact]
public async Task GetById_ReturnsNotFound_WhenProductDoesNotExist()
{
    var service = new Mock<IProductService>();
    service.Setup(x => x.GetByIdAsync(1, It.IsAny<CancellationToken>()))
        .ReturnsAsync((ProductDto?)null);

    var controller = new ProductsController(service.Object);

    ActionResult<ProductDto> result = await controller.GetById(1, CancellationToken.None);

    Assert.IsType<NotFoundResult>(result.Result);
}

For Minimal APIs, it is often better to test endpoint behavior with integration tests or keep handlers as named methods that can be called directly.

Code
[Fact]
public async Task GetById_ReturnsNotFound_WhenProductDoesNotExist()
{
    await using var application = new CustomWebApplicationFactory();
    using HttpClient client = application.CreateClient();

    HttpResponseMessage response = await client.GetAsync("/api/products/999");

    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

In interviews, a strong answer is that integration tests are valuable for both because they verify routing, binding, filters, middleware, serialization, authorization, and response behavior together.

When to Choose Minimal APIs

Choose Minimal APIs when:

  • You are starting a new API and do not need MVC-specific features.
  • The service has simple HTTP endpoints.
  • You want less boilerplate and direct route-to-handler mapping.
  • You are building microservices, internal APIs, health/status APIs, webhooks, or backend-for-frontend endpoints.
  • You prefer vertical-slice organization by feature.
  • Endpoint filters and middleware are enough for cross-cutting concerns.
  • You want concise tests around small handlers or route groups.
  • You want to take advantage of modern ASP.NET Core endpoint features.

Example suitable Minimal API scenario:

Code
app.MapPost("/api/webhooks/payment-succeeded", async (
    PaymentSucceededEvent paymentEvent,
    IPaymentWebhookHandler handler,
    CancellationToken cancellationToken) =>
{
    await handler.HandleAsync(paymentEvent, cancellationToken);
    return Results.NoContent();
});

This endpoint is simple, action-oriented, and does not need controller-specific infrastructure.

When to Choose Controllers

Choose controllers when:

  • The application already uses controller conventions and MVC infrastructure.
  • The team prefers a familiar class-based structure.
  • You need advanced model binding or validation extensibility.
  • You need MVC filters or application model conventions.
  • You use OData or JSON Patch features that are more natural with controllers.
  • You have many related actions that benefit from controller grouping.
  • You need mature patterns around attributes, conventions, versioning, and documentation.
  • You are maintaining an existing controller-based API and consistency matters.

Example suitable controller scenario:

Code
[ApiController]
[Route("api/customers/{customerId:int}/addresses")]
public sealed class CustomerAddressesController : ControllerBase
{
    [HttpPatch("{addressId:int}")]
    public async Task<IActionResult> PatchAddress(
        int customerId,
        int addressId,
        JsonPatchDocument<UpdateAddressRequest> patchDocument,
        CancellationToken cancellationToken)
    {
        // JSON Patch and complex model validation scenarios are often easier
        // to standardize with controller-based APIs.
        return NoContent();
    }
}

Mixing Controllers and Minimal APIs

You can use both styles in the same application.

Code
builder.Services.AddControllers();

var app = builder.Build();

app.MapControllers();

app.MapGet("/health/live", () => Results.Ok(new { status = "live" }));
app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" }));

app.Run();

Mixing styles can be useful when:

  • existing business APIs use controllers, but simple operational endpoints use Minimal APIs
  • new vertical-slice modules use Minimal APIs while older modules remain controller-based
  • the team wants gradual migration
  • a specific feature benefits from one style more than the other

The main risk is inconsistency. If both styles are used, document conventions for routing, validation, error responses, authorization, logging, OpenAPI metadata, and folder structure.

Common Mistakes

A common mistake is putting business logic directly inside controllers or Minimal API lambdas. Endpoint code should usually coordinate request handling, authorization, validation, and response mapping. Business rules should live in application services, domain services, use cases, commands, queries, or handlers.

Another mistake is choosing Minimal APIs only because they look shorter in small demos. Minimal APIs still need clean organization, consistent error handling, validation, logging, authorization, and documentation in production.

A third mistake is choosing controllers only because they are familiar. For small services and simple endpoints, controllers can add unnecessary ceremony.

Other common mistakes include:

  • returning inconsistent error shapes across endpoints
  • skipping OpenAPI response metadata
  • using synchronous I/O in async endpoints
  • manually checking validation everywhere instead of centralizing it
  • overusing filters for business logic
  • allowing endpoints to depend directly on DbContext in large systems without a clear architecture
  • mixing controllers and Minimal APIs without team conventions
  • assuming Minimal APIs cannot be used in large apps
  • assuming controllers are always slower in a way that matters

Best Practices

Use a consistent API style per bounded context or module unless there is a clear reason to mix. Keep endpoint handlers thin and move business behavior to application services. Use DTOs for request and response contracts instead of exposing EF Core entities directly. Standardize validation and error responses with Problem Details. Make OpenAPI metadata explicit for public APIs. Use route groups for Minimal APIs and controller-level route attributes for controllers. Apply authorization at the group or controller level when possible.

For Minimal APIs:

  • organize endpoints by feature using extension methods
  • use route groups for shared prefixes, tags, authorization, and filters
  • prefer named handler methods when lambdas become large
  • use TypedResults when strong response typing helps tests and documentation
  • keep validation and error handling consistent

For controllers:

  • derive from ControllerBase for API-only controllers
  • use [ApiController] for API behavior
  • prefer ActionResult<T> when an endpoint can return either data or an HTTP result
  • use filters for cross-cutting concerns, not business logic
  • avoid large controllers by splitting by resource or feature

Practical Decision Matrix

SituationBetter DefaultReason
Small microservice with simple CRUD endpointsMinimal APIsLess boilerplate and direct endpoint mapping
Existing MVC/Web API projectControllersConsistency and existing conventions matter
Heavy OData usageControllersController-based APIs commonly fit OData patterns better
JSON Patch endpointControllersController support and examples are more mature
Health checks, status, simple webhooksMinimal APIsSmall endpoint surface and low ceremony
Large team with established controller standardsControllersFamiliar organization and review conventions
Vertical-slice architectureMinimal APIsRoute groups and endpoint modules fit feature folders well
Advanced model binding extensibilityControllersMVC model binding extensibility is stronger
API requiring only middleware plus endpoint filtersMinimal APIsSimpler model with enough extension points
Public API with detailed OpenAPI contractEitherBoth can work if metadata is explicit

Interview Practice

PreviousContent negotiation, status codes and request/response contractsNext UpEndpoint routing and route matching