Backend Development with .NET
Session 10
EF Core Migrations
& CRUD
Eng. Seif Mansour  ·  Andalusia Academy
Week 5  ·  2.5 hours
Session Goals

By the end of this session, you will be able to:

  • Create and apply EF Core migrations to generate and evolve a real database schema
  • Implement full CRUD operations — Create, Read, Update, Delete — against a live database using DbContext
  • Write LINQ queries that execute at the database level for filtering, sorting, and pagination
  • Understand eager loading and recognize the N+1 query problem before it reaches production
Session Agenda
Time Segment Type Duration
0:00Migrations — what and whyTheory15 min
0:15Create & apply first migrationDemo20 min
0:35CRUD with DbContextDemo30 min
1:05LINQ for filtering and paginationDemo20 min
1:25Break10 min
1:35Eager loading & N+1 problemTheory + Demo20 min
1:55Lab — persist the task list to the databaseLab30 min
2:25Wrap-upDiscussion5 min
Migrations Workflow
A migration is a snapshot of the difference between your C# model and the current database schema. EF Core compares those two states and generates SQL to bring the database in line with your code — without you writing a single SQL DDL statement by hand.
In this section
  • Why migrations exist and what problem they solve
  • The four key CLI commands
  • What EF Core generates inside a migration file
  • Rolling forward and rolling back
Why Migrations?
  • Schema lives in code, not in a DBA's head — every column, index, and constraint is defined in C# entity classes and committed to version control alongside the application code that uses it
  • Changes are incremental and reversible — each migration is a named, ordered script. You can roll forward to apply it or roll back to undo it with a single CLI command
  • Teams stay in sync — when a teammate adds a new column, they add a migration. When you pull their branch and run dotnet ef database update, your local database reflects their change automatically
  • No manual SQL required — EF Core generates the SQL diff from the model snapshot. You review the generated migration, verify it, and apply it — the SQL is produced for you
Prerequisite
Session 09 installed EF Core, configured AppDbContext, and defined the TaskItem entity. This session applies that setup to create and apply a real migration.
Migration CLI Commands

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 diff
  • database update — applies all pending migrations to the database in order
  • migrations remove — deletes the most recently added migration file, but only if it has not been applied to the database yet
  • database 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
Install the tools first
Run dotnet tool install --global dotnet-ef once. The tools must be installed globally before any dotnet ef command is available in the terminal.
Inside a Migration File
Migrations/20240501120000_InitialCreate.cs
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.

CRUD with DbContext
Once the migration is applied and the table exists, every CRUD operation follows the same pattern: load or create an entity, modify it in memory, then call SaveChangesAsync to flush the change tracker to the database in a single round trip.
In this section
  • Adding an entity and persisting it
  • Reading all records and reading by ID
  • Updating an existing entity
  • Deleting an entity
Key Principle
"EF Core tracks every entity it loads.
You change the object — EF Core writes the SQL."
The change tracker is the engine behind EF Core CRUD. When you call 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 and Read
Services/TaskService.cs
// 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 vs FirstOrDefaultAsync
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 and Delete

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();
Always load before deleting
Calling Remove on an entity that was never loaded will throw. Load first — this also lets you return 404 if the record does not exist.
LINQ for Filtering & Pagination
LINQ queries against a DbSet are not executed immediately — they build an expression tree. Only when you call ToListAsync, CountAsync, or a similar terminal operator does EF Core translate the entire tree into a single SQL statement and send it to the database.
In this section
  • Deferred execution and IQueryable
  • Composing filters conditionally
  • Counting the total before paging
  • Applying Skip and Take for pagination
Deferred Execution and IQueryable
  • LINQ on a DbSet returns IQueryable<T> — not a list. The query has not run yet; EF Core is building an expression tree in memory
  • Composition is free — you can call .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 set
  • A terminal operator fires the SQL — calling ToListAsync(), CountAsync(), FirstOrDefaultAsync(), or AnyAsync() translates the entire tree into one SQL statement and sends it to the database
  • This is database-side filtering — only the rows that match your Where clause are returned over the network. The alternative — loading all rows into memory and filtering in C# — is never acceptable at scale
Anti-pattern
Never call ToListAsync() 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.
Composing a Filtered, Paginated Query
Services/TaskService.cs
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.

Eager Loading & the N+1 Problem
Lazy loading hides a trap: loading a collection and then accessing a navigation property inside a loop fires one extra database query per item. On 1,000 tasks, that is 1,001 queries. Eager loading collapses those 1,001 round trips into a single SQL JOIN.
In this section
  • What the N+1 problem looks like in code
  • Why it is invisible until it hits production load
  • Fixing it with .Include()
  • Chaining multiple includes
The N+1 Problem
  • The pattern — you fetch N items (one query), then for each item you access a navigation property that triggers another query — a total of N+1 database round trips
  • Why it hides in development — with 5 rows in your local database, 6 queries finish in milliseconds. In production with 10,000 rows, 10,001 queries saturate the connection pool and the page times out
  • The tell-tale sign — a loop body that accesses task.User, task.Category, or any navigation property without .Include() on the original query
The bad pattern
var tasks = await _context.Tasks.ToListAsync();
foreach (var task in tasks)
{
    var user = await _context.Users.FindAsync(task.UserId); // N extra queries!
}
Fixing N+1 with Eager Loading

.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.

  • Pass a navigation property expression: t => t.User
  • Chain .ThenInclude() to load a navigation property of the related entity
  • Multiple .Include() calls load multiple independent relationships
  • The generated SQL uses LEFT JOIN — the task row is returned even when the related row is null
Always prefer eager loading
Unless you have a deliberate reason to lazy-load, always use .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();
Lab — Persist the Task List to the Database

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.

1 Run dotnet ef migrations add InitialCreate and inspect the generated migration file — confirm it creates the Tasks table with the correct columns
2 Run dotnet ef database update and verify the table was created in the database using a database explorer or psql
3 Inject AppDbContext into TaskService via the constructor and remove the List<TaskItem> in-memory field
4 Rewrite each service method — GetAll, GetById, Create, Update, Delete — using DbContext and async/await
5 Add LINQ filtering to GetAllILike search on title and an optional isCompleted filter, with CountAsync and Skip/Take pagination
6 Use Swagger UI to test all five endpoints — create a task, list it, update it, verify the change persists after restarting the application, then delete it
Summary
  • Migrations are version-controlled schema changes — EF Core generates them from your entity model; dotnet ef database update applies them in order
  • CRUD follows a consistent pattern — add or load an entity, modify it in memory, call SaveChangesAsync() — EF Core's change tracker handles the SQL
  • LINQ queries are deferred — each .Where() and .OrderBy() adds to an expression tree; the SQL is not sent until you call a terminal operator like ToListAsync()
  • Always filter at the database level — compose your Where clauses before calling ToListAsync(); never load all rows and filter in memory
  • The N+1 problem is silent until production — use .Include() for every related entity you know you will access to collapse N+1 round trips into a single SQL JOIN
What's Next

Session 11 — Repository Pattern & DTOs

  • The 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 testable
  • You will define a ITaskRepository interface, implement it with an EF Core-backed class, and inject it into the service — the service never imports EF Core again
  • DTOs (Data Transfer Objects) will replace direct entity exposure in controller responses — decoupling your API's public contract from the database schema
  • After Session 11, the architecture will have three clear layers: Controller → Service → Repository — each with a single responsibility and a testable seam between them
Before next session
Complete the lab and the assignment. Make sure data persists across application restarts before Session 11 — the repository will wrap the same DbContext calls you write today.
Assignment

Migrate the Task Manager API from in-memory storage to a real database using EF Core.

What to build:

  • Create and apply an InitialCreate migration that produces the Tasks table with all required columns
  • Rewrite TaskService to use AppDbContext for all five CRUD operations, with full async/await
  • Add LINQ filtering and pagination to the GetAll method — search by title (case-insensitive), filter by completion status, and return a paginated response with a total count

Acceptance criteria:

  • The Tasks table exists in the database and matches the entity definition after running dotnet ef database update
  • All five CRUD endpoints return correct results against the database — verified through Swagger UI
  • Data created through POST /api/tasks survives an application restart and is visible on the next GET /api/tasks
  • GET /api/tasks?search=meeting&page=1&pageSize=5 returns only matching tasks and the correct totalCount, without loading all rows into memory
Bonus
Add a second migration that introduces an UpdatedAt 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.
Questions?
Session 10 — EF Core Migrations & CRUD