Complete CRUD
The previous two chapters added Todo creation and querying. This chapter adds updates and deletion: first load an entity, then modify it or mark it for deletion, and finally call SaveChangesAsync() to save.
CRUD stands for Create, Read, Update, and Delete. Validation, route groups, and error handling follow the earlier examples. To focus on writes, this chapter's list returns all Todos without filtering or pagination. The categories Work (1) and Life (2) are preloaded, and there are no category create or delete endpoints yet.
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
// For standalone learning samples only; EnsureCreatedAsync does not update existing tables.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
await db.Database.EnsureCreatedAsync();
if (!await db.Categories.AnyAsync())
{
db.Categories.AddRange(new Category { Name = "Work" }, new Category { Name = "Life" });
await db.SaveChangesAsync();
}
}
var todos = app.MapGroup("/todos").WithTags("Todos");
todos.MapGet("/", async (TodoDbContext db) =>
await db.Todos.AsNoTracking().OrderBy(t => t.Id)
.Select(t => new TodoResponse(t.Id, t.Title, t.Done, t.CategoryId)).ToListAsync());
todos.MapGet("/{id:int}", async Task<Results<Ok<TodoResponse>, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is null ? TypedResults.NotFound()
: TypedResults.Ok(new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
});
todos.MapPost("/", async Task<Results<Created<TodoResponse>, ProblemHttpResult>> (CreateTodo input, TodoDbContext db) =>
{
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
var todo = new Todo { Title = input.Title, CategoryId = input.CategoryId };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return TypedResults.Created($"/todos/{todo.Id}",
new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
}).ProducesProblem(400);
todos.MapPut("/{id:int}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (int id, ReplaceTodo input, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
{
return TypedResults.Problem(statusCode: 400, title: "Category not found");
}
todo.Title = input.Title;
todo.Done = input.Done;
todo.CategoryId = input.CategoryId;
await db.SaveChangesAsync();
return TypedResults.NoContent();
}).ProducesProblem(400);
todos.MapDelete("/{id:int}", async Task<Results<NoContent, NotFound>> (int id, TodoDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return TypedResults.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
app.Run();using System.ComponentModel.DataAnnotations;
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool Done { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Todo> Todos { get; set; } = [];
}
public record CreateTodo(
[Required, StringLength(100)] string Title,
[Range(1, int.MaxValue)] int CategoryId);
public record ReplaceTodo(
[Required, StringLength(100)] string Title,
bool Done,
[Range(1, int.MaxValue)] int CategoryId);
public record TodoResponse(int Id, string Title, bool Done, int CategoryId);using Microsoft.EntityFrameworkCore;
public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
public DbSet<Category> Categories => Set<Category>();
}Run and verify
Stop the previous chapter's service, then run this from the repository root:
cd samples/17-crud
dotnet runThis chapter uses todos-17.db. On the first run, the Todo table is empty. Perform these operations in order; only the key response headers are shown, and the ID may differ if the database already has data.
Create a Todo:
curl -i -X POST http://localhost:5080/todos -H "Content-Type: application/json" -d '{"title":"Write report","categoryId":1}'HTTP/1.1 201 Created
Location: /todos/1
Content-Type: application/json; charset=utf-8
{"id":1,"title":"Write report","done":false,"categoryId":1}Mark it complete and move it to the Life category:
curl -i -X PUT http://localhost:5080/todos/1 -H "Content-Type: application/json" -d '{"title":"Write report","done":true,"categoryId":2}'HTTP/1.1 204 No ContentA 204 response has no body. Retrieve it again to see the saved content:
curl http://localhost:5080/todos/1{"id":1,"title":"Write report","done":true,"categoryId":2}Delete it, then query it again:
curl -i -X DELETE http://localhost:5080/todos/1
curl -i http://localhost:5080/todos/1The first response is 204; the second is 404, with this body. The traceId differs each time:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"request-trace-id"}Which fields can a request change?
CreateTodo accepts only Title and CategoryId; ReplaceTodo also accepts Done. Id always comes from the route or database, never from the request body.
Why not use Todo directly as the request type? If the entity gains internal fields in the future, accepting the whole entity could allow clients to change those fields too. A separate request type makes the allowed fields explicit.
[Required], [StringLength], and [Range] on the inputs use the mechanism from Chapter 06. The input record remains public so the .NET 10 validation generator can process it.
Here, PUT means replacing the complete client-editable state, so send all three fields: title, done, and categoryId. It does not mean “update only the fields that appear in the JSON.” For example, if done is omitted, deserialization supplies false, replacing the current completion state with false.
Load, modify, then save
The PUT steps are:
FindAsync(id)loads the entity. If it does not exist, return 404.- Check whether the target category exists. If not, return a 400 with an explanation.
- Modify the properties of the tracked entity.
SaveChangesAsync()detects the changes and writes them to the database.
There is no need to call Update(todo) because the entity returned by FindAsync is already tracked by the current context. AsNoTracking() is for read-only list queries; if you retrieve an untracked object and only change its properties, saving will not automatically write those changes back. Basic save operations
Deletion works the same way: Remove(todo) marks the entity for deletion, and SaveChangesAsync() actually deletes the database row. Querying that ID afterward returns 404.
A valid ID does not prove the category exists
[Range(1, int.MaxValue)] can check that an ID is positive, but it cannot prove that category 99 exists. Checking whether a category exists requires a database query, so that check belongs in the handler:
curl -i -X POST http://localhost:5080/todos -H "Content-Type: application/json" -d '{"title":"Invalid","categoryId":99}'The response is 400, with this body:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Category not found","status":400,"traceId":"request-trace-id"}The check lets us return a clear “category does not exist” message. The database foreign key constraint still prevents an invalid ID from being saved. This example cannot delete categories; if a delete endpoint is added later, it will also need to handle a category being deleted after the check but before the save.
.ProducesProblem(400) adds this kind of 400 response to the OpenAPI document. See Chapter 09 for why.
Repeated requests and concurrent updates
Repeating a PUT with the same content leads to the same final state. After repeated DELETE requests, the resource is still gone. This is called idempotency: it does not require the same status code on every request, so a 204 for the first DELETE and a 404 for the next one are consistent. POST may create a new resource each time.
This chapter does not check for concurrent updates yet. If two people read the same Todo and then make separate changes, the later save may overwrite the earlier one. The next chapter first limits who can call these endpoints.
FastAPI comparison
This is similar to loading a SQLAlchemy entity in a FastAPI handler, changing its properties, and then committing the Session. Separating DTOs from database entities also corresponds to giving Pydantic input/output models and ORM models distinct responsibilities.
Tip
For a comparison with INSERT, UPDATE, and DELETE, see EF Core / LINQ ↔ PostgreSQL cheat sheet, which includes runnable examples.
Summary
- CRUD combines create, read, update, and delete operations, each using appropriate HTTP methods and status codes.
- Request DTOs limit the fields clients can change, entities represent database records, and response DTOs define returned fields.
- A tracked entity can be changed directly;
SaveChangesAsync()detects and saves its changes. Removeonly marks an entity for deletion. The deletion takes effect on save; field validation cannot replace a database existence check.- In this example, PUT replaces the complete editable state, and concurrent update conflicts are not handled yet.
Next: Authentication (JWT)—first establish who is calling the API. Previous: Relations and Queries.
