Minimal APIs
Minimal APIs let you build an HTTP API with very little ceremony: map a URL and a method to a function, and ASP.NET Core takes care of reading the request and writing JSON. They are the default in the webapi template, they are fast, and with a little organisation they scale from a two-endpoint demo to a large service.
The C# course's minimal API lesson showed the basics. Here you will go further and build an appointments API for a clinic: how each parameter gets its value, returning precise results with TypedResults, grouping and organising endpoints, adding endpoint filters, and using the built-in validation that arrived in .NET 10.
Where parameters get their values
A handler is any method or lambda. ASP.NET Core inspects its parameters at start-up and decides where each value comes from. This is called parameter binding.
| Source | When it is used | Example parameter |
|---|---|---|
| Route | The name matches a {parameter} in the template | int id in /appointments/{id:int} |
| Query string | A simple type (number, string, date) that is not in the route | DateOnly? date for ?date=2026-11-20 |
| Header | Marked with [FromHeader] | [FromHeader(Name = "X-Branch")] string branch |
| Body (JSON) | A complex type such as a record, usually on POST or PUT | NewAppointment input |
| Form | Marked with [FromForm]; needs antiforgery protection | [FromForm] string name, IFormFile photo |
| Services | The type is registered in DI | AppointmentStore store |
| Special types | Always available | HttpContext, HttpRequest, CancellationToken, ClaimsPrincipal |
Only one parameter can come from the JSON body. When a handler needs many query values, group them with [AsParameters], which binds a whole record's properties as if they were separate parameters:
public record AppointmentFilter(int? DoctorId, DateOnly? Date, int Page = 1);
app.MapGet("/api/appointments", ([AsParameters] AppointmentFilter filter, AppointmentStore store) =>
store.Search(filter.DoctorId, filter.Date, filter.Page));A request to /api/appointments?doctorId=2&date=2026-11-20 fills DoctorId and Date, and Page keeps its default of 1. Nullable types make query values optional; a non-nullable int doctorId would make the value required, and a request without it gets 400 Bad Request.
TypedResults and precise return types
Returning an object gives 200 OK with JSON. Real endpoints also return 201, 404, 409 and so on. TypedResults creates these results with specific types, and the Results<...> union type lists every possible outcome in the method signature:
using Microsoft.AspNetCore.Http.HttpResults;
static Results<Ok<Appointment>, NotFound> GetAppointment(int id, AppointmentStore store) =>
store.Get(id) is { } found ? TypedResults.Ok(found) : TypedResults.NotFound();Compared with the Results class (Results.Ok(...), which returns the general IResult interface), TypedResults has two advantages: unit tests can check the exact type and value without running a server, and OpenAPI documentation learns automatically that this endpoint can return 200 with an Appointment or 404.
A complete, organised appointments API
Putting every endpoint in Program.cs becomes messy quickly. A common pattern is one static class per feature with an extension method that maps its endpoints. Program.cs stays short:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<AppointmentStore>();
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
var app = builder.Build();
app.MapAppointmentEndpoints();
app.Run();And AppointmentEndpoints.cs holds the feature:
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.HttpResults;
public static class AppointmentEndpoints
{
public static RouteGroupBuilder MapAppointmentEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/appointments").WithTags("Appointments");
group.MapGet("/{id:int}", GetAppointment).WithName("GetAppointment");
group.MapPost("/", CreateAppointment);
group.MapDelete("/{id:int}", (int id, AppointmentStore store) =>
store.Remove(id) ? Results.NoContent() : Results.NotFound());
return group;
}
static Results<Ok<Appointment>, NotFound> GetAppointment(int id, AppointmentStore store) =>
store.Get(id) is { } found ? TypedResults.Ok(found) : TypedResults.NotFound();
static Results<Created<Appointment>, ProblemHttpResult> CreateAppointment(
NewAppointment input, AppointmentStore store)
{
if (!store.TryAdd(input, out Appointment? created))
{
return TypedResults.Problem(
$"Doctor {input.DoctorId} is already booked at {input.Time:HH:mm} on {input.Date:yyyy-MM-dd}.",
statusCode: StatusCodes.Status409Conflict);
}
return TypedResults.Created($"/api/appointments/{created.Id}", created);
}
}
public record NewAppointment(
[Range(1, 500)] int DoctorId,
[Required, StringLength(60)] string PatientName,
DateOnly Date,
TimeOnly Time);
public record Appointment(int Id, int DoctorId, string PatientName, DateOnly Date, TimeOnly Time);AppointmentStore is a small thread-safe in-memory class (like the one in the C# course) with Get, TryAdd and Remove; you will replace it with EF Core later. Try it from Git Bash or a Linux shell:
curl -i -X POST http://localhost:5080/api/appointments -H "Content-Type: application/json" -d '{"doctorId":2,"patientName":"Kabir Singh","date":"2026-11-20","time":"10:30"}'HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/appointments/1
{"id":1,"doctorId":2,"patientName":"Kabir Singh","date":"2026-11-20","time":"10:30:00"}Some headers are left out. Notice that DateOnly and TimeOnly travel as ISO strings, and that the time came back as 10:30:00. Sending the same request again returns 409 Conflict with a ProblemDetails body like this (the traceId differs every time):
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Conflict",
"status": 409,
"detail": "Doctor 2 is already booked at 10:30 on 2026-11-20.",
"traceId": "00-6c1f0e4b2a9d4c3e8f7a6b5c4d3e2f10-9a8b7c6d5e4f3a2b-00"
}Built-in validation (.NET 10)
With the NewAppointment record above, a body with "doctorId": 0 or an empty patientName never reaches CreateAppointment. Validation discovers types through a source generator in the project that calls AddValidation, so request types defined in another class library need that library to call it too. This validation system is for minimal APIs and Blazor; MVC controllers and Razor Pages keep their own model validation, covered in Models, Binding & Validation.
Endpoint filters
An endpoint filter runs code before and after a handler, a little like middleware but only for chosen endpoints, and with access to the handler's arguments. This one rejects bookings in the past:
group.MapPost("/", CreateAppointment)
.AddEndpointFilter(async (context, next) =>
{
var input = context.GetArgument<NewAppointment>(0);
if (input.Date < DateOnly.FromDateTime(DateTime.Today))
{
return TypedResults.Problem("Appointments cannot be booked in the past.",
statusCode: StatusCodes.Status400BadRequest);
}
return await next(context);
});GetArgument<T>(0) reads the first handler argument, already bound from the JSON body. Returning a result without calling next stops the request, exactly like short-circuiting middleware. Filters added to a route group apply to every endpoint in the group.
Streaming updates
Some endpoints should keep sending data, such as the "now serving" token at a clinic counter. .NET 10 adds TypedResults.ServerSentEvents(...), which streams items from an IAsyncEnumerable<T> using the browser's Server-Sent Events format. You will compare it with SignalR in SignalR Overview.
Common mistakes
- Two complex parameters on one POST. Only one can be the body; combine them into one request record.
- Expecting a query value to be optional when its type is not nullable. Use
int?or give a default. - Returning
nullfor "not found". ReturnTypedResults.NotFound()so clients get a clear 404. - Forgetting
Content-Type: application/json. Body binding then fails with 415 Unsupported Media Type. - Keeping all endpoints in
Program.cs. Group them by feature in extension methods.
Key points
- Parameters bind from the route, query string, headers, JSON body, form, DI services or special types; only one body parameter is allowed.
[AsParameters]binds a record's properties as separate parameters, useful for search filters.TypedResultswithResults<T1, T2>documents every outcome and makes handlers easy to unit test.- Organise endpoints with
MapGroupinside static extension methods, one file per feature. - .NET 10's
AddValidation()validates data annotations before the handler; endpoint filters add custom per-endpoint checks.
Exercise
Add an endpoint PUT /api/appointments/{id:int}/time that moves an appointment to a new time. It reads a JSON body {"time": "11:00"} into a record NewTime(TimeOnly Time), returns Ok<Appointment> with the updated appointment, NotFound when the ID does not exist, and a 409 ProblemDetails when the doctor is already booked at the new time. Assume AppointmentStore has a method MoveResult Move(int id, TimeOnly time, out Appointment? updated) where MoveResult is an enum with Moved, Missing and Clash.
Show solution
group.MapPut("/{id:int}/time", MoveAppointment);
static Results<Ok<Appointment>, NotFound, ProblemHttpResult> MoveAppointment(
int id, NewTime body, AppointmentStore store)
{
return store.Move(id, body.Time, out Appointment? updated) switch
{
MoveResult.Moved => TypedResults.Ok(updated!),
MoveResult.Missing => TypedResults.NotFound(),
_ => TypedResults.Problem($"The doctor is already booked at {body.Time:HH:mm}.",
statusCode: StatusCodes.Status409Conflict)
};
}
public record NewTime(TimeOnly Time);id binds from the route and NewTime from the JSON body, so {"time": "11:00"} becomes a TimeOnly. The three-way Results<...> type tells readers, tests and OpenAPI exactly which responses the endpoint can produce. The ! after updated tells the compiler the value is not null when the result is Moved.
Quick Quiz
1. In app.MapGet("/api/doctors/{id:int}", (int id, string? city) => ...), where does city come from?
city is a simple type that is not in the route template, so it binds from the query string.
2. What is the main advantage of TypedResults over Results?
TypedResults methods return concrete types such as Ok<T>, which tests and documentation can inspect.
3. What does builder.Services.AddValidation() do in .NET 10?
Invalid requests are rejected with 400 and a validation ProblemDetails before your code runs.
4. How many parameters of a minimal API handler can be bound from the JSON body?
Only one parameter can read the request body; combine data into one request type.
Learn ASP.NET Core with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
