Entity Framework Core is the go-to object-relational mapper (ORM) for .NET applications. It lets you work with databases using C# objects instead of writing raw SQL queries. Whether you are building a small application or a large enterprise system, understanding EF Core deeply will make you a more effective developer.

This guide covers everything from setting up your first DbContext to implementing advanced patterns used in production applications.

Setting Up Entity Framework Core

First, install the required NuGet packages for your database provider. For SQL Server:

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

For SQLite:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite

For PostgreSQL:

dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

Creating Your First DbContext

The DbContext is the main class that coordinates Entity Framework functionality for your data model:

using Microsoft.EntityFrameworkCore;

namespace MyApp.Data;

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

    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // Configure Customer entity
        modelBuilder.Entity<Customer>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
            entity.Property(e => e.Email).HasMaxLength(255).IsRequired();
            entity.HasIndex(e => e.Email).IsUnique();
        });

        // Configure Order entity
        modelBuilder.Entity<Order>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.OrderDate).IsRequired();
            entity.Property(e => e.TotalAmount).HasPrecision(18, 2);
            
            entity.HasOne(e => e.Customer)
                .WithMany(c => c.Orders)
                .HasForeignKey(e => e.CustomerId)
                .OnDelete(DeleteBehavior.Cascade);
        });

        // Configure Product entity
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
            entity.Property(e => e.Price).HasPrecision(18, 2);
            entity.Property(e => e.Sku).HasMaxLength(50);
            entity.HasIndex(e => e.Sku).IsUnique();
        });

        // Configure OrderItem entity
        modelBuilder.Entity<OrderItem>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Quantity).IsRequired();
            entity.Property(e => e.UnitPrice).HasPrecision(18, 2);

            entity.HasOne(e => e.Order)
                .WithMany(o => o.Items)
                .HasForeignKey(e => e.OrderId)
                .OnDelete(DeleteBehavior.Cascade);

            entity.HasOne(e => e.Product)
                .WithMany()
                .HasForeignKey(e => e.ProductId)
                .OnDelete(DeleteBehavior.Restrict);
        });
    }
}

Defining Entity Classes

Here are the entity classes that match our DbContext configuration:

namespace MyApp.Data.Entities;

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string? Phone { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public bool IsActive { get; set; } = true;

    public ICollection<Order> Orders { get; set; } = new List<Order>();
}

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public DateTime OrderDate { get; set; } = DateTime.UtcNow;
    public decimal TotalAmount { get; set; }
    public OrderStatus Status { get; set; } = OrderStatus.Pending;

    public Customer Customer { get; set; } = null!;
    public ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public decimal Price { get; set; }
    public string? Sku { get; set; }
    public int StockQuantity { get; set; }
    public bool IsAvailable { get; set; } = true;
}

public class OrderItem
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }

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

public enum OrderStatus
{
    Pending,
    Processing,
    Shipped,
    Delivered,
    Cancelled
}

Registering DbContext in ASP.NET Core

In your Program.cs file, register the DbContext with the dependency injection container:

var builder = WebApplication.CreateBuilder(args);

// SQL Server
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// Or SQLite
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));

// Or PostgreSQL
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

Working with Migrations

Migrations let you evolve your database schema over time. Create your first migration:

dotnet ef migrations add InitialCreate
dotnet ef database update

When you change your entities, create a new migration:

dotnet ef migrations add AddCustomerPhone
dotnet ef database update

To revert a migration:

dotnet ef database update PreviousMigrationName
dotnet ef migrations remove

Basic CRUD Operations

Let us create a service that handles common database operations:

using Microsoft.EntityFrameworkCore;

namespace MyApp.Services;

public class CustomerService
{
    private readonly ApplicationDbContext _context;

    public CustomerService(ApplicationDbContext context)
    {
        _context = context;
    }

    // Create
    public async Task<Customer> CreateCustomerAsync(string name, string email, string? phone = null)
    {
        var customer = new Customer
        {
            Name = name,
            Email = email,
            Phone = phone
        };

        _context.Customers.Add(customer);
        await _context.SaveChangesAsync();

        return customer;
    }

    // Read - Single
    public async Task<Customer?> GetCustomerByIdAsync(int id)
    {
        return await _context.Customers
            .Include(c => c.Orders)
            .FirstOrDefaultAsync(c => c.Id == id);
    }

    // Read - List with filtering and pagination
    public async Task<List<Customer>> GetCustomersAsync(
        string? searchTerm = null,
        bool? isActive = null,
        int page = 1,
        int pageSize = 10)
    {
        var query = _context.Customers.AsQueryable();

        if (!string.IsNullOrEmpty(searchTerm))
        {
            query = query.Where(c => 
                c.Name.Contains(searchTerm) || 
                c.Email.Contains(searchTerm));
        }

        if (isActive.HasValue)
        {
            query = query.Where(c => c.IsActive == isActive.Value);
        }

        return await query
            .OrderBy(c => c.Name)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();
    }

    // Update
    public async Task<Customer?> UpdateCustomerAsync(int id, string name, string email, string? phone)
    {
        var customer = await _context.Customers.FindAsync(id);

        if (customer is null)
        {
            return null;
        }

        customer.Name = name;
        customer.Email = email;
        customer.Phone = phone;

        await _context.SaveChangesAsync();

        return customer;
    }

    // Delete
    public async Task<bool> DeleteCustomerAsync(int id)
    {
        var customer = await _context.Customers.FindAsync(id);

        if (customer is null)
        {
            return false;
        }

        _context.Customers.Remove(customer);
        await _context.SaveChangesAsync();

        return true;
    }
}

Advanced Querying Techniques

EF Core provides powerful querying capabilities. Here are some advanced patterns:

public class OrderService
{
    private readonly ApplicationDbContext _context;

    public OrderService(ApplicationDbContext context)
    {
        _context = context;
    }

    // Eager loading with Include and ThenInclude
    public async Task<Order?> GetOrderWithDetailsAsync(int orderId)
    {
        return await _context.Orders
            .Include(o => o.Customer)
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .FirstOrDefaultAsync(o => o.Id == orderId);
    }

    // Projection with Select
    public async Task<List<OrderSummaryDto>> GetOrderSummariesAsync(int customerId)
    {
        return await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .Select(o => new OrderSummaryDto
            {
                OrderId = o.Id,
                OrderDate = o.OrderDate,
                TotalAmount = o.TotalAmount,
                Status = o.Status.ToString(),
                ItemCount = o.Items.Count
            })
            .ToListAsync();
    }

    // Aggregation queries
    public async Task<CustomerStatsDto> GetCustomerStatsAsync(int customerId)
    {
        return await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .GroupBy(o => o.CustomerId)
            .Select(g => new CustomerStatsDto
            {
                CustomerId = g.Key,
                TotalOrders = g.Count(),
                TotalSpent = g.Sum(o => o.TotalAmount),
                AverageOrderValue = g.Average(o => o.TotalAmount),
                FirstOrderDate = g.Min(o => o.OrderDate),
                LastOrderDate = g.Max(o => o.OrderDate)
            })
            .FirstOrDefaultAsync() ?? new CustomerStatsDto { CustomerId = customerId };
    }

    // Complex filtering with expression trees
    public async Task<List<Order>> SearchOrdersAsync(OrderSearchCriteria criteria)
    {
        var query = _context.Orders
            .Include(o => o.Customer)
            .Include(o => o.Items)
            .AsQueryable();

        if (criteria.CustomerId.HasValue)
        {
            query = query.Where(o => o.CustomerId == criteria.CustomerId.Value);
        }

        if (criteria.MinAmount.HasValue)
        {
            query = query.Where(o => o.TotalAmount >= criteria.MinAmount.Value);
        }

        if (criteria.MaxAmount.HasValue)
        {
            query = query.Where(o => o.TotalAmount <= criteria.MaxAmount.Value);
        }

        if (criteria.StartDate.HasValue)
        {
            query = query.Where(o => o.OrderDate >= criteria.StartDate.Value);
        }

        if (criteria.EndDate.HasValue)
        {
            query = query.Where(o => o.OrderDate <= criteria.EndDate.Value);
        }

        if (criteria.Statuses?.Any() == true)
        {
            query = query.Where(o => criteria.Statuses.Contains(o.Status));
        }

        return await query
            .OrderByDescending(o => o.OrderDate)
            .Take(criteria.MaxResults)
            .ToListAsync();
    }

    // Raw SQL when needed
    public async Task<List<TopProductDto>> GetTopSellingProductsAsync(int count = 10)
    {
        return await _context.Database
            .SqlQuery<TopProductDto>($"""
                SELECT 
                    p.Id as ProductId,
                    p.Name as ProductName,
                    SUM(oi.Quantity) as TotalQuantitySold,
                    SUM(oi.Quantity * oi.UnitPrice) as TotalRevenue
                FROM Products p
                INNER JOIN OrderItems oi ON p.Id = oi.ProductId
                INNER JOIN Orders o ON oi.OrderId = o.Id
                WHERE o.Status != {(int)OrderStatus.Cancelled}
                GROUP BY p.Id, p.Name
                ORDER BY TotalQuantitySold DESC
                LIMIT {count}
                """)
            .ToListAsync();
    }
}

public record OrderSummaryDto
{
    public int OrderId { get; init; }
    public DateTime OrderDate { get; init; }
    public decimal TotalAmount { get; init; }
    public string Status { get; init; } = string.Empty;
    public int ItemCount { get; init; }
}

public record CustomerStatsDto
{
    public int CustomerId { get; init; }
    public int TotalOrders { get; init; }
    public decimal TotalSpent { get; init; }
    public decimal AverageOrderValue { get; init; }
    public DateTime? FirstOrderDate { get; init; }
    public DateTime? LastOrderDate { get; init; }
}

public record OrderSearchCriteria
{
    public int? CustomerId { get; init; }
    public decimal? MinAmount { get; init; }
    public decimal? MaxAmount { get; init; }
    public DateTime? StartDate { get; init; }
    public DateTime? EndDate { get; init; }
    public List<OrderStatus>? Statuses { get; init; }
    public int MaxResults { get; init; } = 100;
}

public record TopProductDto
{
    public int ProductId { get; init; }
    public string ProductName { get; init; } = string.Empty;
    public int TotalQuantitySold { get; init; }
    public decimal TotalRevenue { get; init; }
}

Implementing the Repository Pattern

The repository pattern abstracts the data access layer and makes your code more testable:

namespace MyApp.Data.Repositories;

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();
    Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
    Task AddAsync(T entity);
    Task AddRangeAsync(IEnumerable<T> entities);
    void Update(T entity);
    void Remove(T entity);
    void RemoveRange(IEnumerable<T> entities);
}

public class Repository<T> : IRepository<T> where T : class
{
    protected readonly ApplicationDbContext _context;
    protected readonly DbSet<T> _dbSet;

    public Repository(ApplicationDbContext context)
    {
        _context = context;
        _dbSet = context.Set<T>();
    }

    public virtual async Task<T?> GetByIdAsync(int id)
    {
        return await _dbSet.FindAsync(id);
    }

    public virtual async Task<IEnumerable<T>> GetAllAsync()
    {
        return await _dbSet.ToListAsync();
    }

    public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
    {
        return await _dbSet.Where(predicate).ToListAsync();
    }

    public virtual async Task AddAsync(T entity)
    {
        await _dbSet.AddAsync(entity);
    }

    public virtual async Task AddRangeAsync(IEnumerable<T> entities)
    {
        await _dbSet.AddRangeAsync(entities);
    }

    public virtual void Update(T entity)
    {
        _dbSet.Update(entity);
    }

    public virtual void Remove(T entity)
    {
        _dbSet.Remove(entity);
    }

    public virtual void RemoveRange(IEnumerable<T> entities)
    {
        _dbSet.RemoveRange(entities);
    }
}

Unit of Work Pattern

The Unit of Work pattern coordinates multiple repositories and manages transactions:

namespace MyApp.Data;

public interface IUnitOfWork : IDisposable
{
    IRepository<Customer> Customers { get; }
    IRepository<Order> Orders { get; }
    IRepository<Product> Products { get; }
    IRepository<OrderItem> OrderItems { get; }
    Task<int> SaveChangesAsync();
    Task BeginTransactionAsync();
    Task CommitTransactionAsync();
    Task RollbackTransactionAsync();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly ApplicationDbContext _context;
    private IDbContextTransaction? _transaction;

    private IRepository<Customer>? _customers;
    private IRepository<Order>? _orders;
    private IRepository<Product>? _products;
    private IRepository<OrderItem>? _orderItems;

    public UnitOfWork(ApplicationDbContext context)
    {
        _context = context;
    }

    public IRepository<Customer> Customers =>
        _customers ??= new Repository<Customer>(_context);

    public IRepository<Order> Orders =>
        _orders ??= new Repository<Order>(_context);

    public IRepository<Product> Products =>
        _products ??= new Repository<Product>(_context);

    public IRepository<OrderItem> OrderItems =>
        _orderItems ??= new Repository<OrderItem>(_context);

    public async Task<int> SaveChangesAsync()
    {
        return await _context.SaveChangesAsync();
    }

    public async Task BeginTransactionAsync()
    {
        _transaction = await _context.Database.BeginTransactionAsync();
    }

    public async Task CommitTransactionAsync()
    {
        if (_transaction is not null)
        {
            await _transaction.CommitAsync();
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }

    public async Task RollbackTransactionAsync()
    {
        if (_transaction is not null)
        {
            await _transaction.RollbackAsync();
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }

    public void Dispose()
    {
        _transaction?.Dispose();
        _context.Dispose();
    }
}

Query Performance Optimization

Optimizing EF Core queries is critical for application performance:

public class OptimizedQueryExamples
{
    private readonly ApplicationDbContext _context;

    public OptimizedQueryExamples(ApplicationDbContext context)
    {
        _context = context;
    }

    // Use AsNoTracking for read-only queries
    public async Task<List<Customer>> GetCustomersReadOnlyAsync()
    {
        return await _context.Customers
            .AsNoTracking()
            .ToListAsync();
    }

    // Use AsSplitQuery for complex includes to avoid cartesian explosion
    public async Task<List<Order>> GetOrdersWithSplitQueryAsync()
    {
        return await _context.Orders
            .Include(o => o.Customer)
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .AsSplitQuery()
            .ToListAsync();
    }

    // Project only needed fields
    public async Task<List<CustomerListItemDto>> GetCustomerListAsync()
    {
        return await _context.Customers
            .AsNoTracking()
            .Select(c => new CustomerListItemDto
            {
                Id = c.Id,
                Name = c.Name,
                Email = c.Email,
                OrderCount = c.Orders.Count
            })
            .ToListAsync();
    }

    // Batch operations for better performance
    public async Task UpdateProductPricesAsync(decimal percentageIncrease)
    {
        await _context.Products
            .ExecuteUpdateAsync(setters => setters
                .SetProperty(p => p.Price, p => p.Price * (1 + percentageIncrease / 100)));
    }

    // Bulk delete
    public async Task DeleteInactiveCustomersAsync()
    {
        await _context.Customers
            .Where(c => !c.IsActive && c.Orders.Count == 0)
            .ExecuteDeleteAsync();
    }
}

public record CustomerListItemDto
{
    public int Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public string Email { get; init; } = string.Empty;
    public int OrderCount { get; init; }
}

Handling Concurrency

EF Core provides built-in support for optimistic concurrency:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}

public class ProductService
{
    private readonly ApplicationDbContext _context;

    public ProductService(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<bool> UpdateProductAsync(int id, decimal newPrice)
    {
        var product = await _context.Products.FindAsync(id);

        if (product is null)
        {
            return false;
        }

        product.Price = newPrice;

        try
        {
            await _context.SaveChangesAsync();
            return true;
        }
        catch (DbUpdateConcurrencyException)
        {
            // Handle the concurrency conflict
            return false;
        }
    }
}

Conclusion

Entity Framework Core is a powerful tool that can handle everything from simple CRUD operations to complex enterprise scenarios. The key to using it effectively is understanding when to use each feature and how to optimize your queries for performance.

Remember these principles:

  • Use AsNoTracking for read-only queries
  • Project only the fields you need with Select
  • Use AsSplitQuery for complex includes
  • Implement the repository and unit of work patterns for better testability
  • Handle concurrency appropriately for your use case
  • Use ExecuteUpdate and ExecuteDelete for bulk operations

With these patterns and techniques, you can build efficient, maintainable data access layers in your ASP.NET Core applications.