By the end of this session, you will be able to:
DbContext| Time | Segment | Type | Duration |
|---|---|---|---|
| 0:00 | Migrations — what and why | Theory | 15 min |
| 0:15 | Create & apply first migration | Demo | 20 min |
| 0:35 | CRUD with DbContext | Demo | 30 min |
| 1:05 | LINQ for filtering and pagination | Demo | 20 min |
| 1:25 | Break | — | 10 min |
| 1:35 | Eager loading & N+1 problem | Theory + Demo | 20 min |
| 1:55 | Lab — persist the task list to the database | Lab | 30 min |
| 2:25 | Wrap-up | Discussion | 5 min |
dotnet ef database update, your local database reflects their change automaticallyAppDbContext, and defined the TaskItem entity. This session applies that setup to create and apply a real migration.
The EF Core CLI tools expose four commands you will use on every project:
migrations add — compares the current model to the last snapshot and generates a new migration file with the diffdatabase update — applies all pending migrations to the database in ordermigrations remove — deletes the most recently added migration file, but only if it has not been applied to the database yetdatabase update 0 — rolls back every migration, leaving an empty database with no tables# Create a new migration
dotnet ef migrations add InitialCreate
# Apply all pending migrations
dotnet ef database update
# Remove the last unapplied migration
dotnet ef migrations remove
# Roll back all migrations
dotnet ef database update 0
dotnet tool install --global dotnet-ef once. The tools must be installed globally before any dotnet ef command is available in the terminal.
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Tasks",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Title = table.Column<string>(nullable: false),
IsCompleted = table.Column<bool>(nullable: false),
CreatedAt = table.Column<DateTime>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Tasks");
}
}
Up() runs when the migration is applied. Down() runs when it is rolled back. EF Core generates both methods — you should always review them before applying.
SaveChangesAsync(), EF Core inspects every tracked entity, identifies what changed, and generates the minimal SQL — INSERT, UPDATE, or DELETE — to synchronize the database with the in-memory state. You never write that SQL yourself.
// CREATE — add a new row to the Tasks table
var task = new TaskItem { Title = "Write tests", CreatedAt = DateTime.UtcNow };
_context.Tasks.Add(task);
await _context.SaveChangesAsync();
// task.Id is now populated with the generated primary key
// READ ALL — load every row as a list
var tasks = await _context.Tasks.ToListAsync();
// READ ONE — look up by primary key; returns null if not found
var task = await _context.Tasks.FindAsync(id);
FindAsync(id) checks the change tracker first — if the entity is already loaded, no SQL is issued. FirstOrDefaultAsync(t => t.Id == id) always hits the database. Use FindAsync when you have a primary key; use FirstOrDefaultAsync when filtering on any other column.
Update — load the entity, modify it, then save. No explicit "mark as modified" call is needed; the change tracker detects the difference automatically.
var task = await _context.Tasks
.FindAsync(id);
if (task is null)
return NotFound();
task.IsCompleted = true;
task.Title = dto.Title;
await _context.SaveChangesAsync();
Delete — load the entity, remove it from the DbSet, then save. EF Core issues a DELETE statement for that row.
var task = await _context.Tasks
.FindAsync(id);
if (task is null)
return NotFound();
_context.Tasks.Remove(task);
await _context.SaveChangesAsync();
Remove on an entity that was never loaded will throw. Load first — this also lets you return 404 if the record does not exist.
IQueryableSkip and Take for paginationIQueryable<T> — not a list. The query has not run yet; EF Core is building an expression tree in memory.Where(), .OrderBy(), .Skip(), and .Take() in any order and as many times as needed; each call adds a clause to the tree, not to a result setToListAsync(), CountAsync(), FirstOrDefaultAsync(), or AnyAsync() translates the entire tree into one SQL statement and sends it to the databaseWhere clause are returned over the network. The alternative — loading all rows into memory and filtering in C# — is never acceptable at scaleToListAsync() first and then filter with .Where() on the resulting list. This loads every row into application memory before filtering — a performance disaster on any table larger than a few hundred rows.
var query = _context.Tasks.AsQueryable();
if (!string.IsNullOrEmpty(filter.Search))
query = query.Where(t =>
EF.Functions.ILike(t.Title, $"%{filter.Search}%"));
if (filter.IsCompleted.HasValue)
query = query.Where(t => t.IsCompleted == filter.IsCompleted.Value);
var total = await query.CountAsync(); // first SQL call — COUNT only
var items = await query
.OrderByDescending(t => t.CreatedAt)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.ToListAsync(); // second SQL call — the page of rows
EF.Functions.ILike maps to PostgreSQL's case-insensitive ILIKE operator. Use EF.Functions.Like for SQL Server. Both translate to a SQL predicate — no data is loaded for the search.
.Include()task.User, task.Category, or any navigation property without .Include() on the original queryvar tasks = await _context.Tasks.ToListAsync();
foreach (var task in tasks)
{
var user = await _context.Users.FindAsync(task.UserId); // N extra queries!
}
.Include() tells EF Core to JOIN the related table in the same query — all data arrives in one round trip regardless of how many rows are returned.
t => t.User.ThenInclude() to load a navigation property of the related entity.Include() calls load multiple independent relationshipsLEFT JOIN — the task row is returned even when the related row is null.Include() for related data you know you will need. It is explicit, predictable, and query-efficient.
// One query — one SQL JOIN
var tasks = await _context.Tasks
.Include(t => t.User)
.ToListAsync();
// foreach is now safe — no extra queries
foreach (var task in tasks)
{
Console.WriteLine(task.User.Name);
}
// Chaining deeper relationships
var tasks = await _context.Tasks
.Include(t => t.User)
.ThenInclude(u => u.Department)
.Include(t => t.Category)
.ToListAsync();
Replace the in-memory list in TaskService with real EF Core calls. All existing endpoints must behave identically from the outside — only the data storage layer changes.
dotnet ef migrations add InitialCreate and inspect the generated migration file — confirm it creates the Tasks table with the correct columns
dotnet ef database update and verify the table was created in the database using a database explorer or psql
AppDbContext into TaskService via the constructor and remove the List<TaskItem> in-memory field
GetAll, GetById, Create, Update, Delete — using DbContext and async/await
GetAll — ILike search on title and an optional isCompleted filter, with CountAsync and Skip/Take pagination
dotnet ef database update applies them in orderSaveChangesAsync() — EF Core's change tracker handles the SQL.Where() and .OrderBy() adds to an expression tree; the SQL is not sent until you call a terminal operator like ToListAsync()Where clauses before calling ToListAsync(); never load all rows and filter in memory.Include() for every related entity you know you will access to collapse N+1 round trips into a single SQL JOINSession 11 — Repository Pattern & DTOs
TaskService now talks directly to DbContext. Session 11 introduces the Repository Pattern to sit between the service layer and EF Core — making the data layer swappable and independently testableITaskRepository interface, implement it with an EF Core-backed class, and inject it into the service — the service never imports EF Core againDbContext calls you write today.
Migrate the Task Manager API from in-memory storage to a real database using EF Core.
What to build:
InitialCreate migration that produces the Tasks table with all required columnsTaskService to use AppDbContext for all five CRUD operations, with full async/awaitGetAll method — search by title (case-insensitive), filter by completion status, and return a paginated response with a total countAcceptance criteria:
Tasks table exists in the database and matches the entity definition after running dotnet ef database updatePOST /api/tasks survives an application restart and is visible on the next GET /api/tasksGET /api/tasks?search=meeting&page=1&pageSize=5 returns only matching tasks and the correct totalCount, without loading all rows into memoryUpdatedAt column to the Tasks table. Set this column automatically in the Update service method and confirm the migration applies cleanly without data loss on an already-populated database.