ASP.NET Core Minimal APIs

Build a JSON web API with ASP.NET Core minimal APIs: map GET, POST, PUT and DELETE endpoints, bind route, query and body values, return proper status codes with Results, validate input, and use dependency injection.

Lesson 25 of 43Advanced16 min read

Web APIs are how modern apps talk to each other. A mobile app asks an API for a user's orders, a website sends a new booking, another company's system checks stock levels. ASP.NET Core is Microsoft's framework for building web apps and APIs in C#, and it's one of the fastest web frameworks available on any platform.

Minimal APIs are the simplest way to build an API with ASP.NET Core. Instead of controller classes and lots of setup, you map a URL and HTTP method straight to a lambda: app.MapGet("/products", () => products);. ASP.NET Core takes care of routing the request, reading values from the URL and body, turning your return value into JSON, and sending the right status code.

This lesson brings the whole course together: records for data, lambdas for handlers, async for I/O, interfaces and dependency injection for services, and pattern matching and nullability for validation. You'll build a small but complete products API with create, read, update and delete operations, proper status codes and input validation.

Your first API

Terminal
dotnet new web -o ProductsApi
cd ProductsApi
dotnet run
C#
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Products API is running");
app.MapGet("/time", () => new { now = DateTime.UtcNow });

app.Run();
  • CreateBuilder sets up configuration, logging and services; Build creates the app.
  • MapGet(path, handler) responds to GET requests at that path.
  • Returning a string sends text; returning an object or record sends JSON, with camelCase property names.
  • app.Run() starts the web server. The terminal shows the address, such as http://localhost:5000.

Routes, query strings and bodies

Handler parameters are filled in automatically from the request:

C#
app.MapGet("/products/{id:int}", (int id) => ...);           // from the route
app.MapGet("/search", (string? q, int page = 1) => ...);     // from the query string
app.MapPost("/products", (NewProduct input) => ...);          // from the JSON body
  • {id:int} is a route constraint: /products/abc won't match, so it returns 404 instead of an error.
  • Simple types not in the route come from the query string: /search?q=lamp&page=2.
  • Complex types such as records are read from the JSON body of POST and PUT requests.
  • Services registered with dependency injection can also be parameters, and so can CancellationToken.

Status codes with Results

A good API returns the right HTTP status code. TypedResults (or Results) creates them:

MethodStatusWhen
TypedResults.Ok(value)200Success with data
TypedResults.Created(url, value)201A new item was created
TypedResults.NoContent()204Success with nothing to return
TypedResults.BadRequest(errors)400The input was invalid
TypedResults.NotFound()404The item doesn't exist
TypedResults.ValidationProblem(dict)400Validation errors in a standard format

Route groups keep related endpoints together: var products = app.MapGroup("/products"); then products.MapGet("/", ...).

Services and dependency injection

Register services on the builder, then ask for them as handler parameters:

C#
builder.Services.AddSingleton<IProductStore, InMemoryProductStore>();

app.MapGet("/products", (IProductStore store) => store.All());
  • Singleton services are created once for the whole app; scoped services are created once per request (typical for database contexts); transient services are created every time they're needed.
  • Endpoints depend on the interface, so the in-memory store can later be replaced with an Entity Framework version without touching the endpoints.

C# Advanced Syntax

Minimal API syntax

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IStore, MemoryStore>();
var app = builder.Build();

var api = app.MapGroup("/api/items");
api.MapGet("/", (IStore s) => s.All());
api.MapGet("/{id:int}", (int id, IStore s) =>
    s.Find(id) is { } item ? Results.Ok(item) : Results.NotFound());
api.MapPost("/", (NewItem input, IStore s) => { var i = s.Add(input); return Results.Created($"/api/items/{i.Id}", i); });
api.MapDelete("/{id:int}", (int id, IStore s) => s.Remove(id) ? Results.NoContent() : Results.NotFound());

app.Run();

C# Advanced Summary

  • WebApplication.CreateBuilder and Build create the app; app.Run starts it.
  • MapGet, MapPost, MapPut and MapDelete map HTTP methods and paths to handlers.
  • Parameters bind from the route, query string, JSON body and registered services.
  • TypedResults return proper status codes: 200, 201, 204, 400 and 404.
  • Register services with AddSingleton, AddScoped or AddTransient and depend on interfaces.

C# Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A complete products API

C#
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ProductStore>();
var app = builder.Build();

var products = app.MapGroup("/products");

products.MapGet("/", (ProductStore store, string? search) =>
    store.All(search));

products.MapGet("/{id:int}", (int id, ProductStore store) =>
    store.Find(id) is { } p ? Results.Ok(p) : Results.NotFound());

products.MapPost("/", (NewProduct input, ProductStore store) =>
{
    var errors = Validate(input);
    if (errors.Count > 0) return Results.ValidationProblem(errors);
    var created = store.Add(input);
    return Results.Created($"/products/{created.Id}", created);
});

products.MapDelete("/{id:int}", (int id, ProductStore store) =>
    store.Remove(id) ? Results.NoContent() : Results.NotFound());

app.Run();

Dictionary<string, string[]> Validate(NewProduct p)
{
    var errors = new Dictionary<string, string[]>();
    if (string.IsNullOrWhiteSpace(p.Name)) errors["name"] = ["Name is required."];
    if (p.Price is <= 0 or > 10_000) errors["price"] = ["Price must be between 0 and 10,000."];
    return errors;
}

record NewProduct(string Name, decimal Price);
record Product(int Id, string Name, decimal Price);

class ProductStore
{
    private readonly List<Product> items = [new(1, "Mug", 8.50m), new(2, "Lamp", 24.00m)];
    private int nextId = 3;

    public IEnumerable<Product> All(string? search) =>
        search is null ? items : items.Where(p => p.Name.Contains(search, StringComparison.OrdinalIgnoreCase));
    public Product? Find(int id) => items.FirstOrDefault(p => p.Id == id);
    public Product Add(NewProduct input)
    {
        var product = new Product(nextId++, input.Name.Trim(), input.Price);
        items.Add(product);
        return product;
    }
    public bool Remove(int id) => items.RemoveAll(p => p.Id == id) > 0;
}
Output
GET  /products             200 [{"id":1,"name":"Mug","price":8.50},{"id":2,"name":"Lamp","price":24.00}]
GET  /products?search=lam 200 [{"id":2,"name":"Lamp","price":24.00}]
GET  /products/9           404
POST /products {"name":"Pen","price":1.2}   201 {"id":3,"name":"Pen","price":1.2}
POST /products {"name":"","price":-1}       400 errors for name and price
DELETE /products/1         204
Line-by-line explanation
  1. Line 2The store is registered once and shared by every request.
  2. Line 5A route group gives every endpoint the /products prefix.
  3. Lines 7 to 8search comes from the query string and is optional because it's nullable.
  4. Lines 10 to 11The int constraint and a property pattern return 200 with the product or 404.
  5. Lines 15 to 16Invalid input gets a 400 response in the standard validation format.
  6. Line 18Created returns 201 with the new item's address.
  7. Lines 21 to 22Delete returns 204 when something was removed.
  8. Lines 34 to 35Records define the request and response shapes.
Example 2

Async endpoints and configuration

C#
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<WeatherClient>(client =>
    client.BaseAddress = new Uri(builder.Configuration["Weather:BaseUrl"] ?? "https://api.example.com/"));
var app = builder.Build();

app.MapGet("/weather/{city}", async (string city, WeatherClient weather, CancellationToken ct) =>
{
    if (city.Length > 60) return Results.BadRequest("City name is too long.");
    var forecast = await weather.GetForecastAsync(city, ct);
    return forecast is null ? Results.NotFound() : Results.Ok(forecast);
});

app.Run();

record Forecast(string City, int TemperatureC, string Summary);

class WeatherClient(HttpClient http)
{
    public Task<Forecast?> GetForecastAsync(string city, CancellationToken ct) =>
        http.GetFromJsonAsync<Forecast>($"forecast/{Uri.EscapeDataString(city)}", ct);
}
Output
GET /weather/Leeds   200 {"city":"Leeds","temperatureC":12,"summary":"Light rain"}
GET /weather/(61 characters)   400 "City name is too long."
Line-by-line explanation
  1. Lines 2 to 3AddHttpClient registers a typed client, with its address read from appsettings.json.
  2. Line 6The handler is async; ASP.NET Core supplies the request's cancellation token.
  3. Line 8Input is validated before any work is done.
  4. Line 9If the caller disconnects, the token cancels the outgoing request too.
  5. Line 20EscapeDataString makes any city name safe to put in a URL.

Common C# Advanced Mistakes

Returning 200 for everything

Don't
app.MapGet("/products/{id}", (int id) => store.Find(id));   // 200 with "null" when missing
Do
app.MapGet("/products/{id:int}", (int id) =>
    store.Find(id) is { } p ? Results.Ok(p) : Results.NotFound());

Blocking calls in a handler

Don't
app.MapGet("/data", () => http.GetStringAsync(url).Result);   // ties up a server thread
Do
app.MapGet("/data", async () => await http.GetStringAsync(url));

Exposing internal objects

Don't
app.MapGet("/users/{id}", (int id) => db.Users.Find(id));     // also returns PasswordHash
Do
app.MapGet("/users/{id:int}", (int id) =>
    db.Users.Find(id) is { } u ? Results.Ok(new UserDto(u.Id, u.Name)) : Results.NotFound());

C# Advanced in Real Projects

Minimal APIs in production

Companies use minimal APIs for microservices, backends for mobile apps and internal tools. Real projects add Entity Framework Core for the database, authentication with JWT bearer tokens, OpenAPI documentation so other teams can explore the API, rate limiting, health checks and structured logging, all configured on the same builder. The patterns in this lesson, records for shapes, validated input, correct status codes and services behind interfaces, scale to large applications.

C# Advanced FAQ

What is the difference between minimal APIs and controllers in ASP.NET Core?

Minimal APIs map each endpoint directly to a handler with MapGet, MapPost and similar methods, with very little ceremony. Controllers group endpoints as methods in classes that inherit from ControllerBase and use attributes for routing. Both run on the same framework and perform well; minimal APIs suit small and medium services, while controllers are common in larger existing codebases.

How do I change the port of an ASP.NET Core app?

For local development, edit the applicationUrl in Properties/launchSettings.json. You can also pass --urls on the command line, as in dotnet run --urls http://localhost:5050, or set the ASPNETCORE_URLS environment variable, which is common in containers.

How do I return a 404 from a minimal API?

Return TypedResults.NotFound() or Results.NotFound() from the handler. A common pattern is store.Find(id) is { } product ? TypedResults.Ok(product) : TypedResults.NotFound(). When a handler can return different result types, declare its return type as Results<Ok<Product>, NotFound> so the possible responses are documented.

How do I read a JSON request body in a minimal API?

Add a parameter of a complex type, such as a record, to a MapPost or MapPut handler: app.MapPost("/products", (NewProduct input) => ...). ASP.NET Core deserialises the JSON body into it automatically. If the body is missing or isn't valid JSON for that type, the framework responds with 400 Bad Request before your handler runs.

What is the difference between AddSingleton, AddScoped and AddTransient?

AddSingleton creates one instance shared by the whole app for its lifetime. AddScoped creates one instance per HTTP request, which suits database contexts. AddTransient creates a new instance every time the service is requested. Never inject a scoped service into a singleton, because the singleton would keep using one request's object forever.

C# Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the line to handle GET requests to /hello.

app.("/hello", () => "Hello!");
Multiple choiceeasy

Which status code means a new resource was created?

Multiple choicemedium

Where does int page come from in app.MapGet("/search", (string q, int page) => ...)?

Predict the outputmedium

A request is sent to /products/abc. What happens with this route?

app.MapGet("/products/{id:int}", (int id) => id);
Multiple choicehard

Which service lifetime is usually right for a database context?

C# Advanced Quiz

5 questions to check what you learned.

  1. What does returning a record from a minimal API handler send?

    • XML
    • Plain text
    • JSON
    • HTML
  2. What does MapGroup("/products") do?

    • Creates a database table
    • Starts a new server
    • Registers a service
    • Groups endpoints under a shared route prefix
  3. How does a handler get a registered service?

    • new it up inside the handler
    • Read a global variable
    • Declare it as a handler parameter
    • Use reflection
  4. Which result should an endpoint return when the requested item doesn't exist?

    • Ok(null)
    • BadRequest()
    • NoContent()
    • NotFound()
  5. Why accept a CancellationToken in an async endpoint?

    • Work can stop when the client disconnects, saving server resources
    • It's required
    • It speeds up JSON
    • It validates input