Controllers vs Minimal APIs: The First SMS Endpoint – Web API | Lesson 68
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
StudentServiceand 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.
| Controllers | Minimal APIs | |
|---|---|---|
| Endpoint is | A method on a class deriving from ControllerBase | A lambda or method passed to app.MapGet and friends |
| Route from | [Route] and [HttpGet("...")] attributes | The MapGet("/path", ...) call |
| Services arrive | Through the controller's constructor | As lambda parameters |
| Results | ActionResult<T>: NotFound(), Ok(...), or the value | Results.Ok(...), Results.NotFound() |
| Grouping | One class per resource | MapGroup("/api/students"), or extension methods |
| Filters, model validation | Built in for years | Endpoint filters; built-in validation from .NET 10 |
Example: what [ApiController] adds
[ApiController] on a controller switches on API-friendly behaviour:
| Behaviour | Effect |
|---|---|
| Attribute routing required | Every action must have a route from attributes |
| Automatic 400 | Invalid input is rejected before the action runs (Lesson 73) |
| Binding source rules | Complex types come from the body, simple ones from the route or query |
| ProblemDetails for errors | Error 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
dotnet add src/SchoolApp.Api reference src/SchoolApp.Core src/SchoolApp.Data
<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
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
// 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:
$env:SCHOOLDB_CONNECTION = "Server=(localdb)\MSSQLLocalDB;Database=SchoolDb;Trusted_Connection=True;TrustServerCertificate=True;"
dotnet run --project src/SchoolApp.Api
curl.exe -i http://localhost:5080/api/students/ADM-2017-0003
curl.exe -i http://localhost:5080/api/minimal/students/ADM-2017-0003
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:
| Request | Status | Why |
|---|---|---|
GET /API/STUDENTS/adm-2017-0003 | 200 OK, Kabir | Route matching ignores case; SchoolDb's collation ignores case too |
GET /api/students | 404 Not Found | No action has the route api/students without a number yet (Lesson 72) |
GET /api/students/ADM-2017-0003/extra | 404 Not Found | An extra path segment matches no route |
POST /api/students/ADM-2017-0003 | 405 Method Not Allowed, Allow: GET | The 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:
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
| Consideration | Why controllers fit SchoolApp |
|---|---|
| Size | About 60 endpoints by Module 11, grouped by resource: one class each |
| Team | Most existing .NET codebases use controllers, so it is the skill you need first |
| Features | Model validation, filters, and ProblemDetails behave the same for every action |
| Structure | Constructor 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
- Business rules in controllers. Rules become hard to test and are repeated across endpoints. Fix: controllers call services; rules stay in Core.
- Returning entities from endpoints. Internal columns and navigation loops leak into JSON. Fix: return DTOs.
- Long lambdas in
Program.cs. With minimal APIs,Program.csgrows into hundreds of lines. Fix: group endpoints in extension methods per resource. IActionResulteverywhere. Tools lose the success type. Fix:ActionResult<T>.- 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
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
CancellationTokenin 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 asNotFound().- The first student came from SchoolDb through
StudentServiceand 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):
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 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
Where does a minimal API endpoint get StudentService from?
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.
What does returning a StudentDto from an ActionResult<StudentDto> action produce?
Show answer
Answer: B. 200 OK with the DTO as JSON The value is wrapped in Ok() automatically.
Why did the controller's 404 have a JSON body?
Show answer
Answer: B. [ApiController] turns error results into ProblemDetails ProblemDetails is a standard error shape with a traceId.
Why was the first student request so slow?
Show answer
Answer: B. LocalDB was starting and EF Core was building its model Later requests took a few milliseconds.
What should a controller action contain?
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.