Intermediate 65 min.NET 10

Build a complete CRUD feature

Connect MVC to EF Core and implement real create, read, update, and delete operations.

BY THE END OF THIS TUTORIAL

Here's what you'll be able to do.

  • Configure a scoped EF Core database context
  • Implement tracked updates and read-only queries
  • Keep ownership and antiforgery checks on every mutation
Before you begin

Complete forms and validation. Download TaskBoard; it includes SQLite migrations and Identity.

Understand the idea

CRUD is easy to demonstrate and surprisingly easy to get wrong. A complete feature needs more than four buttons: it needs input validation, predictable error handling, ownership isolation, persistence, and an understandable user journey.

EF Core maps C# entities to database records and tracks changes during a unit of work. The DbContext is scoped to a request by default. TaskBoard uses SQLite so you can run the entire feature without configuring a database server.

Choose your environment

Open TaskBoard.csproj from the example download. Install the ASP.NET and web development workload and a Visual Studio release supporting .NET 10. Select the TaskBoard launch profile and press F5. Use Solution Explorer to find the files named below.

Download the complete companion project
STEP 01

Configure the persistence boundary

BoardDb inherits from IdentityDbContext so application tasks and Identity tables live in the same database. DbSet exposes a queryable set of tasks. OnModelCreating calls the base method before adding TaskBoard mappings; forgetting that call breaks Identity’s model configuration.

The OwnerId foreign key links each task to a user. The compound index supports the common pattern of finding one user’s tasks in creation order. Indexes should follow real query patterns rather than being added to every column.

Data/BoardDb.cs
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using TaskBoard.Models;

namespace TaskBoard.Data;

public class BoardDb(DbContextOptions<BoardDb> options) : IdentityDbContext<IdentityUser>(options)
{
    public DbSet<BoardTask> Tasks => Set<BoardTask>();
    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        builder.Entity<BoardTask>().HasIndex(t => new { t.OwnerId, t.CreatedUtc });
        builder.Entity<BoardTask>().HasOne<IdentityUser>().WithMany()
            .HasForeignKey(t => t.OwnerId).OnDelete(DeleteBehavior.Cascade);
    }
}
STEP 02

Create and read records

Add tells EF that a new entity should be inserted. SaveChangesAsync sends the operation to the database. Until saving succeeds, do not tell the user the task was created.

For lists, AsNoTracking avoids keeping change-tracking state for entities you only render. Apply the owner filter before materializing. Loading all users’ tasks and filtering them in the view would waste work and weaken the data boundary.

Controller excerpt
db.Tasks.Add(new BoardTask
{
    Title = input.Title.Trim(),
    Notes = input.Notes,
    OwnerId = OwnerId
});
await db.SaveChangesAsync(ct);

var tasks = await db.Tasks.AsNoTracking()
    .Where(t => t.OwnerId == OwnerId)
    .OrderByDescending(t => t.CreatedUtc)
    .ThenBy(t => t.Id)
    .Take(6).ToListAsync(ct);
STEP 03

Update only the intended properties

The edit POST loads the existing row using both id and owner. It handles missing rows before applying changes. EF tracks this loaded entity, so assigning Title and Notes is enough for SaveChangesAsync to detect the update.

Avoid attaching an entire browser-submitted entity and marking all properties modified. Explicit mapping makes it clear which fields can change and prevents accidental updates to OwnerId or CreatedUtc. For multi-editor production workflows, add optimistic concurrency handling so a later save does not silently overwrite another editor.

Controller excerpt
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, TaskInput input, CancellationToken ct)
{
    var task = await db.Tasks.SingleOrDefaultAsync(
        t => t.Id == id && t.OwnerId == OwnerId, ct);
    if (task is null) return NotFound();
    if (!ModelState.IsValid) return View(input);
    task.Title = input.Title.Trim();
    task.Notes = input.Notes;
    await db.SaveChangesAsync(ct);
    TempData["Message"] = "Task updated.";
    return RedirectToAction(nameof(Index));
}
STEP 04

Delete with the same protections

A delete must repeat the authorization boundary even if the record was already shown on an authorized list. The browser can submit any id. Query the owned row again, then remove it and save. Use a POST form with an antiforgery token.

Test persistence, not only the success message. Create a task, restart the app, edit it, restart again, and finally delete it. The expected state should survive every restart. Try accessing its id using a second account and confirm it is unavailable.

Controller excerpt
var task = await db.Tasks.SingleOrDefaultAsync(
    t => t.Id == id && t.OwnerId == OwnerId, ct);
if (task is null) return NotFound();
db.Tasks.Remove(task);
await db.SaveChangesAsync(ct);
return RedirectToAction(nameof(Index));
YOUR TURN

Make it your own.

Create, edit, complete, and delete a task using TaskBoard. Then add a Details GET action that returns only a task owned by the current user.

See the solution & reasoning

Use AsNoTracking().SingleOrDefaultAsync(t => t.Id == id && t.OwnerId == OwnerId, ct). Return NotFound when absent; otherwise return View(task). Add Views/Tasks/Details.cshtml with @model BoardTask and encoded output. Keep [Authorize] on the controller.

When something doesn't work

No such table: apply the included migrations to the same connection string the running app uses.

Changes disappear after restart: check that you are using the same working directory and SQLite file. Relative paths resolve from the process context.

DbUpdateException is not a user-friendly validation system. Validate known rules first and handle database failures as operational errors without leaking connection details.

CHECK YOUR UNDERSTANDING

Why does the edit action load the owned row before assigning properties?

Why does the edit action load the owned row before assigning properties?

ONE STEP FURTHER

That's another skill in your toolkit.

Your progress is saved in this browser. Sign in to save completion to your account.