EthicsComputerCourses
Join training +91 90532 10052

Minimal APIs

ASP.NET Core · Lesson 12 of 29 Intermediate 8 min read Updated

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.

SourceWhen it is usedExample parameter
RouteThe name matches a {parameter} in the templateint id in /appointments/{id:int}
Query stringA simple type (number, string, date) that is not in the routeDateOnly? date for ?date=2026-11-20
HeaderMarked with [FromHeader][FromHeader(Name = "X-Branch")] string branch
Body (JSON)A complex type such as a record, usually on POST or PUTNewAppointment input
FormMarked with [FromForm]; needs antiforgery protection[FromForm] string name, IFormFile photo
ServicesThe type is registered in DIAppointmentStore store
Special typesAlways availableHttpContext, 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:

C#
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:

C#
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:

C#
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:

C#
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:

Terminal
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"}'
Output
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):

JSON
{
  "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:

C#
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 null for "not found". Return TypedResults.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.
  • TypedResults with Results<T1, T2> documents every outcome and makes handlers easy to unit test.
  • Organise endpoints with MapGroup inside 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
C#
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?

2. What is the main advantage of TypedResults over Results?

3. What does builder.Services.AddValidation() do in .NET 10?

4. How many parameters of a minimal API handler can be bound from the JSON body?

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