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