C# · lesson 24 of 26
ASP.NET Core Basics
ASP.NET Core by example: minimal APIs, a CRUD API driven with curl, controllers and validation, middleware order, exception handling, DI and config.
By SkillByExample · Updated
A Minimal API
An ASP.NET Core app is a console program that builds a web host, maps URLs to handlers, and runs. Create one with dotnet new web -n MyApi; this is the whole Program.cs:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello from ASP.NET Core");
app.MapGet("/hello/{name}", (string name) => new { Greeting = $"Hello, {name}!", name.Length });
app.MapGet("/add", (int a, int b) => a + b);
app.Run();
Run it with dotnet run --urls http://localhost:5080, then call it from a second terminal (every example in this lesson is run the same way):
$ curl -s localhost:5080/
Hello from ASP.NET Core
$ curl -s localhost:5080/hello/Ada
{"greeting":"Hello, Ada!","length":3}
$ curl -s "localhost:5080/add?a=2&b=40"
42
$ curl -s -o /dev/null -w "%{http_code}\n" "localhost:5080/add?a=2&b=x"
400
server console (dotnet run):
Now listening on: http://localhost:5080
Application started. Press Ctrl+C to shut down.
A handler returning a string is sent as plain text; returning an object is serialized to JSON with camelCase names (greeting, length). Parameters bind by name: {name} from the route, a and b from the query string. When binding fails — b=x is not an int — the framework answers 400 Bad Request without running the handler.
A CRUD API
MapGroup shares a route prefix; Results helpers produce the right status codes. The repository is registered in DI as a singleton, so its data lives as long as the app.
using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ProductStore>();
var app = builder.Build();
var products = app.MapGroup("/api/products");
products.MapGet("/", (ProductStore store) => store.All());
products.MapGet("/{id:int}", (int id, ProductStore store) =>
store.Get(id) is { } p ? Results.Ok(p) : Results.NotFound());
products.MapPost("/", (CreateProduct req, ProductStore store) =>
{
var p = store.Add(req.Name, req.Price);
return Results.Created($"/api/products/{p.Id}", p);
});
products.MapPut("/{id:int}", (int id, CreateProduct req, ProductStore store) =>
store.Update(id, req.Name, req.Price) is { } p ? Results.Ok(p) : Results.NotFound());
products.MapDelete("/{id:int}", (int id, ProductStore store) =>
store.Remove(id) ? Results.NoContent() : Results.NotFound());
app.Run();
public record CreateProduct(string Name, decimal Price);
public record Product(int Id, string Name, decimal Price);
public class ProductStore
{
private readonly ConcurrentDictionary<int, Product> _items = new();
private int _nextId;
public IEnumerable<Product> All() => _items.Values.OrderBy(p => p.Id);
public Product? Get(int id) => _items.GetValueOrDefault(id);
public Product Add(string name, decimal price)
{
var p = new Product(Interlocked.Increment(ref _nextId), name, price);
_items[p.Id] = p;
return p;
}
public Product? Update(int id, string name, decimal price) =>
_items.ContainsKey(id) ? _items[id] = new Product(id, name, price) : null;
public bool Remove(int id) => _items.TryRemove(id, out _);
}
$ curl -si localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"Widget","price":9.99}' | grep -E "^HTTP|^Location|^\{"
HTTP/1.1 201 Created
Location: /api/products/1
{"id":1,"name":"Widget","price":9.99}
$ curl -s localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"Gadget","price":49.99}'
{"id":2,"name":"Gadget","price":49.99}
$ curl -s localhost:5080/api/products
[{"id":1,"name":"Widget","price":9.99},{"id":2,"name":"Gadget","price":49.99}]
$ curl -s -X PUT localhost:5080/api/products/1 -H "Content-Type: application/json" -d '{"name":"Widget v2","price":12.5}'
{"id":1,"name":"Widget v2","price":12.5}
$ curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:5080/api/products/2
204
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:5080/api/products/2
404
$ curl -s localhost:5080/api/products
[{"id":1,"name":"Widget v2","price":12.5}]
The POST returned 201 Created with a Location header pointing at the new resource — the REST convention Results.Created implements. The request body was deserialized case-insensitively ("name" bound to Name), and responses use camelCase. After the DELETE (204 No Content), fetching the same id gives 404. The :int route constraint means /api/products/abc never reaches these handlers at all. A singleton store keeps data only in memory — restart the app and it is gone; the EF Core lesson shows the database-backed version.
Controllers and Automatic Validation
Controllers group related endpoints into a class. With [ApiController], data-annotation attributes on the request type are validated before your action runs.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
public class CreateProductRequest
{
[Required, StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = "";
[Range(0.01, 100_000)]
public decimal Price { get; set; }
}
[ApiController]
[Route("api/[controller]")]
public class ProductsController(ILogger<ProductsController> logger) : ControllerBase
{
private static readonly List<(int Id, string Name, decimal Price)> Items = new() { (1, "Widget", 9.99m) };
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
var item = Items.FirstOrDefault(p => p.Id == id);
if (item == default)
{
logger.LogWarning("Product {Id} not found", id);
return NotFound();
}
return Ok(new { item.Id, item.Name, item.Price });
}
[HttpPost]
public IActionResult Create(CreateProductRequest req)
{
int id = Items.Count + 1;
Items.Add((id, req.Name, req.Price));
return CreatedAtAction(nameof(GetById), new { id }, new { id, req.Name, req.Price });
}
}
$ curl -s localhost:5080/api/products/1
{"id":1,"name":"Widget","price":9.99}
$ curl -s localhost:5080/api/products/9
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-6f8810ee970a9cc01fd068f6a2f31c4b-7fd3f7793d4f4c65-00"}
$ curl -s localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"X","price":0}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The field Name must be a string with a minimum length of 2 and a maximum length of 100."],"Price":["The field Price must be between 0.01 and 100000."]},"traceId":"00-81af276620dee4c6db553e9acbc25aad-912fa701d359b71c-00"}
$ curl -si localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"Gizmo","price":14.99}' | grep -E "^HTTP|^Location|^\{"
HTTP/1.1 201 Created
Location: http://localhost:5080/api/Products/2
{"id":2,"name":"Gizmo","price":14.99}
server console:
warn: ProductsController[0]
Product 9 not found
The route api/[controller] becomes api/products from the class name. A missing product returns 404 with a standard ProblemDetails JSON body, and the structured log line shows up on the server. The invalid POST never reached Create: [ApiController] ran the [StringLength] and [Range] checks and returned 400 with every error listed per field. CreatedAtAction built the Location URL from the GetById route.
Middleware and Its Order
Every request flows through the middleware pipeline in the order components are added; each can act before and after calling next.
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var log = app.Logger;
app.Use(async (context, next) =>
{
var sw = Stopwatch.StartNew();
log.LogInformation("1 -> {Method} {Path}", context.Request.Method, context.Request.Path);
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Elapsed-Ms"] = sw.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
await next(context);
log.LogInformation("1 <- {Status}", context.Response.StatusCode);
});
app.Use(async (context, next) =>
{
if (!context.Request.Headers.ContainsKey("X-Api-Key"))
{
log.LogInformation("2 rejected: no API key");
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
log.LogInformation("2 -> key ok");
await next(context);
log.LogInformation("2 <-");
});
app.MapGet("/data", () => { log.LogInformation("3 endpoint runs"); return new[] { 1, 2, 3 }; });
app.Run();
$ curl -si localhost:5080/data -H "X-Api-Key: secret" | grep -E "^HTTP|^X-Elapsed|^\["
HTTP/1.1 200 OK
X-Elapsed-Ms: 11
[1,2,3]
$ curl -si localhost:5080/data | grep -E "^HTTP|^X-Elapsed"
HTTP/1.1 401 Unauthorized
X-Elapsed-Ms: 0
server console (only the middleware's own log lines shown):
1 -> GET /data
2 -> key ok
3 endpoint runs
2 <-
1 <- 200
1 -> GET /data
2 rejected: no API key
1 <- 401
The server log shows the onion: middleware 1 runs first on the way in and last on the way out, wrapping everything after it. Without a key, middleware 2 short-circuits — it never calls next, so the endpoint does not run, and middleware 1 still sees the 401 on the way out. The header is set in OnStarting because once the response body has started streaming, headers can no longer be changed. This ordering is why UseExceptionHandler goes first and UseAuthentication goes before UseAuthorization.
Global Exception Handling
UseExceptionHandler catches anything the pipeline throws and turns it into a response, so clients get a consistent JSON error instead of a dropped connection or a stack trace.
using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(errorApp => errorApp.Run(async context =>
{
var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error;
context.Response.StatusCode = ex switch
{
KeyNotFoundException => StatusCodes.Status404NotFound,
ArgumentException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError,
};
await context.Response.WriteAsJsonAsync(new
{
status = context.Response.StatusCode,
error = context.Response.StatusCode == 500 ? "An unexpected error occurred." : ex?.Message,
});
}));
app.MapGet("/orders/{id:int}", (int id) =>
id == 42 ? Results.Ok(new { id, status = "Shipped" }) : throw new KeyNotFoundException($"Order {id} not found"));
app.MapGet("/qty/{n:int}", (int n) => n > 0 ? Results.Ok(n) : throw new ArgumentException("Quantity must be positive"));
app.MapGet("/boom", () => { throw new InvalidOperationException("connection string is wrong: Server=db;Password=hunter2"); });
app.Run();
$ curl -s localhost:5080/orders/42
{"id":42,"status":"Shipped"}
$ curl -s localhost:5080/orders/7
{"status":404,"error":"Order 7 not found"}
$ curl -s localhost:5080/qty/0
{"status":400,"error":"Quantity must be positive"}
$ curl -s localhost:5080/boom
{"status":500,"error":"An unexpected error occurred."}
server console (header and exception line of each logged error; other log lines omitted):
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
System.Collections.Generic.KeyNotFoundException: Order 7 not found
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
System.ArgumentException: Quantity must be positive
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
System.InvalidOperationException: connection string is wrong: Server=db;Password=hunter2
Expected failures map to meaningful status codes (404, 400) with their messages. The unexpected one becomes a generic 500: the exception message, which here contains a password, is logged on the server but not sent to the client. The same idea — a domain exception type decides the status code — is the one Error Handling in C# builds up. This run set ASPNETCORE_ENVIRONMENT=Production for the server; dotnet run normally starts in Development (from Properties/launchSettings.json), where you might choose to return more detail.
Dependency Injection Lifetimes per Request
ASP.NET Core creates a DI scope for each HTTP request. Scoped services are shared within one request and fresh for the next; singletons are shared by all.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<Counter>();
builder.Services.AddScoped<RequestContext>();
builder.Services.AddTransient<Stamp>();
var app = builder.Build();
app.MapGet("/ids", (Counter counter, RequestContext ctx1, RequestContext ctx2, Stamp s1, Stamp s2) => new
{
request = counter.Next(),
scopedSame = ReferenceEquals(ctx1, ctx2),
scopedId = ctx1.Id,
transientSame = ReferenceEquals(s1, s2),
});
app.Run();
public class Counter { private int _n; public int Next() => Interlocked.Increment(ref _n); }
public class RequestContext { private static int _made; public int Id { get; } = Interlocked.Increment(ref _made); }
public class Stamp { }
$ curl -s localhost:5080/ids
{"request":1,"scopedSame":true,"scopedId":1,"transientSame":false}
$ curl -s localhost:5080/ids
{"request":2,"scopedSame":true,"scopedId":2,"transientSame":false}
Within one request, both RequestContext parameters are the same object; the second request got a new one (scopedId 2). The singleton Counter kept counting across requests, and the two transient Stamps are different objects even within one request. This is why an EF Core DbContext is registered as scoped: one per request, never shared between concurrent requests.
Configuration and the Options Pattern
Settings come from appsettings.json, environment-specific files, environment variables and the command line, with later sources overriding earlier ones. Bind a section to a class with Configure<T> and inject IOptions<T>.
{
"Features": {
"EnableCaching": true,
"MaxPageSize": 100
},
"ConnectionStrings": {
"Default": "Data Source=app.db"
}
}
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<FeatureOptions>(builder.Configuration.GetSection("Features"));
var app = builder.Build();
app.MapGet("/config", (IOptions<FeatureOptions> options, IConfiguration config, IWebHostEnvironment env) => new
{
env.EnvironmentName,
options.Value.EnableCaching,
options.Value.MaxPageSize,
connection = config.GetConnectionString("Default"),
});
app.Run();
public class FeatureOptions
{
public bool EnableCaching { get; set; }
public int MaxPageSize { get; set; } = 20;
}
With appsettings.json alone:
$ curl -s localhost:5080/config
{"environmentName":"Development","enableCaching":true,"maxPageSize":100,"connection":"Data Source=app.db"}
And again with environment variables set for the server process — ASPNETCORE_ENVIRONMENT=Staging and Features__MaxPageSize=5:
$ curl -s localhost:5080/config
{"environmentName":"Staging","enableCaching":true,"maxPageSize":5,"connection":"Data Source=app.db"}
The environment variable won over the JSON file (maxPageSize 5), while the other settings still came from the file. In an environment variable, __ stands for the : separator of nested keys. The environment name also selects which appsettings.{Environment}.json file is layered on top — appsettings.Staging.json here, if it existed. Keep secrets out of appsettings.json: use user secrets in development and environment variables or a secret store in production.
Validating Minimal API Input
Minimal APIs in .NET 8 do not run data-annotation validation for you. Validate explicitly and return Results.ValidationProblem, which produces the same 400 shape controllers use.
using System.ComponentModel.DataAnnotations;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/api/products", (CreateProductRequest req) =>
{
var results = new List<ValidationResult>();
if (!Validator.TryValidateObject(req, new ValidationContext(req), results, validateAllProperties: true))
return Results.ValidationProblem(results
.GroupBy(r => r.MemberNames.FirstOrDefault() ?? "")
.ToDictionary(g => g.Key, g => g.Select(r => r.ErrorMessage ?? "").ToArray()));
return Results.Created("/api/products/1", req);
});
app.Run();
public class CreateProductRequest
{
[Required, StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = "";
[Range(0.01, 100_000)]
public decimal Price { get; set; }
}
$ curl -s localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"Widget","price":9.99}'
{"name":"Widget","price":9.99}
$ curl -s localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"W","price":-1}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The field Name must be a string with a minimum length of 2 and a maximum length of 100."],"Price":["The field Price must be between 0.01 and 100000."]}}
The valid request is created; the invalid one gets a 400 listing both problems. Without the explicit TryValidateObject call, the attributes on CreateProductRequest would be ignored and the bad product would have been accepted. Libraries such as FluentValidation wrap this pattern in an endpoint filter so each handler does not repeat it.
Practice
- Search endpoint. Add
GET /api/products/search?q=widto the CRUD example that returns products whose name containsq, case-insensitively.
Solution
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var products = new List<(int Id, string Name)> { (1, "Widget"), (2, "Gadget"), (3, "Widget Pro") };
app.MapGet("/api/products/search", (string q) =>
products.Where(p => p.Name.Contains(q, StringComparison.OrdinalIgnoreCase))
.Select(p => new { p.Id, p.Name }));
app.Run();
$ curl -s "localhost:5080/api/products/search?q=wid"
[{"id":1,"name":"Widget"},{"id":3,"name":"Widget Pro"}]
$ curl -s -o /dev/null -w "%{http_code}\n" "localhost:5080/api/products/search"
400
Omitting a required query parameter is a 400; make it string? q to allow that.
- Request-ID middleware. Write middleware that adds an
X-Request-Idheader (a new GUID if the client didn’t send one, otherwise the client’s value) to every response.
Solution
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Use(async (context, next) =>
{
string id = context.Request.Headers.TryGetValue("X-Request-Id", out var v) ? v.ToString() : Guid.NewGuid().ToString("N");
context.Response.Headers["X-Request-Id"] = id;
await next(context);
});
app.MapGet("/", () => "ok");
app.Run();
$ curl -si localhost:5080/ -H "X-Request-Id: abc123" | grep -i "^x-request-id"
X-Request-Id: abc123
$ curl -si localhost:5080/ | grep -ci "^x-request-id"
1
Setting the header before next works because nothing has been written to the body yet.
- 404 for a domain exception. In the exception-handling example, add
class OrderNotFoundException(int id) : Exception($"Order {id} not found")and map it to 404 without changing the/boombehaviour.
Solution
using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseExceptionHandler(e => e.Run(async context =>
{
var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error;
context.Response.StatusCode = ex is OrderNotFoundException ? 404 : 500;
await context.Response.WriteAsJsonAsync(new { error = ex is OrderNotFoundException ? ex.Message : "Unexpected error" });
}));
app.MapGet("/orders/{id:int}", (int id) => { throw new OrderNotFoundException(id); });
app.Run();
class OrderNotFoundException(int id) : Exception($"Order {id} not found");
$ curl -s -w " %{http_code}\n" localhost:5080/orders/5
{"error":"Order 5 not found"} 404