Build a complete CRUD feature
Connect MVC to EF Core and implement real create, read, update, and delete operations.
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
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.
Install the .NET 10 SDK and C# Dev Kit. Open the TaskBoard folder in VS Code. Run dotnet restore, then dotnet run in the integrated terminal. Browse http://localhost:5300. Open files with Ctrl+P (Cmd+P on macOS).
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.
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);
}
}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.
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);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.
[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));
}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.
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));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.
Why does the edit action load the owned row before assigning properties?
That's another skill in your toolkit.
Your progress is saved in this browser. Sign in to save completion to your account.