Skip to main content
C# intermediate Lesson 24 of 25

Entity Framework Core

DbContext, migrations, LINQ queries, relationships, and async EF Core in C#.

Setting Up EF Core

Entity Framework Core is .NET’s official object-relational mapper (ORM). It lets you work with a database using C# classes and LINQ instead of raw SQL, and it generates and applies the database schema from your model through a migration system. This means your database schema is version-controlled alongside your code, and changes to the model produce incremental, reviewable migration files.

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.EntityFrameworkCore.Design

Defining the Model

The model is the heart of EF Core — plain C# classes (entities) whose properties map to database columns. Navigation properties express relationships between entities: a Customer has many Orders, and each Order has many OrderLines. EF Core infers most of the schema from conventions (e.g., a property named Id becomes the primary key), and you override the defaults in OnModelCreating when you need constraints, indexes, or non-default column types.

// Entities — plain C# classes, no base class required
public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
    public bool IsActive { get; set; } = true;
    public DateTime CreatedAt { get; set; }

    // Navigation property — EF Core uses this to build the JOIN in queries
    public List<Order> Orders { get; set; } = new();
}

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }   // foreign key — convention: {NavigationName}Id
    public decimal Total { get; set; }
    public string Status { get; set; } = "Pending";
    public DateTime OrderDate { get; set; }

    // Navigation properties — back-reference and child collection
    public Customer Customer { get; set; } = null!;
    public List<OrderLine> Lines { get; set; } = new();
}

public class OrderLine
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public string ProductName { get; set; } = "";
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public decimal Total => Quantity * UnitPrice;  // computed — not stored in DB

    public Order Order { get; set; } = null!;
}

DbContext

DbContext is the unit of work and the entry point for all database operations. Each DbSet<T> property represents a database table. OnModelCreating is where you apply explicit configuration: required fields, max lengths, indexes, column types, relationships, and delete behaviors. Registering AppDbContext with the DI container makes it available throughout your application with the correct lifetime (scoped per HTTP request by default).

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    // Each DbSet maps to a table — the name of the DbSet becomes the table name
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderLine> OrderLines => Set<OrderLine>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Customer — explicit constraints and a unique index on email
        modelBuilder.Entity<Customer>(e =>
        {
            e.HasKey(c => c.Id);
            e.Property(c => c.Name).IsRequired().HasMaxLength(200);
            e.Property(c => c.Email).IsRequired().HasMaxLength(254);
            e.HasIndex(c => c.Email).IsUnique();  // enforce unique emails at DB level
            e.Property(c => c.CreatedAt)
             .HasDefaultValueSql("GETUTCDATE()");  // set by the database on insert
        });

        // Order — decimal precision and relationship to Customer
        modelBuilder.Entity<Order>(e =>
        {
            e.HasKey(o => o.Id);
            e.Property(o => o.Total).HasColumnType("decimal(18,2)");
            e.Property(o => o.Status).HasMaxLength(50);

            // Restrict delete — can't delete a customer who has orders
            e.HasOne(o => o.Customer)
             .WithMany(c => c.Orders)
             .HasForeignKey(o => o.CustomerId)
             .OnDelete(DeleteBehavior.Restrict);
        });

        // OrderLine — cascade delete (deleting an order removes its lines)
        modelBuilder.Entity<OrderLine>(e =>
        {
            e.HasKey(l => l.Id);
            e.Property(l => l.UnitPrice).HasColumnType("decimal(18,2)");
            e.Ignore(l => l.Total);   // computed property — do not create a column

            e.HasOne(l => l.Order)
             .WithMany(o => o.Lines)
             .HasForeignKey(l => l.OrderId)
             .OnDelete(DeleteBehavior.Cascade);
        });
    }
}

// Register in DI — scoped lifetime, one instance per HTTP request
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

Migrations

Migrations are the version control system for your database schema. Each migration is a C# file describing a forward (Up) and rollback (Down) operation. You commit migration files to source control so every developer and deployment environment can apply the same incremental schema changes without manual SQL scripts.

# Create a migration — captures the difference between your model and the last migration
dotnet ef migrations add InitialCreate

# Apply all pending migrations to the database
dotnet ef database update

# Roll back to a specific migration (runs the Down method of migrations after it)
dotnet ef database update PreviousMigrationName

# Generate a SQL script for production deployments — review before running
dotnet ef migrations script --output migration.sql

# Remove the last migration if it has not been applied yet
dotnet ef migrations remove

Apply migrations automatically at startup during development:

// In Program.cs — convenient for development, use CI/CD scripts in production
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();

LINQ Queries

EF Core translates LINQ expressions into SQL at runtime. The key habit is to keep the query as an IQueryable<T> — applying filters, projections, and ordering — and only materialize it (with ToListAsync, FirstOrDefaultAsync, etc.) at the very end. This ensures the full query is sent to the database as a single SQL statement, rather than loading rows into memory and filtering them in C#. Use AsNoTracking() on all read-only queries to skip the change-tracking overhead.

public class CustomerRepository
{
    private readonly AppDbContext _db;
    public CustomerRepository(AppDbContext db) => _db = db;

    // Read-only query — AsNoTracking skips change tracking for better performance
    public async Task<List<Customer>> GetActiveAsync()
        => await _db.Customers
                    .Where(c => c.IsActive)
                    .OrderBy(c => c.Name)
                    .AsNoTracking()
                    .ToListAsync();

    // Projection — SELECT only the columns you need, not the full entity
    public async Task<List<CustomerSummary>> GetSummariesAsync()
        => await _db.Customers
                    .Where(c => c.IsActive)
                    .Select(c => new CustomerSummary(
                        c.Id, c.Name, c.Email,
                        c.Orders.Count(o => o.Status != "Cancelled"),
                        c.Orders.Sum(o => o.Total)))
                    .AsNoTracking()
                    .ToListAsync();

    // Eager loading — Include pulls related entities in one JOIN query
    // Avoids N+1: without Include, accessing c.Orders would fire one query per customer
    public async Task<Customer?> GetWithOrdersAsync(int id)
        => await _db.Customers
                    .Include(c => c.Orders)
                    .ThenInclude(o => o.Lines)  // include the grandchild collection too
                    .AsNoTracking()
                    .FirstOrDefaultAsync(c => c.Id == id);

    // Paging — always apply ordering before Skip/Take for deterministic results
    public async Task<(List<Customer> Items, int Total)> GetPageAsync(
        int page, int pageSize, string? search = null)
    {
        var query = _db.Customers.Where(c => c.IsActive);

        if (!string.IsNullOrWhiteSpace(search))
            query = query.Where(c => c.Name.Contains(search)
                                  || c.Email.Contains(search));

        int total = await query.CountAsync();  // single COUNT query
        var items = await query
            .OrderBy(c => c.Name)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .AsNoTracking()
            .ToListAsync();

        return (items, total);
    }
}

Async CRUD Operations

All EF Core operations have async counterparts — always use them in async methods to avoid blocking thread pool threads while the database query is in flight. EF 7 added ExecuteDeleteAsync and ExecuteUpdateAsync for bulk operations that modify rows directly in the database without loading entities first, which is dramatically more efficient for large datasets.

public class OrderService
{
    private readonly AppDbContext _db;
    public OrderService(AppDbContext db) => _db = db;

    // Create — add entity, then call SaveChangesAsync to persist
    public async Task<Order> CreateAsync(int customerId, List<(string product, int qty, decimal price)> lines)
    {
        var order = new Order
        {
            CustomerId = customerId,
            OrderDate  = DateTime.UtcNow,
            Status     = "Pending",
        };
        order.Lines = lines.Select(l => new OrderLine
        {
            ProductName = l.product,
            Quantity    = l.qty,
            UnitPrice   = l.price
        }).ToList();
        order.Total = order.Lines.Sum(l => l.Quantity * l.UnitPrice);

        _db.Orders.Add(order);
        await _db.SaveChangesAsync();  // generates INSERT statements for order + lines
        return order;
    }

    // Update — load the entity, modify it, then SaveChanges (change tracking detects the diff)
    public async Task<bool> ShipAsync(int orderId)
    {
        var order = await _db.Orders.FindAsync(orderId);
        if (order is null || order.Status != "Pending") return false;

        order.Status = "Shipped";      // change tracker marks this property as modified
        await _db.SaveChangesAsync();  // generates UPDATE Orders SET Status='Shipped' WHERE Id=...
        return true;
    }

    // Delete without loading — EF 7+ ExecuteDeleteAsync sends DELETE directly to the DB
    public async Task DeleteAsync(int orderId)
    {
        await _db.Orders
                 .Where(o => o.Id == orderId)
                 .ExecuteDeleteAsync();
    }

    // Bulk update without loading entities — EF 7+ ExecuteUpdateAsync
    public async Task CancelOldPendingOrdersAsync(DateTime cutoff)
    {
        await _db.Orders
                 .Where(o => o.Status == "Pending" && o.OrderDate < cutoff)
                 .ExecuteUpdateAsync(s =>
                     s.SetProperty(o => o.Status, "Cancelled"));
    }
}

Raw SQL

LINQ covers the vast majority of queries, but some operations — complex aggregations, full-text search, stored procedures, or database-specific features — are easier or only possible in raw SQL. EF Core supports raw SQL while still mapping results to entities, and FromSqlInterpolated handles parameterization automatically to prevent SQL injection.

// FromSqlRaw — use when the query returns the full entity shape
var customers = await _db.Customers
    .FromSqlRaw("SELECT * FROM Customers WHERE DATEDIFF(day, CreatedAt, GETUTCDATE()) <= {0}", 30)
    .AsNoTracking()
    .ToListAsync();

// FromSqlInterpolated — uses C# string interpolation but parameterizes safely
string name = "Alice";
var results = await _db.Customers
    .FromSqlInterpolated($"SELECT * FROM Customers WHERE Name LIKE {name + "%"}")
    .ToListAsync();

// SqlQuery<T> — for non-entity result shapes (aggregations, custom projections)
var stats = await _db.Database
    .SqlQuery<OrderStats>($"SELECT Status, COUNT(*) AS Count, SUM(Total) AS Total FROM Orders GROUP BY Status")
    .ToListAsync();

Transactions

By default, each SaveChangesAsync call wraps its changes in a database transaction. When you need multiple SaveChanges calls to succeed or fail together — for example, transferring funds between accounts — you manage the transaction explicitly. The await using pattern ensures the transaction is disposed (and rolled back if not committed) even if an exception is thrown.

public async Task TransferFundsAsync(int fromAccountId, int toAccountId, decimal amount)
{
    // Begin an explicit transaction — both SaveChanges calls join the same transaction
    await using var transaction = await _db.Database.BeginTransactionAsync();
    try
    {
        var from = await _db.Accounts.FindAsync(fromAccountId);
        var to   = await _db.Accounts.FindAsync(toAccountId);

        if (from!.Balance < amount)
            throw new InvalidOperationException("Insufficient funds");

        from.Balance -= amount;
        to!.Balance  += amount;

        await _db.SaveChangesAsync();   // writes both changes
        await transaction.CommitAsync(); // makes them permanent
    }
    catch
    {
        await transaction.RollbackAsync();  // undoes everything if anything fails
        throw;
    }
}

Frequently Asked Questions

What is the difference between Code First and Database First?
Code First means you define your C# model classes and EF generates the database schema from them (via migrations). Database First means you have an existing database and scaffold C# classes from it. Code First is the recommended approach for new projects.
Should I use AsNoTracking?
Use AsNoTracking() for read-only queries where you don't need to update the entities. It skips change tracking, which makes the query faster and uses less memory. Always use it in read-heavy scenarios like API GET endpoints.
What causes the N+1 query problem and how do I fix it?
N+1 happens when you load a collection and then lazily access a navigation property for each item, triggering one extra query per item. Fix it with Include() (eager loading) to load related data in a single JOIN query.