All articles

RESTful CRUD Operations with ASP.NET Core Minimal APIs

Build a CRUD API with correct verbs and status codes: 201 with Location, 204 for deletes, RFC 9457 validation problems, and compile-checked typed results — verified with curl.

0 · log in to like, save & follow Share on LinkedIn Share on X
RESTful CRUD Operations with ASP.NET Core Minimal APIs

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.

RESTful CRUD Operations with ASP.NET Core Minimal APIs

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:

  • MapGroup hangs 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") + CreatedAtRoute builds the Location header from the read endpoint's route — rename the route pattern and Location stays correct.
  • {id:int} rejects /api/products/abc with 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.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.