CRUD over HTTP sounds trivial until you audit real APIs: POSTs that return 200 with no Location, DELETEs that 200 whether anything existed, validation errors as bare strings. This post builds a RESTful CRUD API with ASP.NET Core minimal APIs on .NET 10 — correct verbs, correct status codes, typed results that make the contract compiler-checked, and validation as RFC-standard problem details — every response verified with curl.

Resource thinking first
REST's core is resources and uniform verbs. One resource, five operations:
| Operation | Route | Success | Failure |
|---|---|---|---|
| List | GET /api/products |
200 | — |
| Read | GET /api/products/{id} |
200 | 404 |
| Create | POST /api/products |
201 + Location | 400 |
| Replace | PUT /api/products/{id} |
200 | 404 / 400 |
| Delete | DELETE /api/products/{id} |
204 | 404 |
The bolded pair is where most APIs go wrong: creation returns 201 Created with a Location header pointing at the new resource; deletion returns 204 No Content — there is nothing to say.
The endpoints
Minimal APIs express this without controllers:
var products = app.MapGroup("/api/products").WithTags("Products");
products.MapGet("/", (ProductStore store) => Results.Ok(store.All()));
products.MapGet("/{id:int}", Results<Ok<Product>, NotFound> (int id, ProductStore store) =>
store.Get(id) is { } p ? TypedResults.Ok(p) : TypedResults.NotFound())
.WithName("GetProduct");
products.MapPost("/", Results<CreatedAtRoute<Product>, ValidationProblem> (ProductInput input, ProductStore store) =>
{
if (Validate(input) is { Count: > 0 } errors) return TypedResults.ValidationProblem(errors);
var created = store.Add(input);
return TypedResults.CreatedAtRoute(created, "GetProduct", new { id = created.Id });
});
products.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id, ProductStore store) =>
store.Delete(id) ? TypedResults.NoContent() : TypedResults.NotFound());
The pieces doing quiet heavy lifting:
MapGrouphangs the whole resource off one prefix — filters, auth, and tags apply once.Results<Ok<Product>, NotFound>declares every response type in the signature. Return anything else and it's a compile error — the API contract is type-checked, and OpenAPI generation reads it for free..WithName("GetProduct")+CreatedAtRoutebuilds the Location header from the read endpoint's route — rename the route pattern and Location stays correct.{id:int}rejects/api/products/abcwith a 404 before your code runs.
Verified with curl
GET /api/products → 200 [{"id":1,"name":"Mechanical keyboard",...}]
POST /api/products → HTTP/1.1 201 Created
Location: http://localhost:5299/api/products/3
POST (invalid body) → 400 {"title":"One or more validation errors occurred.",
"errors":{"name":["Name is required."],"price":["Price must be positive."]}}
PUT /api/products/1 → 200
DELETE /api/products/2 → 204
GET /api/products/2 → 404
Every row of the design table, observed on the wire.
Validation as problem details
static Dictionary<string, string[]> Validate(ProductInput input)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(input.Name)) errors["name"] = ["Name is required."];
if (input.Price <= 0) errors["price"] = ["Price must be positive."];
return errors;
}
TypedResults.ValidationProblem(errors) wraps this into RFC 9457 problem details — the standard machine-readable error shape, keyed by field, that client generators and frontends already understand. Hand-rolled { "error": "bad name" } shapes make every consumer write custom handling. For larger models graduate to FluentValidation or DataAnnotations with .AddValidation(); the response shape stays the same.
PUT vs PATCH, and idempotency
PUT here replaces the resource — send the whole representation. Partial updates are PATCH's job; if you accept partial PUT bodies you inherit "did the client mean to clear that field?" ambiguity forever. Idempotency is the other REST property worth protecting: PUT and DELETE applied twice give the same end state (our DELETE returns 404 the second time — state unchanged, contract honest); POST is the only non-idempotent verb, which is why retry logic and rate limiting treat it specially.
Minimal APIs or controllers?
The store behind this demo is an in-memory ConcurrentDictionary for focus; swapping it for EF Core or Dapper changes nothing about the HTTP contract — which is the point. Choose minimal APIs for endpoint-shaped services like this (less ceremony, faster startup, explicit dependencies per endpoint); choose controllers when you lean on filters, model-binding conventions, and view-adjacent features. Both sit on the same routing and middleware; the REST discipline in this post applies identically to either.
The complete runnable API is in the companion repository — dotnet run and replay the curl transcript above against it.
Comments (0)
No comments yet — be the first to share your thoughts.