To build a RESTful CRUD API in ASP.NET Core, model each entity as a resource, map the four HTTP verbs (GET, POST, PUT, DELETE) to read/create/update/delete, and return the right status codes with typed Results. With .NET 9 Minimal APIs and EF Core 9 you can do this in a single Program.cs — no controllers, no ceremony. In this article you will build a complete Product API backed by SQLite, wire up all five endpoints, group the routes, add validation, and test everything from a .http file.
What makes an API RESTful?
REST (Representational State Transfer) is an architectural style, not a framework. An API is RESTful when it treats data as resources identified by URLs and uses the HTTP protocol the way it was designed:
- A resource is a noun with a stable address:
/productsis the collection,/products/42is one item. - HTTP verbs describe the action, so the URL never contains one. You never write
/getProduct— you sendGET /products/42. - Status codes communicate the outcome:
200 OK,201 Created,204 No Content,404 Not Found,400 Bad Request. - Requests are stateless — each carries everything the server needs, which is what lets REST APIs scale horizontally.
Mapping CRUD onto verbs is the core of the design:
| Operation | Verb | Route | Success code |
|---|---|---|---|
| Read all | GET | /products |
200 |
| Read one | GET | /products/{id} |
200 / 404 |
| Create | POST | /products |
201 |
| Update | PUT | /products/{id} |
204 / 404 |
| Delete | DELETE | /products/{id} |
204 / 404 |
Statelessness deserves emphasis because it is what separates a REST API from a session-bound web app. The server keeps no memory of previous calls, so any instance behind a load balancer can answer any request. That is why authentication travels in a header on every call rather than living in server-side session state.
Set up the project
Create the project and add the two packages you need. On .NET 9 the web template already gives you Minimal API hosting; you add the EF Core 9 SQLite provider on top:
dotnet new web -n MinimalApiCrud
cd MinimalApiCrud
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 9.0.0
The generated Program.cs starts with two lines — var builder = WebApplication.CreateBuilder(args); and var app = builder.Build();. Everything in this article slots between them (service registration) and after app.Build() (the endpoints), finishing with app.Run();. There is no Startup class and no controllers folder; the whole API lives in one readable file.
Define the Product model
The model is a plain C# record-free class that EF Core 9 maps to a table. Keep it small and use data annotations for validation:
public class Product
{
public int Id { get; set; }
[Required, StringLength(120)]
public string Name { get; set; } = string.Empty;
[Range(0.01, 100000)]
public decimal Price { get; set; }
public int Stock { get; set; }
}
Configure the EF Core DbContext
EF Core 9 needs a DbContext that exposes the entity as a DbSet. Using the SQLite provider means the sample runs anywhere with a single file and no server to install — swap the connection string for SQL Server 2022 in production without touching the rest of the code.
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
The primary constructor (C# 13) passes options straight to the base class. Register it in Program.cs:
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlite("Data Source=products.db"));
For a zero-file test run you can use .UseInMemoryDatabase("products") instead — the endpoints behave identically.
Build the five endpoints with route grouping
MapGroup gives every product route a shared /products prefix, so each endpoint declares only what is unique to it. Each handler injects the AppDbContext directly and returns a typed Results<...> union, which keeps the OpenAPI metadata accurate.

var products = app.MapGroup("/products");
products.MapGet("/", async (AppDbContext db) =>
Results.Ok(await db.Products.ToListAsync()));
products.MapGet("/{id:int}", async (int id, AppDbContext db) =>
await db.Products.FindAsync(id) is Product p
? Results.Ok(p)
: Results.NotFound());
products.MapPost("/", async (Product input, AppDbContext db) =>
{
db.Products.Add(input);
await db.SaveChangesAsync();
return Results.Created($"/products/{input.Id}", input);
});
products.MapPut("/{id:int}", async (int id, Product input, AppDbContext db) =>
{
var p = await db.Products.FindAsync(id);
if (p is null) return Results.NotFound();
p.Name = input.Name;
p.Price = input.Price;
p.Stock = input.Stock;
await db.SaveChangesAsync();
return Results.NoContent();
});
products.MapDelete("/{id:int}", async (int id, AppDbContext db) =>
{
var p = await db.Products.FindAsync(id);
if (p is null) return Results.NotFound();
db.Products.Remove(p);
await db.SaveChangesAsync();
return Results.NoContent();
});
Notice how the response matches the semantics of each verb. POST returns 201 Created with a Location header pointing at the new resource. PUT and DELETE return 204 No Content because the client already knows the outcome and there is nothing to send back. Any missing id yields 404 Not Found instead of a null body.
The {id:int} route constraint is doing quiet work here: it rejects /products/abc with a 404 before your handler ever runs, so you never parse a bad id. FindAsync is the right lookup for a primary key because it checks EF Core's change tracker first and only hits the database on a miss. Returning Results<Ok<Product>, NotFound> as an explicit union — rather than IResult — lets the OpenAPI document and the .http tooling know exactly which responses each endpoint can produce.
Why you should return DTOs, not entities
Returning the EF entity directly is fine for a demo, but in real systems you expose a DTO (Data Transfer Object) — a small record shaped for the API contract. It decouples your database schema from your public JSON, hides fields you don't want to leak, and prevents over-posting attacks where a client sets properties it shouldn't. A typical pattern:
public record ProductDto(int Id, string Name, decimal Price, int Stock);
Map entity to DTO on the way out and a separate CreateProductRequest on the way in. For this tutorial we keep the entity to stay focused, but treat DTOs as the default for anything shipping to production.
Add validation
Data annotations on the model describe the rules; Minimal APIs don't enforce them automatically, so validate explicitly before saving. A compact approach uses Validator.TryValidateObject:
products.MapPost("/", async (Product input, AppDbContext db) =>
{
var ctx = new ValidationContext(input);
var results = new List<ValidationResult>();
if (!Validator.TryValidateObject(input, ctx, results, true))
return Results.ValidationProblem(results.ToDictionary(
r => r.MemberNames.FirstOrDefault() ?? "",
r => new[] { r.ErrorMessage ?? "Invalid" }));
db.Products.Add(input);
await db.SaveChangesAsync();
return Results.Created($"/products/{input.Id}", input);
});
Invalid input now returns 400 Bad Request with a standard ProblemDetails payload. For richer rules, add the MinimalApis.Extensions or FluentValidation packages.
Test with the .http file and curl
Visual Studio and the C# Dev Kit run .http files directly, so you can exercise every endpoint without Postman:
POST http://localhost:5000/products
Content-Type: application/json
{ "name": "Mechanical Keyboard", "price": 89.99, "stock": 12 }
The same call from the terminal with curl:
curl -X POST http://localhost:5000/products \
-H "Content-Type: application/json" \
-d '{"name":"Mechanical Keyboard","price":89.99,"stock":12}'
curl http://localhost:5000/products
curl -X DELETE http://localhost:5000/products/1 -i
Run dotnet run, then watch the status codes: 201 on create, 200 on the list, 204 on delete. That round trip confirms the full CRUD surface works end to end.
Key takeaways
- REST maps CRUD onto HTTP verbs and resource URLs — the verb lives in the method, never the path.
- .NET 9 Minimal APIs express a full CRUD API in one
Program.cswith no controllers. - Return typed
Results(Ok,Created,NotFound,NoContent) so status codes match REST semantics. MapGroupremoves route duplication and centralizes shared metadata.- EF Core 9 with SQLite runs with zero setup; switch the provider for SQL Server 2022 in production.
- Expose DTOs and validate input before persisting to keep the API safe and stable.
Frequently asked questions
Should I use Minimal APIs or controllers for a REST API?
Both produce identical REST semantics. Minimal APIs suit small-to-medium services and microservices where you want less ceremony; controllers still shine for large apps with heavy filters, conventions, and shared base classes. On .NET 9 either is a first-class choice.
What is the difference between PUT and PATCH?
PUT replaces the entire resource and is idempotent — sending it twice leaves the same result. PATCH applies a partial update to specific fields. This tutorial uses PUT because it overwrites the whole product; use PATCH with JSON Patch when clients update only a field or two.
Why return 204 No Content instead of 200 on update and delete?
204 tells the client the operation succeeded and there is deliberately no response body. It saves bandwidth and matches REST conventions for actions where the client already has all the information it needs.
Do I need a database server to run this sample?
No. The sample uses the SQLite provider, which stores everything in a single local file, and you can switch to the in-memory provider for tests. Neither needs an installed server, so dotnet run works immediately.
Comments (0)
No comments yet — be the first to share your thoughts.