Create, Read, Update, Delete — every data-backed application is CRUD at its core. This tutorial builds a complete suppliers CRUD API with ASP.NET Core, wired to an in-memory store so you can run it with zero setup, and shows the verified request/response for every operation including the status codes a well-behaved API returns. Swapping the store for a database afterwards changes two lines of thinking, and we cover that too.

The model, with validation
using System.ComponentModel.DataAnnotations;
public class Supplier
{
public int Id { get; set; }
[Required(ErrorMessage = "Supplier name is required")]
[StringLength(50, MinimumLength = 3)]
public string Name { get; set; } = string.Empty;
[EmailAddress]
public string? ContactEmail { get; set; }
[Range(1, 5, ErrorMessage = "Rating must be between 1 and 5")]
public int Rating { get; set; }
}
The attributes drive automatic request rejection — details in model validation using DataAnnotations.
The CRUD controller
[Route("api/[controller]")]
[ApiController]
public class SuppliersController : ControllerBase
{
private static readonly List<Supplier> Store = [];
private static int _nextId = 1;
[HttpGet]
public IEnumerable<Supplier> GetAll() => Store;
[HttpGet("{id:int}")]
public ActionResult<Supplier> Get(int id) =>
Store.FirstOrDefault(s => s.Id == id) is { } s ? s : NotFound();
[HttpPost]
public ActionResult<Supplier> Create(Supplier supplier)
{
supplier.Id = _nextId++;
Store.Add(supplier);
return CreatedAtAction(nameof(Get), new { id = supplier.Id }, supplier);
}
[HttpPut("{id:int}")]
public IActionResult Update(int id, Supplier supplier)
{
var existing = Store.FirstOrDefault(s => s.Id == id);
if (existing is null) return NotFound();
existing.Name = supplier.Name;
existing.ContactEmail = supplier.ContactEmail;
existing.Rating = supplier.Rating;
return Ok(existing);
}
[HttpDelete("{id:int}")]
public IActionResult Delete(int id) =>
Store.RemoveAll(s => s.Id == id) > 0 ? NoContent() : NotFound();
}
[ApiController] + [Route("api/[controller]")] give you attribute routing, JSON body binding, and automatic validation responses.
Every operation, verified
Create — POST /api/suppliers
→ {"name":"Geek Supplies","contactEmail":"geek@supplies.com","rating":5}
← 201 Created, Location: /api/Suppliers/1
{"id":1,"name":"Geek Supplies","contactEmail":"geek@supplies.com","rating":5}
CreatedAtAction returns 201 with a Location header pointing at the new resource and the entity (with its server-assigned id) in the body.
Create with invalid data:
→ {"name":"x","rating":9}
← 400, errors: { "Name": ["…minimum length of 3…"], "Rating": ["Rating must be between 1 and 5"] }
Rejected before the action ran — no ifs in the controller.
Read — GET /api/suppliers → 200 with the array; GET /api/suppliers/1 → 200 with one supplier; unknown id → 404.
Update — PUT /api/suppliers/1
→ {"name":"Geek Supplies Ltd","rating":4}
← 200 OK (updated entity)
PUT carries the complete replacement state and is idempotent — sending it twice leaves the same result. Partial updates belong to PATCH.
Delete — DELETE /api/suppliers/2
← 204 No Content (first call — deleted)
← 404 Not Found (second call — already gone)
The status-code contract in one table:
| Operation | Success | Not found | Invalid body |
|---|---|---|---|
| POST | 201 + Location | — | 400 problem+json |
| GET | 200 | 404 | — |
| PUT | 200 (or 204) | 404 | 400 |
| DELETE | 204 | 404 | — |
From in-memory to a real database
The static List<Supplier> is perfect for learning and resets on restart. Production swaps it for EF Core with almost no controller changes:
public class SuppliersController(StoreContext db) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<Supplier>> GetAll() =>
await db.Suppliers.AsNoTracking().ToListAsync();
[HttpPost]
public async Task<ActionResult<Supplier>> Create(Supplier supplier)
{
db.Suppliers.Add(supplier);
await db.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = supplier.Id }, supplier);
}
// …same shape for the rest, with SaveChangesAsync
}
Set up the database side with EF Core code-first migrations or scaffold an existing one via the database-first tutorial. When the app grows, move data access behind an interface — the repository pattern — and return DTOs instead of entities.
Partial updates: PATCH
PUT replaces the whole entity; PATCH changes selected fields. The pragmatic version — a dedicated request model with nullable members, applying only what arrived:
public record SupplierPatch(string? Name, string? ContactEmail, int? Rating);
[HttpPatch("{id:int}")]
public IActionResult Patch(int id, SupplierPatch patch)
{
var existing = Store.FirstOrDefault(s => s.Id == id);
if (existing is null) return NotFound();
if (patch.Name is not null) existing.Name = patch.Name;
if (patch.ContactEmail is not null) existing.ContactEmail = patch.ContactEmail;
if (patch.Rating is not null) existing.Rating = patch.Rating.Value;
return Ok(existing);
}
(The formal alternative — JsonPatchDocument with application/json-patch+json — is more machine-friendly but needs the Newtonsoft package; most APIs are happier with the explicit model.)
Paging, filtering, and sorting the list
GetAll returning everything stops being cute at a few thousand rows. The standard query-string contract:
[HttpGet]
public IActionResult GetAll(string? q, int page = 1, int pageSize = 20)
{
var query = Store.AsQueryable();
if (!string.IsNullOrWhiteSpace(q))
query = query.Where(s => s.Name.Contains(q, StringComparison.OrdinalIgnoreCase));
var total = query.Count();
var items = query
.OrderBy(s => s.Name)
.Skip((page - 1) * pageSize)
.Take(Math.Clamp(pageSize, 1, 100))
.ToList();
return Ok(new { total, page, pageSize, items });
}
Returning total alongside the page lets clients render pagers. Clamp pageSize — an unbounded ?pageSize=1000000 is a self-service denial of service. With EF Core the same shape composes into SQL (Skip/Take become OFFSET/FETCH), so only the page crosses the wire.
Concurrent updates
Two admins edit supplier 7; both PUT. Last-writer-wins silently discards the first edit. The fix is optimistic concurrency: a [Timestamp] rowversion on the entity (covered in EF Core migrations), echoed to clients and back. When the versions don't match, EF throws DbUpdateConcurrencyException — answer with 409 Conflict and the current state, and let the client re-apply. For most internal tools last-writer-wins is genuinely fine; for anything money-adjacent, add the token.
Test it
curl transcripts above, Postman for interactive exploration, or the built-in OpenAPI document with Scalar (/scalar/v1) — see getting started with Web API.
Comments (0)
No comments yet — be the first to share your thoughts.