ASP.NET Core Web API · Data

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.

Intermediate 7 min Lesson 16 of 37Examples target .NET 9.
0/37 in ASP.NET Core Web API
On this page · 7 min · Intermediate

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#

Text Illustrative
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#

C#Controllers/ProductsController.cs Illustrative
// 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(); } }
Expected output
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#

  1. POST returns 201 with a Location header built from the Get action and the created product.
  2. Invalid input never reaches Create: [ApiController] returns 400 with validation problem details.
  3. 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.
  4. DELETE uses ExecuteDeleteAsync (one SQL statement) and returns 204.
  5. 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#

Exposing entities directly

Binding the EF entity allows over-posting and leaks internal fields. Use DTOs.

Returning 200 for everything

Clients and tools rely on correct codes. Use 201, 204, 400 and 404 appropriately.

Blocking calls

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?
Where(p => p.Name.Contains(name)) then project to ProductDto.
Show solution
C# Illustrative
// 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#

CRUD API quizMultiple choice Question 1 of 3

Which status code should a successful POST that creates a product return?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.