All articles

CRUD Operations using ASP.NET Core

Build a complete CRUD API with ASP.NET Core — verified create/read/update/delete requests, the right status codes for each, automatic validation, and the path from in-memory store to EF Core.

0 · log in to like, save & follow Share on LinkedIn Share on X

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.

cover

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/suppliers200 with the array; GET /api/suppliers/1200 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.

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.