Skip to main content

Controllers vs Minimal APIs: The First SMS Endpoint – Web API | Lesson 68

Published / updated

Introduction​

ASP.NET Core offers two ways to write endpoints. Controllers are classes with action methods, the style used since the first versions. Minimal APIs are functions mapped directly in Program.cs, added in .NET 6 and improved in every version since.

This lesson builds the first real SMS endpoint, a student from SchoolDb by admission number, in both styles, side by side. Then you will see where they differ and why SchoolApp uses controllers.

By the end of this lesson, you will be able to:

  • Write a controller action that returns ActionResult<T>.
  • Write the same endpoint as a minimal API with Results.
  • Explain what [ApiController] adds, including ProblemDetails for errors.
  • Connect an endpoint to StudentService and SchoolDb.
  • Choose between the two styles for a project, with reasons.

Why this matters​

You will meet both styles at work. Large, older codebases are mostly controllers; new microservices and Microsoft's own samples increasingly use minimal APIs. Reading both fluently is part of the job.

The choice also shapes a whole codebase: where endpoints live, how they are grouped and tested, and how consistent errors look to clients. Making it once, with reasons, saves arguments later.

The concept​

Example: two styles​

Think of two school offices. One has separate rooms, the fees room and the admissions room, each with its own clerk who handles everything for that department. The other has one front counter with a list: "fee questions, go to window 2".

Controllers are the rooms: one class per resource, with all its actions inside. Minimal APIs are the list: each endpoint is one line that maps a method and path to a function.

ControllersMinimal APIs
Endpoint isA method on a class deriving from ControllerBaseA lambda or method passed to app.MapGet and friends
Route from[Route] and [HttpGet("...")] attributesThe MapGet("/path", ...) call
Services arriveThrough the controller's constructorAs lambda parameters
ResultsActionResult<T>: NotFound(), Ok(...), or the valueResults.Ok(...), Results.NotFound()
GroupingOne class per resourceMapGroup("/api/students"), or extension methods
Filters, model validationBuilt in for yearsEndpoint filters; built-in validation from .NET 10

Example: what [ApiController] adds​

[ApiController] on a controller switches on API-friendly behaviour:

BehaviourEffect
Attribute routing requiredEvery action must have a route from attributes
Automatic 400Invalid input is rejected before the action runs (Lesson 73)
Binding source rulesComplex types come from the body, simple ones from the route or query
ProblemDetails for errorsError results such as NotFound() get a standard JSON body (Lesson 74)

The last row produces the most visible difference in this lesson's output.

Example: ActionResult of T​

An action that returns ActionResult<StudentDto> can return either a StudentDto or any other result, such as NotFound(). Returning the DTO itself means 200 OK with it as JSON.

The type parameter also tells tools what the success body looks like, which the OpenAPI document uses in Lesson 71. With plain IActionResult, that information is lost.

Apply it to the SMS​

Step 1: connect the API to Core and Data​

Terminal
dotnet add src/SchoolApp.Api reference src/SchoolApp.Core src/SchoolApp.Data
src/SchoolApp.Api/SchoolApp.Api.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
</ItemGroup>

<!-- Lesson 68: the rules (Core) and the data access (Data) from Modules 7 to 9. -->
<ItemGroup>
<ProjectReference Include="..\SchoolApp.Core\SchoolApp.Core.csproj" />
<ProjectReference Include="..\SchoolApp.Data\SchoolApp.Data.csproj" />
</ItemGroup>

</Project>

The API now references both projects: the rules in Core, and the EF Core repository in Data. The dependency arrows from Lesson 62 still hold: nothing references the API.

Step 2: the controller​

src/SchoolApp.Api/Controllers/StudentsController.cs
using Microsoft.AspNetCore.Mvc;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Services;

namespace SchoolApp.Api.Controllers;

// Lesson 68: the first SMS endpoint backed by SchoolDb. The controller only translates HTTP:
// the route value goes into StudentService, and the result comes back as 200 or 404.
[ApiController]
[Route("api/students")]
public class StudentsController : ControllerBase
{
private readonly StudentService _students;

// ASP.NET Core passes in a StudentService for each request (dependency injection, Lesson 69).
public StudentsController(StudentService students)
{
_students = students;
}

// GET /api/students/ADM-2017-0003
// ActionResult<StudentDto>: either a StudentDto (200 OK) or another result, such as 404.
[HttpGet("{admissionNumber}")]
public async Task<ActionResult<StudentDto>> GetByAdmissionNumber(string admissionNumber, CancellationToken cancellationToken)
{
StudentDto? student = await _students.FindAsync(admissionNumber, cancellationToken);
if (student is null)
{
return NotFound();
}
return student; // converted to Ok(student)
}
}

The controller does three things only: take the admission number from the route, ask StudentService, and turn the answer into 404 or 200. All rules stay in Core.

[HttpGet("{admissionNumber}")] adds to the controller's route: api/students/{admissionNumber}. The part in braces is a route parameter, passed to the action parameter with the same name.

Step 3: Program.cs, with the minimal version​

src/SchoolApp.Api/Program.cs
// Lesson 66: the start of SchoolApp.Api. Program.cs has two halves:
// 1. Register services (the "builder" part), 2. build the app and describe how requests are handled.
using Microsoft.EntityFrameworkCore;
using SchoolApp.Api.Middleware;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Services;
using SchoolApp.Data;
using SchoolApp.Data.EfCore;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Services: things the app can use. AddControllers finds every controller class in this project.
builder.Services.AddControllers();
builder.Services.AddOpenApi(); // describes the API as an OpenAPI document (Lesson 71)

// Lesson 68: what StudentsController needs, from Modules 7 to 9. Lesson 69 explains these lines.
builder.Services.AddDbContext<SchoolDbContext>(options =>
options.UseSqlServer(SqlConnectionFactory.FromEnvironment().ConnectionString));
builder.Services.AddScoped<IStudentRepository, EfStudentRepository>();
builder.Services.AddScoped<StudentService>();

WebApplication app = builder.Build();

// The request pipeline: what happens to every request, in this order (Lesson 67).
// Each Use... adds one middleware; a request goes down the list, and the response comes back up.
app.UseRequestTiming(); // first, so its time includes everything below it

if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/v1.json, only while developing
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers(); // send each request to the matching controller action

// Lesson 68: the same endpoint as a minimal API, only to compare the two styles.
// SchoolApp uses controllers; this endpoint is removed again in Lesson 69.
app.MapGet("/api/minimal/students/{admissionNumber}",
async (string admissionNumber, StudentService students, CancellationToken cancellationToken) =>
{
StudentDto? student = await students.FindAsync(admissionNumber, cancellationToken);
return student is null ? Results.NotFound() : Results.Ok(student);
});

app.Run(); // start the web server and wait for requests

Three new lines register the DbContext, the repository, and the service, so ASP.NET Core can create a StudentService for each request. Lesson 69 is all about those lines.

At the bottom, app.MapGet maps the minimal version to /api/minimal/students/{admissionNumber}. Its parameters come from three places: the route (admissionNumber), the services (StudentService), and the request itself (CancellationToken). ASP.NET Core works out which is which.

Step 4: call both​

Start the API with LocalDB's connection string set, as in Module 9, then send the requests from the .http file or with curl.exe:

Terminal
$env:SCHOOLDB_CONNECTION = "Server=(localdb)\MSSQLLocalDB;Database=SchoolDb;Trusted_Connection=True;TrustServerCertificate=True;"
dotnet run --project src/SchoolApp.Api
Terminal 2
curl.exe -i http://localhost:5080/api/students/ADM-2017-0003
curl.exe -i http://localhost:5080/api/minimal/students/ADM-2017-0003
Output (Date, Server, and Transfer-Encoding lines removed)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Response-Time-ms: 8466

{"id":3,"admissionNumber":"ADM-2017-0003","fullName":"Kabir Singh","dateOfBirth":"2011-01-30","gender":"Male","status":"Active"}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Response-Time-ms: 48

{"id":3,"admissionNumber":"ADM-2017-0003","fullName":"Kabir Singh","dateOfBirth":"2011-01-30","gender":"Male","status":"Active"}

Kabir arrives as the StudentDto from Lesson 45, from SchoolDb, through the repository from Lesson 62. The DateOnly birth date is written as "2011-01-30", with no extra code.

Example: the slow first request​

The very first student request took 8,466 ms. LocalDB had stopped to save memory and had to start again, and EF Core built its model of SchoolDb on first use (Lesson 59).

On a second run, with LocalDB already awake, the first student request took 3,127 ms and the next one 33 ms. Production servers send a warm-up request after starting, so real users never pay this cost.

Example: how the route matches​

A few more requests show how ASP.NET Core matches routes:

RequestStatusWhy
GET /API/STUDENTS/adm-2017-0003200 OK, KabirRoute matching ignores case; SchoolDb's collation ignores case too
GET /api/students404 Not FoundNo action has the route api/students without a number yet (Lesson 72)
GET /api/students/ADM-2017-0003/extra404 Not FoundAn extra path segment matches no route
POST /api/students/ADM-2017-0003405 Method Not Allowed, Allow: GETThe path exists, but only for GET

405 is a useful clue when testing: the address is right, the method is wrong. The Allow header lists the methods that would work.

Notice that /api/students without a number is a 404, not an empty list. A route either matches an action or it does not; there is no "nearly". Lesson 72 adds the list route with paging.

Example: two different 404s​

Now ask both endpoints for a student who does not exist:

Output (Date, Server, and Transfer-Encoding lines removed)
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
X-Response-Time-ms: 168

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-f46598b7a72e66c4788b6083737cbf20-ff1745c9b4a18b33-00"}
HTTP/1.1 404 Not Found
Content-Length: 0
X-Response-Time-ms: 3

Both are 404, but the controller's comes with a body. Because of [ApiController], NotFound() returned a ProblemDetails object, a standard JSON shape for errors, with a traceId that identifies the request. The minimal API returned an empty body.

Either is valid HTTP, but a client prefers one consistent error shape everywhere. Lesson 74 makes every SchoolApp error a ProblemDetails, whichever way it happens.

Example: testing each style​

A controller is an ordinary class. A unit test can create StudentsController with a StudentService built on the fake repository from Lesson 63, call GetByAdmissionNumber, and check the result, with no web server at all.

A minimal API lambda is harder to reach from a test. Teams usually move the lambda's body into a named static method, which a test can call directly.

For both styles, the most valuable tests send real HTTP requests to the whole app in memory, through the pipeline and the routes. Lesson 80 starts those tests for SchoolApp.Api, and Lesson 96 extends them to the whole API.

Example: why SchoolApp uses controllers​

ConsiderationWhy controllers fit SchoolApp
SizeAbout 60 endpoints by Module 11, grouped by resource: one class each
TeamMost existing .NET codebases use controllers, so it is the skill you need first
FeaturesModel validation, filters, and ProblemDetails behave the same for every action
StructureConstructor injection keeps each controller's dependencies in one place

Minimal APIs are an excellent choice for small services, with less code per endpoint. Both run on the same pipeline, middleware, and dependency injection, so moving between them later is a matter of moving code, not rethinking the design. The comparison endpoint is removed in Lesson 69.

Common mistakes​

  1. Business rules in controllers. Rules become hard to test and are repeated across endpoints. Fix: controllers call services; rules stay in Core.
  2. Returning entities from endpoints. Internal columns and navigation loops leak into JSON. Fix: return DTOs.
  3. Long lambdas in Program.cs. With minimal APIs, Program.cs grows into hundreds of lines. Fix: group endpoints in extension methods per resource.
  4. IActionResult everywhere. Tools lose the success type. Fix: ActionResult<T>.
  5. Mixing both styles at random. Errors and conventions differ between endpoints. Fix: choose one style for the project and write it down.

In real projects​

From 16 years of .NET projects

A team I joined had started a school API with minimal APIs in Program.cs. It was lovely at ten endpoints. At eighty, Program.cs was 2,000 lines, and every merge touched the same file.

We did not switch styles. We moved each resource's endpoints into its own static class with a MapStudentEndpoints() extension method, and Program.cs shrank to thirty lines.

The lesson was not "controllers good, minimal bad". It was that either style needs a structure decided early, before the code decides it for you.

Best practices​

  • Keep endpoints thin: read the request, call a service, return a result.
  • Return DTOs, with ActionResult<T> in controllers.
  • Choose one endpoint style per project, and document the reasons.
  • Group minimal API endpoints by resource, never in one long Program.cs.
  • Take a CancellationToken in every action that does I/O.

Recap​

  • Controllers are classes with actions; minimal APIs are functions mapped in Program.cs.
  • Both run on the same pipeline, middleware, and dependency injection.
  • [ApiController] adds attribute routing, automatic 400s, binding rules, and ProblemDetails.
  • ActionResult<T> returns either the value (200) or another result such as NotFound().
  • The first student came from SchoolDb through StudentService and the EF Core repository.
  • SchoolApp uses controllers, for size, team familiarity, and consistent behaviour.

Homework​

Library module task: In a single-file library API, add GET /api/books/{isbn} that reads a book and its available copies from SchoolDb with Dapper. Write it as a controller action and as a minimal API, and compare their 404 responses for an unknown ISBN.

A correct result looks like (status, type, and body lines):

Output
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"isbn":"9788173711466","title":"Wings of Fire","author":"A. P. J. Abdul Kalam","availableCopies":2}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"isbn":"9788173711466","title":"Wings of Fire","author":"A. P. J. Abdul Kalam","availableCopies":2}
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-eef366993a3e0744791678b7838b7c51-6d45e1ba57bbc85c-00"}
Show the solution
library-assignment/068-library-controller.cs
// Library homework, Lesson 68: one book from SchoolDb, as a controller action and as a minimal API.
// Run with: dotnet run library-assignment/068-library-controller.cs
#:project ../src/SchoolApp.Data/SchoolApp.Data.csproj
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using Dapper;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using SchoolApp.Data;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton(SqlConnectionFactory.FromEnvironment());
builder.Services.AddSingleton<BookCatalog>();
WebApplication app = builder.Build();

app.MapControllers();

// The minimal API version of the same endpoint.
app.MapGet("/api/minimal/books/{isbn}", async (string isbn, BookCatalog catalog) =>
await catalog.FindAsync(isbn) is Book book ? Results.Ok(book) : Results.NotFound());

app.Run("http://localhost:5068");

[ApiController]
[Route("api/books")]
public class BooksController : ControllerBase
{
private readonly BookCatalog _catalog;

public BooksController(BookCatalog catalog)
{
_catalog = catalog;
}

[HttpGet("{isbn}")]
public async Task<ActionResult<Book>> GetByIsbn(string isbn)
{
Book? book = await _catalog.FindAsync(isbn);
return book is null ? NotFound() : book;
}
}

public record Book(string Isbn, string Title, string Author, int AvailableCopies);

public class BookCatalog
{
private readonly SqlConnectionFactory _connections;

public BookCatalog(SqlConnectionFactory connections)
{
_connections = connections;
}

public async Task<Book?> FindAsync(string isbn)
{
await using SqlConnection connection = _connections.Create();
return await connection.QuerySingleOrDefaultAsync<Book>("""
SELECT b.Isbn, b.Title, b.Author,
(SELECT COUNT(*) FROM dbo.BookCopies AS c WHERE c.BookId = b.Id AND c.Status = 'Available') AS AvailableCopies
FROM dbo.Books AS b
WHERE b.Isbn = @Isbn AND b.IsDeleted = 0;
""", new { Isbn = new DbString { Value = isbn, IsAnsi = true, Length = 13 } });
}
}

AddSingleton registers one SqlConnectionFactory and one BookCatalog for the whole app; both the controller and the minimal endpoint receive them. Lesson 69 explains when one shared object is right.

Quiz​

  1. Where does a minimal API endpoint get StudentService from?

    1. A constructor
    2. A lambda parameter, filled from the registered services
    3. A static field
    4. The route
    Show answer

    Answer: B. A lambda parameter, filled from the registered services ASP.NET Core sees StudentService is a registered service and passes it in.

  2. What does returning a StudentDto from an ActionResult<StudentDto> action produce?

    1. 204 No Content
    2. 200 OK with the DTO as JSON
    3. 201 Created
    4. An error
    Show answer

    Answer: B. 200 OK with the DTO as JSON The value is wrapped in Ok() automatically.

  3. Why did the controller's 404 have a JSON body?

    1. A bug
    2. [ApiController] turns error results into ProblemDetails
    3. The minimal API failed
    4. curl added it
    Show answer

    Answer: B. [ApiController] turns error results into ProblemDetails ProblemDetails is a standard error shape with a traceId.

  4. Why was the first student request so slow?

    1. Controllers are slow
    2. LocalDB was starting and EF Core was building its model
    3. The DTO was large
    4. HTTPS redirection
    Show answer

    Answer: B. LocalDB was starting and EF Core was building its model Later requests took a few milliseconds.

  5. What should a controller action contain?

    1. Business rules
    2. SQL queries
    3. Reading the request, calling a service, returning a result
    4. Entity configuration
    Show answer

    Answer: C. Reading the request, calling a service, returning a result Rules stay in Core and data access in Data.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.

Fresher (0-2 yrs)​

F What is the difference between a controller and a minimal API endpoint? concept

A controller is a class deriving from ControllerBase, with action methods routed by attributes and services injected through its constructor. A minimal API endpoint is a function mapped in code with app.MapGet and similar, receiving services as parameters. Both run on the same pipeline.

See the example · Permalink

F What is a route parameter, and how does it reach the action? concept

A part of the route template in braces, such as {admissionNumber} in api/students/{admissionNumber}. ASP.NET Core takes that part of the URL and passes it to the action parameter with the same name, converting the type if needed.

See the example · Permalink

F What does 405 Method Not Allowed mean, and how is it different from 404? concept

405 means the path exists but not for that HTTP method, such as POST to a GET-only route; the Allow header lists the methods that work. 404 means no route matches the path at all.

See the example · Permalink

Mid (3-5 yrs)​

M What does the [ApiController] attribute do? concept

It makes attribute routing required, returns 400 automatically when model validation fails, applies binding source rules (complex types from the body), and turns error results such as NotFound() into ProblemDetails JSON. Without it, a controller behaves like an MVC controller.

See the example · Permalink

M Why return ActionResult<T> instead of IActionResult? coding

ActionResult<T> lets the action return either a T, which becomes 200 OK, or another result such as NotFound(), with less code. It also keeps the success type visible to tools, so the OpenAPI document describes the response body. IActionResult loses that type information.

See the example · Permalink

M Why did the controller and the minimal API return different 404 responses for the same missing student? scenario

[ApiController] converts NotFound() into a ProblemDetails body with type, title, status, and traceId, while the minimal API's Results.NotFound() sends an empty body. To make errors consistent, register ProblemDetails for the whole app so every error uses the same shape.

See the example · Permalink

Senior (6-9 yrs)​

S What belongs in a controller action, and what does not? design

The action reads the request, calls a service, and turns the result into an HTTP response, such as 200 with a DTO or 404. Business rules, SQL, and entity mapping do not belong there: they go in the core services and the data layer, where they can be tested without HTTP.

How to answer: Describe thin controllers and the reasons: testability, reuse across endpoints and other hosts, and keeping HTTP concerns in one layer. Give a concrete example of a rule that would be duplicated.

See the example · Permalink

S A minimal API project's Program.cs has grown to 2,000 lines. How do you restructure it? scenario

Move each resource's endpoints into its own static class with an extension method such as MapStudentEndpoints(this IEndpointRouteBuilder app), using MapGroup("/api/students") for the shared prefix and settings. Move handler bodies into named methods so they can be tested. Program.cs then only calls one Map method per resource.

How to answer: Give the structure, the grouping feature, and how it improves merges and tests; note that switching to controllers is not required to fix it.

See the example · Permalink

Architect / Lead (10+ yrs)​

A How would you choose between controllers and minimal APIs for a new product, and how would you keep the choice reversible? design

Weigh the number of endpoints, team experience, the need for filters and conventions, and existing code. Controllers suit large APIs and teams used to them; minimal APIs suit small focused services. Keep endpoints thin and rules in services, use one error format and one validation approach, and the endpoint layer can be rewritten without touching the rest.

How to answer: Make it a decision with criteria and a written record, and show that a layered design limits the cost of changing it later.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-68-controllers-minimal-apis (code repository coming soon)
  • Files: src/SchoolApp.Api/Controllers/StudentsController.cs, src/SchoolApp.Api/Program.cs, src/SchoolApp.Api/SchoolApp.Api.http, library-assignment/068-library-controller.cs
  • Video: coming soon

What's next​

Three lines in Program.cs made a StudentService appear in the controller. Next: how dependency injection works, the three lifetimes, and the mistake that makes data leak between requests. Lesson 69: Dependency injection

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.