What is Entity Framework Core?

Entity Framework Core (EF Core) is an Object-Relational Mapper (ORM) that lets you work with databases using C# objects instead of writing raw SQL. Think of it as a translator between your C# code and your database - you speak C#, it translates to SQL.

Instead of writing SQL like SELECT * FROM Products WHERE Price > 100, you write C# like products.Where(p => p.Price > 100). Much more natural for developers!

Why Use Entity Framework Core?

Imagine building a house. You could dig foundations by hand (raw SQL), or use excavators (EF Core). Both work, but one is much faster and less error-prone:

  • Write C# Instead of SQL: Use familiar LINQ queries instead of string-based SQL - fewer syntax errors, better IDE support
  • Type Safety: Compiler catches database errors before runtime - no more "column not found" at 3 AM
  • Automatic Database Updates: Migrations automatically update database schema when your models change
  • Cross-Database Support: Same code works with SQL Server, PostgreSQL, MySQL, SQLite - just change connection string
  • Navigation Properties: Access related data naturally (order.Customer.Name) instead of complex JOINs
  • Change Tracking: EF Core automatically tracks changes and generates efficient UPDATE statements
  • Performance: Built-in caching, lazy loading, and query optimization

When to Use EF Core?

Entity Framework Core is perfect for:

  • Most Business Applications: CRUD operations, data management, enterprise apps
  • Rapid Development: Quickly prototype and iterate on database schema
  • Code-First Projects: When you want to design your domain models in C# and generate database from code
  • Multi-Database Apps: Need to support SQL Server, PostgreSQL, MySQL with same code
  • Microservices: Each service manages its own database with EF Core

When to use raw SQL instead:

  • Complex reporting with advanced SQL features (CTEs, window functions)
  • Extremely high-performance scenarios where every millisecond counts
  • Legacy databases with complex stored procedures
  • Bulk operations (though EF Core 7+ has improved here)

DbContext: Your Database Gateway

DbContext is the main class for interacting with your database. Think of it as your database connection manager and query factory:

// DEFINE YOUR MODELS (C# classes)
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int CategoryId { get; set; }

    // Navigation property - access related Category
    public Category Category { get; set; }
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }

    // Collection navigation - access all products in this category
    public List Products { get; set; }
}

// DbContext - Your database connection
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions options) : base(options) { }

    // DbSets represent tables
    public DbSet Products { get; set; }
    public DbSet Categories { get; set; }

    // Configure relationships and constraints
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Configure Product
        modelBuilder.Entity(entity =>
        {
            entity.HasKey(p => p.Id);
            entity.Property(p => p.Name).IsRequired().HasMaxLength(200);
            entity.Property(p => p.Price).HasColumnType("decimal(18,2)");

            // Define relationship: Product belongs to Category
            entity.HasOne(p => p.Category)
                  .WithMany(c => c.Products)
                  .HasForeignKey(p => p.CategoryId);
        });

        // Seed data
        modelBuilder.Entity().HasData(
            new Category { Id = 1, Name = "Electronics" },
            new Category { Id = 2, Name = "Books" }
        );
    }
}

// REGISTER IN Program.cs
builder.Services.AddDbContext(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Or PostgreSQL: options.UseNpgsql(connectionString)
// Or SQLite: options.UseSqlite(connectionString)

Code-First vs Database-First

Two approaches to using EF Core:

// CODE-FIRST (Recommended for new projects)
// Write C# classes first, generate database from them

Step 1: Define models in C#
public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Step 2: Add to DbContext
public DbSet Customers { get; set; }

Step 3: Create migration
dotnet ef migrations add InitialCreate

Step 4: Update database
dotnet ef database update

Benefits:
✅ Full control over domain model
✅ Easy version control (migrations are code files)
✅ Great for new projects
✅ Database agnostic

---

// DATABASE-FIRST (For existing databases)
// Start with existing database, generate C# classes from it

Step 1: Have an existing database

Step 2: Scaffold DbContext and models
dotnet ef dbcontext scaffold "Server=localhost;Database=MyDb;..."
    Microsoft.EntityFrameworkCore.SqlServer -o Models

Step 3: Auto-generated models
public partial class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Benefits:
✅ Works with existing databases
✅ Quick setup
✅ Stays in sync with database

Downsides:
⚠️ Models can be overwritten when re-scaffolding
⚠️ Less control over model design

Most modern projects use Code-First because it gives you more control and works better with version control systems.

Migrations: Managing Database Changes

Migrations are like Git commits for your database schema. They track changes over time:

// CREATE INITIAL MIGRATION
dotnet ef migrations add InitialCreate

// This generates a migration file:
public partial class InitialCreate : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "Products",
            columns: table => new
            {
                Id = table.Column(nullable: false)
                    .Annotation("SqlServer:Identity", "1, 1"),
                Name = table.Column(maxLength: 200, nullable: false),
                Price = table.Column(type: "decimal(18,2)", nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_Products", x => x.Id);
            });
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(name: "Products");
    }
}

// APPLY MIGRATION TO DATABASE
dotnet ef database update

// ADD NEW PROPERTY TO MODEL
public class Product
{
    // ... existing properties ...
    public string Description { get; set; }  // NEW!
}

// CREATE MIGRATION FOR CHANGE
dotnet ef migrations add AddProductDescription

// APPLY TO DATABASE
dotnet ef database update

// ROLLBACK MIGRATION
dotnet ef database update PreviousMigrationName

// REMOVE LAST MIGRATION (if not applied)
dotnet ef migrations remove

// VIEW MIGRATION HISTORY
dotnet ef migrations list

Migration Best Practices:

  • Create migrations frequently - one per logical change
  • Review generated SQL before applying to production
  • Test migrations in development first
  • Never modify applied migrations - create new ones instead
  • Commit migration files to source control

LINQ Queries with EF Core

Query databases using natural C# syntax instead of SQL strings:

// INJECT DbContext
public class ProductService
{
    private readonly AppDbContext _context;

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

    // SIMPLE QUERY - Get all products
    public async Task> GetAllProductsAsync()
    {
        return await _context.Products.ToListAsync();
    }

    // FILTER - Where clause
    public async Task> GetExpensiveProductsAsync()
    {
        return await _context.Products
            .Where(p => p.Price > 1000)
            .ToListAsync();
    }

    // SORT - OrderBy
    public async Task> GetProductsByPriceAsync()
    {
        return await _context.Products
            .OrderBy(p => p.Price)
            .ToListAsync();
    }

    // PAGINATION
    public async Task> GetProductsPageAsync(int page, int pageSize)
    {
        return await _context.Products
            .OrderBy(p => p.Name)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();
    }

    // SINGLE ITEM
    public async Task GetProductByIdAsync(int id)
    {
        // FirstOrDefaultAsync returns null if not found
        return await _context.Products
            .FirstOrDefaultAsync(p => p.Id == id);

        // Alternative: FindAsync (only works with primary key)
        // return await _context.Products.FindAsync(id);
    }

    // JOIN - Include related data
    public async Task> GetProductsWithCategoriesAsync()
    {
        return await _context.Products
            .Include(p => p.Category)  // JOIN with Categories
            .ToListAsync();
    }

    // COMPLEX QUERY - Multiple conditions
    public async Task> SearchProductsAsync(string searchTerm, decimal? minPrice, int? categoryId)
    {
        var query = _context.Products.AsQueryable();

        if (!string.IsNullOrEmpty(searchTerm))
            query = query.Where(p => p.Name.Contains(searchTerm));

        if (minPrice.HasValue)
            query = query.Where(p => p.Price >= minPrice.Value);

        if (categoryId.HasValue)
            query = query.Where(p => p.CategoryId == categoryId.Value);

        return await query
            .Include(p => p.Category)
            .OrderBy(p => p.Name)
            .ToListAsync();
    }

    // AGGREGATIONS
    public async Task GetAveragePriceAsync()
    {
        return await _context.Products.AverageAsync(p => p.Price);
    }

    public async Task GetProductCountAsync()
    {
        return await _context.Products.CountAsync();
    }

    // GROUPING
    public async Task> GetCategorySummaryAsync()
    {
        return await _context.Products
            .GroupBy(p => p.Category)
            .Select(g => new CategorySummary
            {
                CategoryName = g.Key.Name,
                ProductCount = g.Count(),
                AveragePrice = g.Average(p => p.Price),
                TotalValue = g.Sum(p => p.Price)
            })
            .ToListAsync();
    }

    // RAW SQL (when needed)
    public async Task> ExecuteRawSqlAsync()
    {
        return await _context.Products
            .FromSqlRaw("SELECT * FROM Products WHERE Price > {0}", 1000)
            .ToListAsync();
    }
}

CRUD Operations

// CREATE
public async Task CreateProductAsync(Product product)
{
    _context.Products.Add(product);
    await _context.SaveChangesAsync();  // Executes INSERT
    return product;  // Id is now populated
}

// READ (covered in LINQ section above)

// UPDATE
public async Task UpdateProductAsync(Product product)
{
    // Option 1: Attach and mark as modified
    _context.Products.Update(product);
    await _context.SaveChangesAsync();

    // Option 2: Fetch, modify, save (better for partial updates)
    var existing = await _context.Products.FindAsync(product.Id);
    if (existing != null)
    {
        existing.Name = product.Name;
        existing.Price = product.Price;
        // EF Core tracks changes automatically
        await _context.SaveChangesAsync();
    }
}

// DELETE
public async Task DeleteProductAsync(int id)
{
    var product = await _context.Products.FindAsync(id);
    if (product != null)
    {
        _context.Products.Remove(product);
        await _context.SaveChangesAsync();
    }
}

// BULK CREATE
public async Task CreateProductsAsync(List products)
{
    _context.Products.AddRange(products);
    await _context.SaveChangesAsync();
}

// BULK DELETE
public async Task DeleteProductsByCategoryAsync(int categoryId)
{
    var products = await _context.Products
        .Where(p => p.CategoryId == categoryId)
        .ToListAsync();

    _context.Products.RemoveRange(products);
    await _context.SaveChangesAsync();
}

Relationships in EF Core

// ONE-TO-MANY (Most common)
// Example: One Category has many Products
public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List Products { get; set; }  // Collection navigation
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CategoryId { get; set; }  // Foreign key
    public Category Category { get; set; }  // Reference navigation
}

// Configuration (optional - EF Core can infer this)
modelBuilder.Entity()
    .HasOne(p => p.Category)
    .WithMany(c => c.Products)
    .HasForeignKey(p => p.CategoryId);

---

// ONE-TO-ONE
// Example: One User has one Profile
public class User
{
    public int Id { get; set; }
    public string Email { get; set; }
    public UserProfile Profile { get; set; }
}

public class UserProfile
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public string Bio { get; set; }
    public User User { get; set; }
}

modelBuilder.Entity()
    .HasOne(u => u.Profile)
    .WithOne(p => p.User)
    .HasForeignKey(p => p.UserId);

---

// MANY-TO-MANY
// Example: Students and Courses
public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List Courses { get; set; }
}

public class Course
{
    public int Id { get; set; }
    public string Title { get; set; }
    public List Students { get; set; }
}

// EF Core 5+ handles join table automatically!
// Creates StudentCourse table behind the scenes

// To access with extra data on join:
public class StudentCourse
{
    public int StudentId { get; set; }
    public Student Student { get; set; }

    public int CourseId { get; set; }
    public Course Course { get; set; }

    public DateTime EnrolledDate { get; set; }
    public int Grade { get; set; }
}

Loading Related Data

// EAGER LOADING - Load related data upfront
var products = await _context.Products
    .Include(p => p.Category)  // Load Category with Product
    .ToListAsync();

// Access without extra query
var categoryName = products[0].Category.Name;

// MULTIPLE LEVELS
var orders = await _context.Orders
    .Include(o => o.Customer)
        .ThenInclude(c => c.Address)
    .Include(o => o.OrderItems)
        .ThenInclude(oi => oi.Product)
    .ToListAsync();

---

// LAZY LOADING (Automatic - requires configuration)
// Install: Microsoft.EntityFrameworkCore.Proxies

builder.Services.AddDbContext(options =>
    options.UseSqlServer(connectionString)
           .UseLazyLoadingProxies());

// Make navigation properties virtual
public virtual Category Category { get; set; }

// Now accessing Category automatically loads it
var product = await _context.Products.FindAsync(1);
var categoryName = product.Category.Name;  // Automatic query!

---

// EXPLICIT LOADING - Load when needed
var product = await _context.Products.FindAsync(1);

// Later, load Category explicitly
await _context.Entry(product)
    .Reference(p => p.Category)
    .LoadAsync();

// Load collection
var category = await _context.Categories.FindAsync(1);
await _context.Entry(category)
    .Collection(c => c.Products)
    .LoadAsync();

Performance Optimization

  • Use AsNoTracking for read-only queries: .AsNoTracking() - faster, less memory
  • Select only needed columns: .Select(p => new { p.Id, p.Name })
  • Use pagination: Don't load thousands of rows at once
  • Avoid N+1 queries: Use .Include() to eager load related data
  • Use compiled queries: For frequently executed queries
  • Enable query splitting: For complex includes
  • Use indexes: Add indexes in OnModelCreating for frequently queried columns
// GOOD - Read-only query
var products = await _context.Products
    .AsNoTracking()
    .Where(p => p.Price > 100)
    .ToListAsync();

// BAD - N+1 Query Problem
var categories = await _context.Categories.ToListAsync();
foreach (var category in categories)
{
    var products = await _context.Products
        .Where(p => p.CategoryId == category.Id)
        .ToListAsync();  // Executes one query per category!
}

// GOOD - Single query with Include
var categories = await _context.Categories
    .Include(c => c.Products)
    .ToListAsync();

Best Practices for EF Core

  • Always use async methods: ToListAsync(), FindAsync(), SaveChangesAsync()
  • Use DbContext per request: Register as Scoped in DI, don't share across requests
  • Handle concurrency: Use row versioning or optimistic concurrency tokens
  • Use transactions for multiple operations: Ensure all succeed or all fail together
  • Configure relationships explicitly: Don't rely solely on conventions
  • Use value objects: For complex types like Address, Money
  • Log SQL queries in development: See what EF Core generates
  • Test migrations: Before applying to production

Master Entity Framework Core with Expert Mentorship

Our Full Stack .NET program covers Entity Framework Core in depth - from basics to advanced optimization. Build real-world data access layers with personalized guidance.

Explore Full Stack .NET Program

Related Articles