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
dotnet new web -o ProductsApi
cd ProductsApi
dotnet runvar builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Products API is running");
app.MapGet("/time", () => new { now = DateTime.UtcNow });
app.Run();CreateBuildersets up configuration, logging and services;Buildcreates 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 ashttp://localhost:5000.
Routes, query strings and bodies
Handler parameters are filled in automatically from the request:
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/abcwon'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:
| Method | Status | When |
|---|---|---|
TypedResults.Ok(value) | 200 | Success with data |
TypedResults.Created(url, value) | 201 | A new item was created |
TypedResults.NoContent() | 204 | Success with nothing to return |
TypedResults.BadRequest(errors) | 400 | The input was invalid |
TypedResults.NotFound() | 404 | The item doesn't exist |
TypedResults.ValidationProblem(dict) | 400 | Validation 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:
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.
A complete products API
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;
}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 204Line-by-line explanation
- Line 2The store is registered once and shared by every request.
- Line 5A route group gives every endpoint the /products prefix.
- Lines 7 to 8search comes from the query string and is optional because it's nullable.
- Lines 10 to 11The int constraint and a property pattern return 200 with the product or 404.
- Lines 15 to 16Invalid input gets a 400 response in the standard validation format.
- Line 18Created returns 201 with the new item's address.
- Lines 21 to 22Delete returns 204 when something was removed.
- Lines 34 to 35Records define the request and response shapes.
Async endpoints and configuration
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);
}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
- Lines 2 to 3AddHttpClient registers a typed client, with its address read from appsettings.json.
- Line 6The handler is async; ASP.NET Core supplies the request's cancellation token.
- Line 8Input is validated before any work is done.
- Line 9If the caller disconnects, the token cancels the outgoing request too.
- Line 20EscapeDataString makes any city name safe to put in a URL.
Common C# Advanced Mistakes
Returning 200 for everything
app.MapGet("/products/{id}", (int id) => store.Find(id)); // 200 with "null" when missingapp.MapGet("/products/{id:int}", (int id) =>
store.Find(id) is { } p ? Results.Ok(p) : Results.NotFound());Blocking calls in a handler
app.MapGet("/data", () => http.GetStringAsync(url).Result); // ties up a server threadapp.MapGet("/data", async () => await http.GetStringAsync(url));Exposing internal objects
app.MapGet("/users/{id}", (int id) => db.Users.Find(id)); // also returns PasswordHashapp.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.
Complete the line to handle GET requests to /hello.
app.("/hello", () => "Hello!");
Which status code means a new resource was created?
Where does int page come from in app.MapGet("/search", (string q, int page) => ...)?
A request is sent to /products/abc. What happens with this route?
app.MapGet("/products/{id:int}", (int id) => id);
Which service lifetime is usually right for a database context?
C# Advanced Quiz
5 questions to check what you learned.
What does returning a record from a minimal API handler send?
- XML
- Plain text
- JSON
- HTML
What does MapGroup("/products") do?
- Creates a database table
- Starts a new server
- Registers a service
- Groups endpoints under a shared route prefix
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
Which result should an endpoint return when the requested item doesn't exist?
- Ok(null)
- BadRequest()
- NoContent()
- NotFound()
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