Entity Framework Core
What is Entity Framework Core
Entity Framework Core (EF Core) is an object-relational mapper (ORM) that enables .NET developers to work with databases using .NET objects. It eliminates most data-access code that developers typically need to write.
// Traditional SQL
var sql = "SELECT * FROM Customers WHERE Country = @Country";
var customers = connection.Query<Customer>(sql, new { Country = "USA" });
// EF Core - type-safe, refactor-friendly
var customers = await context.Customers
.Where(c => c.Country == "USA")
.ToListAsync();
DbContext
The DbContext is the primary class for interacting with the database.
Basic DbContext
public class ApplicationDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configure entities
modelBuilder.Entity<Customer>(entity =>
{
entity.HasKey(c => c.Id);
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
entity.HasIndex(c => c.Email).IsUnique();
});
}
}
Registration (Dependency Injection)
// Program.cs or Startup.cs
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
// With additional configuration
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(3);
sqlOptions.CommandTimeout(30);
});
if (environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
Entity Configuration
EF Core provides three ways to configure your model, listed here in order of precedence (highest to lowest):
- Fluent API - Configuration in
OnModelCreatingorIEntityTypeConfiguration<T>classes - Data Annotations - Attributes applied directly to entity classes
- Conventions - Automatic rules EF Core applies by default
When configurations conflict, higher precedence wins. For example, a Fluent API configuration overrides any Data Annotation on the same property.
Fluent API (Recommended)
- Keeps domain models clean (POCOs)
- More powerful (filtered indexes, cascade behavior)
- Centralized configuration
- Configuration classes can be unit tested
Data Annotations
- Dual-purpose validation (EF + ASP.NET)
- Self-documenting entities
- Less boilerplate for simple scenarios
- Suitable for prototypes and simple CRUD apps
Why Fluent API is Recommended
Microsoft recommends Fluent API as the primary configuration approach for several reasons:
Keeps domain models clean: Entity classes remain plain C# objects (POCOs) without infrastructure attributes. This matters when your domain layer shouldn’t depend on EF Core or when the same classes are used across multiple contexts.
More powerful: Fluent API supports configurations that Data Annotations cannot express, including filtered indexes, cascade delete behavior, inheritance mapping strategies, and complex relationship configurations.
Centralized configuration: All database mapping lives in one place rather than scattered across entity classes. This makes it easier to review, modify, and understand the complete data model.
Testability: Configuration classes can be unit tested independently of the entities they configure.
When Data Annotations Make Sense
Data Annotations still have valid uses:
Dual-purpose validation: Attributes like [Required] and [MaxLength] work with both EF Core and ASP.NET model validation. If you need the same constraint enforced at both layers, annotations avoid duplication.
Self-documenting entities: Seeing [MaxLength(100)] directly on a property communicates the constraint without looking elsewhere. This can help when entities are shared across teams.
Simple scenarios: For quick prototypes or straightforward CRUD applications where architectural purity isn’t a priority, annotations reduce boilerplate.
Data Annotations
public class Customer
{
public int Id { get; set; }
[Required]
[MaxLength(100)]
public string Name { get; set; } = "";
[EmailAddress]
public string? Email { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal CreditLimit { get; set; }
[NotMapped]
public string DisplayName => $"{Name} ({Email})";
}
Fluent API
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(entity =>
{
entity.ToTable("Customers");
entity.HasKey(c => c.Id);
entity.Property(c => c.Name)
.IsRequired()
.HasMaxLength(100);
entity.Property(c => c.Email)
.HasMaxLength(255);
entity.Property(c => c.CreditLimit)
.HasPrecision(18, 2);
entity.HasIndex(c => c.Email)
.IsUnique()
.HasFilter("[Email] IS NOT NULL");
entity.Ignore(c => c.DisplayName);
});
}
Entity Type Configuration Classes
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.ToTable("Customers");
builder.HasKey(c => c.Id);
builder.Property(c => c.Name).IsRequired().HasMaxLength(100);
builder.HasIndex(c => c.Email).IsUnique();
}
}
// In OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new CustomerConfiguration());
// Or apply all configurations from assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}
Relationships
One-to-Many
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
// Navigation property
public ICollection<Order> Orders { get; set; } = new List<Order>();
}
public class Order
{
public int Id { get; set; }
public DateTime OrderDate { get; set; }
// Foreign key
public int CustomerId { get; set; }
// Navigation property
public Customer Customer { get; set; } = null!;
}
// Configuration
modelBuilder.Entity<Order>()
.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
One-to-One
public class Customer
{
public int Id { get; set; }
public CustomerAddress? Address { get; set; }
}
public class CustomerAddress
{
public int Id { get; set; }
public string Street { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
}
// Configuration
modelBuilder.Entity<Customer>()
.HasOne(c => c.Address)
.WithOne(a => a.Customer)
.HasForeignKey<CustomerAddress>(a => a.CustomerId);
Many-to-Many
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Course> Courses { get; set; } = new List<Course>();
}
public class Course
{
public int Id { get; set; }
public string Title { get; set; }
public ICollection<Student> Students { get; set; } = new List<Student>();
}
// EF Core 5+ automatically creates join table
// Or explicit join entity:
public class StudentCourse
{
public int StudentId { get; set; }
public Student Student { get; set; } = null!;
public int CourseId { get; set; }
public Course Course { get; set; } = null!;
public DateTime EnrollmentDate { get; set; }
}
Querying Data
Basic Queries
// Get all
var customers = await context.Customers.ToListAsync();
// Filter
var activeCustomers = await context.Customers
.Where(c => c.IsActive)
.ToListAsync();
// Find by primary key (cached if already tracked)
var customer = await context.Customers.FindAsync(id);
// Single result
var customer = await context.Customers
.FirstOrDefaultAsync(c => c.Email == email);
// Projection
var names = await context.Customers
.Select(c => c.Name)
.ToListAsync();
// Anonymous type projection
var summaries = await context.Customers
.Select(c => new { c.Id, c.Name, OrderCount = c.Orders.Count })
.ToListAsync();
Loading Related Data
// Eager loading - single query with JOIN
var customers = await context.Customers
.Include(c => c.Orders)
.ThenInclude(o => o.OrderItems)
.ToListAsync();
// Filtered include (EF Core 5+)
var customers = await context.Customers
.Include(c => c.Orders.Where(o => o.Status == OrderStatus.Active))
.ToListAsync();
// Explicit loading
var customer = await context.Customers.FindAsync(id);
await context.Entry(customer)
.Collection(c => c.Orders)
.LoadAsync();
// Lazy loading (requires proxy package)
// Navigation properties auto-load when accessed
Pagination
public async Task<PagedResult<Customer>> GetPagedAsync(int page, int pageSize)
{
var query = context.Customers.AsQueryable();
var totalCount = await query.CountAsync();
var items = await query
.OrderBy(c => c.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<Customer>
{
Items = items,
TotalCount = totalCount,
Page = page,
PageSize = pageSize
};
}
Raw SQL
// Query with FromSqlRaw
var customers = await context.Customers
.FromSqlRaw("SELECT * FROM Customers WHERE Country = {0}", country)
.ToListAsync();
// Interpolated (parameterized)
var customers = await context.Customers
.FromSqlInterpolated($"SELECT * FROM Customers WHERE Country = {country}")
.ToListAsync();
// Non-query execution
await context.Database.ExecuteSqlRawAsync(
"UPDATE Customers SET IsActive = 0 WHERE LastOrderDate < {0}",
cutoffDate);
Saving Data
Adding Entities
// Single entity
var customer = new Customer { Name = "Alice", Email = "alice@example.com" };
context.Customers.Add(customer);
await context.SaveChangesAsync();
// Multiple entities
var customers = new List<Customer>
{
new() { Name = "Bob" },
new() { Name = "Charlie" }
};
context.Customers.AddRange(customers);
await context.SaveChangesAsync();
// With related entities
var order = new Order
{
Customer = new Customer { Name = "Alice" },
Items = new List<OrderItem>
{
new() { ProductId = 1, Quantity = 2 }
}
};
context.Orders.Add(order);
await context.SaveChangesAsync();
Updating Entities
// Tracked entity
var customer = await context.Customers.FindAsync(id);
customer.Name = "Updated Name";
await context.SaveChangesAsync();
// Disconnected entity
public async Task UpdateCustomerAsync(Customer customer)
{
context.Customers.Update(customer);
await context.SaveChangesAsync();
}
// Partial update
var customer = await context.Customers.FindAsync(id);
context.Entry(customer).Property(c => c.Name).IsModified = true;
await context.SaveChangesAsync();
// ExecuteUpdate (EF Core 7+) - bulk update without loading
await context.Customers
.Where(c => c.IsActive == false)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.Status, "Archived")
.SetProperty(c => c.ArchivedAt, DateTime.UtcNow));
Deleting Entities
// Tracked entity
var customer = await context.Customers.FindAsync(id);
context.Customers.Remove(customer);
await context.SaveChangesAsync();
// Without loading
var customer = new Customer { Id = id };
context.Customers.Remove(customer);
await context.SaveChangesAsync();
// ExecuteDelete (EF Core 7+) - bulk delete without loading
await context.Customers
.Where(c => c.LastOrderDate < cutoffDate)
.ExecuteDeleteAsync();
Transactions
// Implicit transaction (SaveChanges is transactional)
context.Customers.Add(new Customer { Name = "Alice" });
context.Orders.Add(new Order { CustomerId = 1 });
await context.SaveChangesAsync(); // Both or neither
// Explicit transaction
using var transaction = await context.Database.BeginTransactionAsync();
try
{
var customer = new Customer { Name = "Alice" };
context.Customers.Add(customer);
await context.SaveChangesAsync();
var order = new Order { CustomerId = customer.Id };
context.Orders.Add(order);
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
// Transaction with execution strategy (for retries)
var strategy = context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
using var transaction = await context.Database.BeginTransactionAsync();
// ... operations
await transaction.CommitAsync();
});
Migrations
Creating Migrations
# Create migration
dotnet ef migrations add InitialCreate
# With specific context
dotnet ef migrations add AddCustomerEmail -c ApplicationDbContext
# Generate SQL script
dotnet ef migrations script
# Apply migrations
dotnet ef database update
Migration in Code
// Apply pending migrations at startup
using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await context.Database.MigrateAsync();
// Or ensure database exists
await context.Database.EnsureCreatedAsync();
Custom Migration Operations
public partial class AddFullTextIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
CREATE FULLTEXT INDEX ON Products(Name, Description)
KEY INDEX PK_Products");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP FULLTEXT INDEX ON Products");
}
}
Performance Best Practices
No-Tracking Queries
// When you don't need to modify entities
var customers = await context.Customers
.AsNoTracking()
.Where(c => c.IsActive)
.ToListAsync();
// Global no-tracking
services.AddDbContext<ApplicationDbContext>(options =>
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
.UseSqlServer(connectionString));
Split Queries
// Single query with Include can have Cartesian explosion
// Split into multiple queries
var customers = await context.Customers
.Include(c => c.Orders)
.ThenInclude(o => o.Items)
.AsSplitQuery() // Generates multiple SQL queries
.ToListAsync();
// Global split query behavior
modelBuilder.Entity<Customer>()
.Navigation(c => c.Orders)
.AutoInclude()
.UsePropertyAccessMode(PropertyAccessMode.Property);
Select Only What You Need
// BAD - loads entire entity
var emails = await context.Customers
.ToListAsync()
.Select(c => c.Email);
// GOOD - SQL only selects Email
var emails = await context.Customers
.Select(c => c.Email)
.ToListAsync();
// DTO projection
var dtos = await context.Customers
.Select(c => new CustomerDto
{
Id = c.Id,
Name = c.Name,
OrderCount = c.Orders.Count
})
.ToListAsync();
Compiled Queries
private static readonly Func<ApplicationDbContext, int, Task<Customer?>> GetCustomerById =
EF.CompileAsyncQuery((ApplicationDbContext context, int id) =>
context.Customers.FirstOrDefault(c => c.Id == id));
// Usage
var customer = await GetCustomerById(context, customerId);
Avoid N+1 Problems
The N+1 problem is one of the most common performance issues in EF Core applications.
N+1 Problem (Bad)
// Triggers N+1 queries
var customers = await context.Customers
.ToListAsync();
foreach (var customer in customers)
{
// Each iteration = 1 query
var orders = customer.Orders.ToList();
}
Results in 1 query for customers + N queries for orders (one per customer).
Eager Loading (Good)
// Single query with JOIN
var customers = await context.Customers
.Include(c => c.Orders)
.ToListAsync();
Results in 1 query that joins customers with orders.
// BAD - N+1 queries
var customers = await context.Customers.ToListAsync();
foreach (var customer in customers)
{
// Each iteration triggers a query
var orders = customer.Orders.ToList();
}
// GOOD - Single query with Include
var customers = await context.Customers
.Include(c => c.Orders)
.ToListAsync();
// Or explicit projection
var customerOrders = await context.Customers
.Select(c => new
{
Customer = c,
Orders = c.Orders.ToList()
})
.ToListAsync();
Concurrency
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public int Stock { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = null!;
}
// Handling concurrency conflicts
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var proposedValues = entry.CurrentValues;
var databaseValues = await entry.GetDatabaseValuesAsync();
// Client wins
entry.OriginalValues.SetValues(databaseValues);
// Or database wins
entry.Reload();
}
}
Value Conversions
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion(
v => v.ToString(), // To database
v => Enum.Parse<OrderStatus>(v)); // From database
// Built-in converters
modelBuilder.Entity<Customer>()
.Property(c => c.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v, default(JsonSerializerOptions)),
v => JsonSerializer.Deserialize<List<string>>(v, default(JsonSerializerOptions))!);
// Reusable converter
public class JsonValueConverter<T> : ValueConverter<T, string>
{
public JsonValueConverter()
: base(
v => JsonSerializer.Serialize(v, default(JsonSerializerOptions)),
v => JsonSerializer.Deserialize<T>(v, default(JsonSerializerOptions))!)
{
}
}
Version History
| Feature | Version | Significance |
|---|---|---|
| EF Core | 1.0 | Cross-platform ORM |
| Global query filters | 2.0 | Automatic filtering |
| Owned entities | 2.0 | Value objects |
| Lazy loading | 2.1 | Automatic relation loading |
| Many-to-many | 5.0 | Skip navigation properties |
| Filtered includes | 5.0 | Include with Where |
| Split queries | 5.0 | Avoid Cartesian explosion |
| Compiled models | 6.0 | Faster startup |
| ExecuteUpdate/Delete | 7.0 | Bulk operations |
| JSON columns | 7.0 | Native JSON support |
Key Takeaways
Use AsNoTracking for read-only queries: Significant performance improvement when you don’t need to modify entities.
Select projections over full entities: Only load the columns you need.
Use Include for related data: Avoid N+1 queries by eagerly loading relationships.
Understand change tracking: EF tracks entities retrieved from the database and detects changes automatically.
Use migrations for schema changes: Migrations provide version control for your database schema.
Handle concurrency: Use row versions for optimistic concurrency in multi-user scenarios.
Consider bulk operations: ExecuteUpdate/Delete for large-scale changes without loading entities.
Found this guide helpful? Share it with your team:
Share on LinkedIn