CRUD API
This is the core of most APIs: list, get, create, update and delete a resource. We put together everything so far — DTOs, validation, EF Core, status codes, CancellationToken — into ShopSmart’s products API and drive it end to end.
On this page · 7 min · Intermediate
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Intermediate
- Updated
- Oct 29, 2026
Related tutorials
In simple words#
A CRUD API is the four service windows of any record office: create, read, update and delete — each with its own verb and status code.
- POST → 201
- Created, with Location header.
- GET → 200 / 404
- Found / not found.
- PUT/DELETE → 204
- Done, nothing to return.
Learning objectives#
- Create an [ApiController] with all five CRUD actions
- Use DTOs for input and output
- Return 200, 201, 204, 400 and 404 correctly
- Use async EF Core calls with cancellation
Explanation#
GET /api/products returns a list (projected to DTOs, AsNoTracking). GET /api/products/{id} returns one or 404.
POST validates the input DTO automatically ([ApiController]), saves the entity and returns 201 with CreatedAtAction — a Location header and the created DTO.
PUT replaces the editable fields of an existing product and returns 204 No Content; 404 if it does not exist.
DELETE removes with ExecuteDeleteAsync in a single statement and returns 204, or 404 when nothing was deleted.
Every action is async and accepts a CancellationToken. Business rules (unique names, stock rules) would move to a service as the API grows.
Syntax#
GET /api/products 200 [ … ]
GET /api/products/{id} 200 { … } | 404
POST /api/products 201 + Location | 400
PUT /api/products/{id} 204 | 400 | 404
DELETE /api/products/{id} 204 | 404
Code example: ProductsController, driven end to end#
// Program.cs
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders(); // keep the demo output clean
builder.Services.AddControllers();
builder.Services.AddRouting(o => o.LowercaseUrls = true); // Location: /api/products/1, not /api/Products/1
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(
@"Server=(localdb)\mssqllocaldb;Database=ShopSmartApi_Crud;Trusted_Connection=True;TrustServerCertificate=True"));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureDeletedAsync(); // demo only: use migrations in real apps
await db.Database.EnsureCreatedAsync();
}
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5216");
await app.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5216") };
async Task Show(string label, Task<HttpResponseMessage> call)
{
var response = await call;
var location = response.Headers.Location is { } url ? quot; Location: {url.AbsolutePath}" : "";
var body = response.Content.Headers.ContentType?.MediaType == "application/problem+json" ? "(problem details)" : await response.Content.ReadAsStringAsync();
Console.WriteLine(quot;{label,-22} {(int)response.StatusCode}{location} {body}".TrimEnd());
}
await Show("POST keyboard", http.PostAsJsonAsync("/api/products", new { name = "Keyboard", price = 2499 }));
await Show("POST mouse", http.PostAsJsonAsync("/api/products", new { name = "Wireless Mouse", price = 799 }));
await Show("POST invalid", http.PostAsJsonAsync("/api/products", new { name = "", price = 0 }));
await Show("GET all", http.GetAsync("/api/products"));
await Show("PUT keyboard", http.PutAsJsonAsync("/api/products/1", new { name = "Mechanical Keyboard", price = 3999 }));
await Show("GET keyboard", http.GetAsync("/api/products/1"));
await Show("DELETE mouse", http.DeleteAsync("/api/products/2"));
await Show("GET mouse", http.GetAsync("/api/products/2"));
await Show("DELETE mouse again", http.DeleteAsync("/api/products/2"));
await app.StopAsync();
using (var scope = app.Services.CreateScope())
await scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.EnsureDeletedAsync();
// Contracts/ProductDtos.cs
using System.ComponentModel.DataAnnotations;
public record ProductDto(int Id, string Name, decimal Price);
public record SaveProductDto(
[Required, StringLength(100)] string Name,
[Range(0.01, 1_000_000)] decimal Price);
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[ApiController]
[Route("api/[controller]")]
public class ProductsController(AppDbContext db) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<ProductDto>> GetAll(CancellationToken ct) =>
await db.Products.AsNoTracking()
.OrderBy(p => p.Name)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync(ct);
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> Get(int id, CancellationToken ct)
{
var p = await db.Products.FindAsync([id], ct);
return p is null ? NotFound() : new ProductDto(p.Id, p.Name, p.Price);
}
[HttpPost]
public async Task<ActionResult<ProductDto>> Create(SaveProductDto dto, CancellationToken ct)
{
var p = new Product { Name = dto.Name, Price = dto.Price };
db.Products.Add(p);
await db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = p.Id }, new ProductDto(p.Id, p.Name, p.Price));
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, SaveProductDto dto, CancellationToken ct)
{
var p = await db.Products.FindAsync([id], ct);
if (p is null) return NotFound();
p.Name = dto.Name;
p.Price = dto.Price;
await db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var affected = await db.Products.Where(p => p.Id == id).ExecuteDeleteAsync(ct);
return affected == 0 ? NotFound() : NoContent();
}
}
POST keyboard 201 Location: /api/products/1 {"id":1,"name":"Keyboard","price":2499}
POST mouse 201 Location: /api/products/2 {"id":2,"name":"Wireless Mouse","price":799}
POST invalid 400 (problem details)
GET all 200 [{"id":1,"name":"Keyboard","price":2499.00},{"id":2,"name":"Wireless Mouse","price":799.00}]
PUT keyboard 204
GET keyboard 200 {"id":1,"name":"Mechanical Keyboard","price":3999.00}
DELETE mouse 204
GET mouse 404 (problem details)
DELETE mouse again 404 (problem details)This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- POST returns 201 with a Location header built from the Get action and the created product.
- Invalid input never reaches Create: [ApiController] returns 400 with validation problem details.
- GET all is sorted by name and projected to DTOs (values read back from decimal(18,2) columns show two decimals); PUT returns 204 and the next GET shows the change.
- DELETE uses ExecuteDeleteAsync (one SQL statement) and returns 204.
- Getting or deleting the removed product returns 404. (AppDbContext and Product are the classes from the EF Core lessons.)
Real-world use case#
This exact controller shape backs ShopSmart’s products, categories and coupons, and the React and Angular projects later in this track call it.
Common mistakes#
Binding the EF entity allows over-posting and leaks internal fields. Use DTOs.
Clients and tools rely on correct codes. Use 201, 204, 400 and 404 appropriately.
ToListAsync and SaveChangesAsync — never .Result or .Wait() — keep the thread pool healthy.
Best practices#
- DTOs for input and output
- Route constraints such as {id:int}
- Thin controllers; business rules in services
- Pagination before lists grow
- OpenAPI documentation
Remember this#
GET 200 · POST 201 + Location · PUT/DELETE 204 · missing 404 · invalid 400.
[ApiController] validates DTOs before the action runs.
Async + CancellationToken everywhere.
Practice exercise#
Your task: Add GET /api/products/search?name=key that returns products whose name contains the search text.
Need a hint?
Show solution
// Controllers/ProductSearchController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
public record ProductDto(int Id, string Name, decimal Price);
[ApiController]
[Route("api/products")]
public class ProductSearchController(AppDbContext db) : ControllerBase
{
[HttpGet("search")]
public async Task<IEnumerable<ProductDto>> Search([FromQuery] string name, CancellationToken ct) =>
await db.Products.AsNoTracking()
.Where(p => p.Name.Contains(name))
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync(ct);
}
Mini quiz#
Which status code should a successful POST that creates a product return?
Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.