Entity Framework Core (EF Core) is a lightweight, extensible, open-source, and cross-platform version of Entity Framework, Microsoft's Object-Relational Mapper (ORM) for .NET.
Key Differences:
| Aspect | EF Core | EF 6 |
|---|---|---|
| Platform | Cross-platform (.NET Core, .NET 5+) | Windows only (.NET Framework) |
| Performance | Faster, more efficient | Slower |
| Features | Modern, lightweight | Feature-rich, mature |
| LINQ Translation | Improved client/server evaluation | Limited |
| Batching | Better batch operations | Limited batching |
| Global Query Filters | Supported | Not supported |
| Shadow Properties | Enhanced support | Limited |
| Owned Entities | Better support | Limited |
| Database Providers | More providers available | Fewer providers |
What EF Core doesn't have (from EF6):
- Lazy loading by default (needs configuration)
- Automatic migrations
- ObjectContext API
- Entity splitting
- Some inheritance strategies
Related Resources
Code First Approach:
- Define your domain model classes first
- EF generates the database schema from your code
- Uses migrations to evolve the database
- Better for new projects and version control
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
public class AppDbContext : DbContext
{
public DbSet Products { get; set; }
}
// Generate migration: dotnet ef migrations add InitialCreate
// Update database: dotnet ef database update
Database First Approach:
- Start with an existing database
- Generate entity classes from the database
- Uses scaffolding to create models
- Better for existing databases
# Scaffold from existing database
dotnet ef dbcontext scaffold "ConnectionString" Microsoft.EntityFrameworkCore.SqlServer -o Models
When to use each:
- Code First: New projects, agile development, domain-driven design
- Database First: Legacy databases, DBA-controlled schemas, multiple applications sharing one database
Related Resources
1. Eager Loading
Load related data as part of the initial query using Include().
// Single level
var orders = context.Orders
.Include(o => o.Customer)
.ToList();
// Multiple levels
var orders = context.Orders
.Include(o => o.Customer)
.Include(o => o.OrderItems)
.ThenInclude(oi => oi.Product)
.ToList();
2. Lazy Loading
Related data is automatically loaded when accessed. Requires Microsoft.EntityFrameworkCore.Proxies.
// Enable lazy loading
services.AddDbContext(options =>
options.UseLazyLoadingProxies()
.UseSqlServer(connectionString));
// Make navigation properties virtual
public class Order
{
public int Id { get; set; }
public virtual Customer Customer { get; set; }
public virtual ICollection OrderItems { get; set; }
}
// Data loaded when accessed
var order = context.Orders.First();
var customerName = order.Customer.Name; // Triggers database query
3. Explicit Loading
Manually load related data when needed using Load().
var order = context.Orders.First();
// Load single reference
context.Entry(order)
.Reference(o => o.Customer)
.Load();
// Load collection
context.Entry(order)
.Collection(o => o.OrderItems)
.Load();
// Load with filter
context.Entry(order)
.Collection(o => o.OrderItems)
.Query()
.Where(oi => oi.Quantity > 5)
.Load();
Comparison:
| Type | Pros | Cons | Use Case |
|---|---|---|---|
| Eager | Single query, no N+1 problem | Can over-fetch data | Known data needs |
| Lazy | Load only what's needed | N+1 problem, requires open connection | Exploratory operations |
| Explicit | Fine-grained control | More code, manual management | Conditional loading |
Related Resources
Migrations track changes to your data model and update the database schema.
1. Basic Migration Commands:
# Add a new migration
dotnet ef migrations add MigrationName
# Update database to latest migration
dotnet ef database update
# Update to specific migration
dotnet ef database update MigrationName
# Remove last migration (if not applied)
dotnet ef migrations remove
# Generate SQL script
dotnet ef migrations script
# List all migrations
dotnet ef migrations list
2. Migration Strategies:
A. Automatic Migrations (Development)
public static void Main(string[] args)
{
var host = CreateHostBuilder(args).Build();
using (var scope = host.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService();
context.Database.Migrate(); // Apply pending migrations
}
host.Run();
}
B. Manual SQL Scripts (Production)
# Generate SQL script for deployment
dotnet ef migrations script --idempotent --output migration.sql
C. Custom Migration Code
public partial class AddProductIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Products_Name",
table: "Products",
column: "Name");
// Custom SQL
migrationBuilder.Sql(@"
UPDATE Products
SET Price = Price * 1.1
WHERE CategoryId = 1
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Products_Name",
table: "Products");
}
}
D. Data Seeding in Migrations
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Categories",
columns: new[] { "Id", "Name" },
values: new object[,]
{
{ 1, "Electronics" },
{ 2, "Books" },
{ 3, "Clothing" }
});
}
Best Practices:
- Keep migrations small and focused
- Test migrations in development first
- Use
--idempotentscripts for production - Never modify applied migrations
- Keep migration history in source control
Related Resources
Repository Pattern Abstracts data access logic and provides a collection-like interface for accessing domain objects.
public interface IRepository where T : class
{
Task GetByIdAsync(int id);
Task<IEnumerable> GetAllAsync();
Task<IEnumerable> FindAsync(Expression<Func> predicate);
Task AddAsync(T entity);
void Update(T entity);
void Remove(T entity);
}
public class Repository : IRepository where T : class
{
protected readonly DbContext _context;
protected readonly DbSet _dbSet;
public Repository(DbContext context)
{
_context = context;
_dbSet = context.Set();
}
public async Task GetByIdAsync(int id)
{
return await _dbSet.FindAsync(id);
}
public async Task<IEnumerable> GetAllAsync()
{
return await _dbSet.ToListAsync();
}
public async Task<IEnumerable> FindAsync(Expression<Func> predicate)
{
return await _dbSet.Where(predicate).ToListAsync();
}
public async Task AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
}
public void Update(T entity)
{
_dbSet.Update(entity);
}
public void Remove(T entity)
{
_dbSet.Remove(entity);
}
}
Unit of Work Pattern Maintains a list of objects affected by a business transaction and coordinates writing changes to the database.
public interface IUnitOfWork : IDisposable
{
IRepository Products { get; }
IRepository Categories { get; }
IRepository Orders { get; }
Task SaveChangesAsync();
Task BeginTransactionAsync();
Task CommitTransactionAsync();
Task RollbackTransactionAsync();
}
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
private IDbContextTransaction _transaction;
public UnitOfWork(AppDbContext context)
{
_context = context;
Products = new Repository(_context);
Categories = new Repository(_context);
Orders = new Repository(_context);
}
public IRepository Products { get; private set; }
public IRepository Categories { get; private set; }
public IRepository Orders { get; private set; }
public async Task SaveChangesAsync()
{
return await _context.SaveChangesAsync();
}
public async Task BeginTransactionAsync()
{
_transaction = await _context.Database.BeginTransactionAsync();
}
public async Task CommitTransactionAsync()
{
await _transaction.CommitAsync();
}
public async Task RollbackTransactionAsync()
{
await _transaction.RollbackAsync();
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}
Usage Example:
public class ProductService
{
private readonly IUnitOfWork _unitOfWork;
public ProductService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task CreateProductWithCategoryAsync(Product product, Category category)
{
await _unitOfWork.BeginTransactionAsync();
try
{
await _unitOfWork.Categories.AddAsync(category);
await _unitOfWork.SaveChangesAsync();
product.CategoryId = category.Id;
await _unitOfWork.Products.AddAsync(product);
await _unitOfWork.SaveChangesAsync();
await _unitOfWork.CommitTransactionAsync();
}
catch
{
await _unitOfWork.RollbackTransactionAsync();
throw;
}
}
}
Benefits:
- Separation of concerns
- Testability (easy to mock)
- Centralized data access logic
- Transaction management
- Reduced coupling
Note: DbContext already implements Unit of Work pattern, so this is often considered over-engineering for simple applications.
Related Resources
N+1 Query Problem occurs when you execute 1 query to fetch N records, then N additional queries to fetch related data for each record.
Example of N+1 Problem:
// 1 query to get orders
var orders = context.Orders.ToList();
// N queries (one per order) to get customers
foreach (var order in orders)
{
Console.WriteLine(order.Customer.Name); // Lazy loading triggers query
}
// Total: 1 + N queries!
Solutions:
1. Eager Loading with Include()
// Single query with JOIN
var orders = context.Orders
.Include(o => o.Customer)
.ToList();
foreach (var order in orders)
{
Console.WriteLine(order.Customer.Name); // No additional query
}
2. Select/Projection
// Query only needed data
var orderSummaries = context.Orders
.Select(o => new
{
OrderId = o.Id,
CustomerName = o.Customer.Name,
Total = o.Total
})
.ToList();
3. Split Queries for Multiple Includes
// Instead of one large JOIN, use multiple queries
var orders = context.Orders
.Include(o => o.Customer)
.Include(o => o.OrderItems)
.AsSplitQuery() // EF Core 5.0+
.ToList();
4. Explicit Loading with Batch
var orders = context.Orders.ToList();
// Load all customers in one query
var customerIds = orders.Select(o => o.CustomerId).Distinct();
var customers = context.Customers
.Where(c => customerIds.Contains(c.Id))
.ToDictionary(c => c.Id);
// Map in memory
foreach (var order in orders)
{
order.Customer = customers[order.CustomerId];
}
5. Disable Lazy Loading
// Don't enable lazy loading proxies
services.AddDbContext(options =>
options.UseSqlServer(connectionString)
// Don't use: .UseLazyLoadingProxies()
);
Detection Tools:
// Enable sensitive data logging and detailed errors
optionsBuilder
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
.LogTo(Console.WriteLine, LogLevel.Information);
Related Resources
1. Use AsNoTracking() for Read-Only Queries
// 30-40% faster for read-only scenarios
var products = context.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToList();
2. Project Only Needed Columns
// Bad: Fetches all columns
var products = context.Products.ToList();
// Good: Fetch only needed data
var products = context.Products
.Select(p => new { p.Id, p.Name, p.Price })
.ToList();
3. Use Compiled Queries
private static readonly Func GetProductById =
EF.CompileQuery((AppDbContext context, int id) =>
context.Products.FirstOrDefault(p => p.Id == id));
// Usage
var product = GetProductById(context, 123);
4. Batch Operations
// Bad: Multiple round trips
foreach (var product in products)
{
context.Products.Add(product);
context.SaveChanges(); // Don't do this!
}
// Good: Single batch
context.Products.AddRange(products);
context.SaveChanges();
5. Use Pagination
var products = context.Products
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
6. Filter Before Loading
// Bad: Load all then filter in memory
var activeProducts = context.Products
.ToList()
.Where(p => p.IsActive);
// Good: Filter in database
var activeProducts = context.Products
.Where(p => p.IsActive)
.ToList();
7. Use Indexes
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.HasIndex(p => p.Name);
modelBuilder.Entity()
.HasIndex(o => new { o.CustomerId, o.OrderDate });
}
8. Avoid Cartesian Explosion with Split Queries
// Multiple collections can create huge result sets
var customers = context.Customers
.Include(c => c.Orders)
.Include(c => c.Addresses)
.AsSplitQuery() // Prevents cartesian product
.ToList();
9. Use Raw SQL for Complex Queries
var results = context.Products
.FromSqlRaw("SELECT * FROM Products WHERE Price > {0}", minPrice)
.ToList();
10. Configure Query Splitting Strategy
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(connectionString,
options => options.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}
11. Use Database-Side Evaluation
// Good: Evaluated in database
var count = context.Products
.Count(p => p.Price > 100);
// Bad: All data loaded into memory first
var count = context.Products
.ToList()
.Count(p => p.Price > 100);
Related Resources
Tracking Queries (Default)
EF Core keeps track of entity changes in the change tracker for SaveChanges().
// Tracking enabled by default
var product = context.Products.First();
product.Price = 99.99m;
context.SaveChanges(); // Change tracked and saved
How Change Tracking Works:
var product = context.Products.Find(1);
// Check tracking state
var entry = context.Entry(product);
Console.WriteLine(entry.State); // EntityState.Unchanged
product.Name = "Updated";
Console.WriteLine(entry.State); // EntityState.Modified
// See what changed
foreach (var property in entry.Properties)
{
if (property.IsModified)
{
Console.WriteLine($"{property.Metadata.Name}: " +
$"{property.OriginalValue} -> {property.CurrentValue}");
}
}
No-Tracking Queries Entities are not tracked, improving performance for read-only scenarios.
// No-tracking for single query
var products = context.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToList();
// Changes are NOT tracked
products[0].Price = 99.99m;
context.SaveChanges(); // Nothing saved!
Global No-Tracking Configuration:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
// Override for specific query
var product = context.Products
.AsTracking()
.First();
AsNoTrackingWithIdentityResolution
// Maintains identity resolution within query without full tracking
var orders = context.Orders
.Include(o => o.Customer)
.Include(o => o.OrderItems)
.ThenInclude(oi => oi.Product)
.AsNoTrackingWithIdentityResolution() // Same customer instance reused
.ToList();
Comparison:
| Aspect | Tracking | No-Tracking |
|---|---|---|
| Performance | Slower (memory overhead) | 30-40% faster |
| Memory | Higher | Lower |
| Use Case | Update/Delete operations | Read-only queries |
| Identity Resolution | Automatic | Not by default |
| SaveChanges() | Detects changes | No changes detected |
When to Use Each:
Use Tracking:
- Updating or deleting entities
- Need automatic change detection
- Working with related entities
- Short-lived contexts
Use No-Tracking:
- Read-only queries
- Display/reporting
- API GET endpoints
- Large result sets
- Performance critical scenarios
Manual Tracking Control:
// Detach entity
context.Entry(product).State = EntityState.Detached;
// Attach and mark as modified
context.Attach(product);
context.Entry(product).State = EntityState.Modified;
// Track specific properties
context.Entry(product).Property(p => p.Price).IsModified = true;
Related Resources
Owned Entities Owned types are value objects that belong to another entity and share its lifetime.
// Value object
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string ZipCode { get; set; }
public string Country { get; set; }
}
// Entity owning the value object
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public Address ShippingAddress { get; set; }
public Address BillingAddress { get; set; }
}
// Configuration
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.OwnsOne(c => c.ShippingAddress, sa =>
{
sa.Property(a => a.Street).HasColumnName("ShippingStreet");
sa.Property(a => a.City).HasColumnName("ShippingCity");
});
modelBuilder.Entity()
.OwnsOne(c => c.BillingAddress, ba =>
{
ba.Property(a => a.Street).HasColumnName("BillingStreet");
ba.Property(a => a.City).HasColumnName("BillingCity");
});
}
Result: All columns in same table:
Customers Table:
Id | Name | ShippingStreet | ShippingCity | ... | BillingStreet | BillingCity | ...
Owned Collections:
public class Order
{
public int Id { get; set; }
public ICollection Items { get; set; }
}
public class OrderItem
{
public string ProductName { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.OwnsMany(o => o.Items, item =>
{
item.WithOwner().HasForeignKey("OrderId");
item.Property("Id");
item.HasKey("Id");
});
}
Owned Types in Separate Table:
modelBuilder.Entity()
.OwnsOne(c => c.ShippingAddress)
.ToTable("ShippingAddresses");
Table Splitting Multiple entity types share the same table.
public class Order
{
public int Id { get; set; }
public DateTime OrderDate { get; set; }
public OrderDetails Details { get; set; }
}
public class OrderDetails
{
public int OrderId { get; set; }
public string Notes { get; set; }
public string ShippingMethod { get; set; }
public DateTime? DeliveryDate { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.ToTable("Orders");
modelBuilder.Entity()
.ToTable("Orders"); // Same table!
// Configure relationship
modelBuilder.Entity()
.HasOne(o => o.Details)
.WithOne()
.HasForeignKey(d => d.OrderId);
// Shared primary key
modelBuilder.Entity()
.HasKey(d => d.OrderId);
}
Result: Single table with columns from both entities:
Orders Table:
Id | OrderDate | Notes | ShippingMethod | DeliveryDate
Benefits of Table Splitting:
- Logical separation of concerns in code
- Single table in database
- Can load entities independently
- Useful for large tables with many columns
Usage Example:
// Load only main entity
var order = context.Orders
.AsNoTracking()
.First();
// Load with details
var orderWithDetails = context.Orders
.Include(o => o.Details)
.First();
Owned Entity Navigation:
// Query owned entity
var customersInNewYork = context.Customers
.Where(c => c.ShippingAddress.City == "New York")
.ToList();
Key Differences:
| Feature | Owned Entities | Table Splitting |
|---|---|---|
| Relationship | One-to-one or one-to-many | One-to-one only |
| Identity | No separate identity | Separate entities |
| Querying | Part of owner | Can query independently |
| Use Case | Value objects | Logical separation |
Related Resources
Concurrency Control prevents data conflicts when multiple users update the same record simultaneously.
1. Optimistic Concurrency with RowVersion (Timestamp)
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
}
// Or using Fluent API
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.Property(p => p.RowVersion)
.IsRowVersion();
}
How It Works:
try
{
var product = context.Products.Find(1);
product.Price = 99.99m;
context.SaveChanges(); // Checks RowVersion
}
catch (DbUpdateConcurrencyException ex)
{
// Handle conflict
var entry = ex.Entries.Single();
var databaseValues = entry.GetDatabaseValues();
var currentValues = entry.CurrentValues;
Console.WriteLine("Conflict detected!");
Console.WriteLine($"Current: {currentValues["Price"]}");
Console.WriteLine($"Database: {databaseValues["Price"]}");
}
2. Concurrency Token (Any Property)
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
[ConcurrencyCheck]
public DateTime LastModified { get; set; }
}
// Or Fluent API
modelBuilder.Entity()
.Property(p => p.LastModified)
.IsConcurrencyToken();
3. Complete Concurrency Handling Strategy
public async Task UpdateProductAsync(Product product)
{
using var transaction = await context.Database.BeginTransactionAsync();
try
{
context.Entry(product).State = EntityState.Modified;
await context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch (DbUpdateConcurrencyException ex)
{
await transaction.RollbackAsync();
foreach (var entry in ex.Entries)
{
if (entry.Entity is Product)
{
var proposedValues = entry.CurrentValues;
var databaseValues = entry.GetDatabaseValues();
if (databaseValues == null)
{
// Entity was deleted
Console.WriteLine("Entity was deleted by another user");
}
else
{
// Conflict resolution strategies:
// 1. Client Wins (overwrite database)
entry.OriginalValues.SetValues(databaseValues);
// 2. Database Wins (discard client changes)
// entry.CurrentValues.SetValues(databaseValues);
// 3. Merge (selective update)
// foreach (var property in proposedValues.Properties)
// {
// var proposed = proposedValues[property];
// var database = databaseValues[property];
// // Custom merge logic
// }
}
}
}
// Retry the save operation
try
{
await context.SaveChangesAsync();
return true;
}
catch (DbUpdateConcurrencyException)
{
return false; // Give up after retry
}
}
}
4. Disconnected Entity Scenario (Web API)
[HttpPut("products/{id}")]
public async Task UpdateProduct(int id, ProductDto dto)
{
var product = new Product
{
Id = id,
Name = dto.Name,
Price = dto.Price,
RowVersion = dto.RowVersion // From client
};
context.Products.Attach(product);
context.Entry(product).Property(p => p.Name).IsModified = true;
context.Entry(product).Property(p => p.Price).IsModified = true;
try
{
await context.SaveChangesAsync();
return Ok();
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new { message = "This record was modified by another user" });
}
}
5. Manual Concurrency Check
modelBuilder.Entity()
.Property(p => p.Name)
.IsConcurrencyToken();
// SQL Server generates:
// UPDATE Products
// SET Price = @price
// WHERE Id = @id AND Name = @originalName
Concurrency Resolution Strategies:
| Strategy | Description | Use Case |
|---|---|---|
| Client Wins | Overwrite database with client values | User is always right |
| Database Wins | Discard client changes | Latest change wins |
| Merge | Combine non-conflicting changes | Collaborative editing |
| User Decides | Show both versions, let user choose | Critical data |
Best Practices:
- Always use RowVersion/Timestamp for SQL Server
- Handle DbUpdateConcurrencyException appropriately
- Inform users about conflicts
- Use optimistic concurrency for web applications
- Consider pessimistic locking (database locks) for critical sections
- Log concurrency conflicts for monitoring
Related Resources
SaveChanges() - Synchronous Blocks the current thread until database operations complete.
public void AddProduct(Product product)
{
context.Products.Add(product);
int affectedRows = context.SaveChanges(); // Blocks here
Console.WriteLine($"Saved {affectedRows} rows");
}
SaveChangesAsync() - Asynchronous Returns a Task, allowing the thread to do other work while waiting.
public async Task AddProductAsync(Product product)
{
context.Products.Add(product);
int affectedRows = await context.SaveChangesAsync(); // Doesn't block
Console.WriteLine($"Saved {affectedRows} rows");
}
Key Differences:
| Aspect | SaveChanges() | SaveChangesAsync() |
|---|---|---|
| Thread Blocking | Blocks thread | Non-blocking |
| Return Type | int | Task<int> |
| Performance | Can bottleneck | Better scalability |
| Use Case | Console apps, batch jobs | Web APIs, UI apps |
| Thread Pool | Occupies thread | Releases thread |
Performance Impact:
// Synchronous - Thread blocked during I/O
public void ProcessOrders()
{
for (int i = 0; i < 1000; i++)
{
var order = new Order { /* ... */ };
context.Orders.Add(order);
context.SaveChanges(); // Thread waits for DB
}
}
// Asynchronous - Thread available for other work
public async Task ProcessOrdersAsync()
{
for (int i = 0; i < 1000; i++)
{
var order = new Order { /* ... */ };
context.Orders.Add(order);
await context.SaveChangesAsync(); // Thread released during DB operation
}
}
Web API Example:
// BAD: Synchronous in async controller
[HttpPost]
public async Task CreateProduct(Product product)
{
context.Products.Add(product);
context.SaveChanges(); // Don't do this!
return Ok();
}
// GOOD: Async all the way
[HttpPost]
public async Task CreateProduct(Product product)
{
context.Products.Add(product);
await context.SaveChangesAsync();
return Ok();
}
CancellationToken Support:
public async Task CreateProductAsync(
Product product,
CancellationToken cancellationToken)
{
context.Products.Add(product);
await context.SaveChangesAsync(cancellationToken);
return product;
}
When to Use Each:
Use SaveChanges():
- Console applications
- Batch processing jobs
- Synchronous codebases
- Simple scripts
- When async is not available
Use SaveChangesAsync():
- ASP.NET Core Web APIs
- Web applications
- Desktop apps with UI
- High-concurrency scenarios
- When scalability matters
Important Notes:
- Mixing sync and async can cause deadlocks
- Always use async in ASP.NET Core
- Async doesn't make individual operations faster, but improves scalability
- Don't use
SaveChangesAsync().Wait()or.Result- use proper async/await
Transaction Example:
// Synchronous transaction
using (var transaction = context.Database.BeginTransaction())
{
try
{
context.Products.Add(product);
context.SaveChanges();
context.Categories.Add(category);
context.SaveChanges();
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
// Asynchronous transaction
using (var transaction = await context.Database.BeginTransactionAsync())
{
try
{
context.Products.Add(product);
await context.SaveChangesAsync();
context.Categories.Add(category);
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
Related Resources
Shadow Properties are properties that exist in the EF Core model but not in the .NET entity class. They only exist in the database and change tracker.
Why Use Shadow Properties?
- Foreign keys you don't want in your domain model
- Audit fields (CreatedDate, ModifiedDate)
- Soft delete flags
- Database-specific metadata
- Keep domain model clean
1. Basic Shadow Property Configuration:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
// No CreatedDate or ModifiedDate properties in class!
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Add shadow properties
modelBuilder.Entity()
.Property("CreatedDate")
.HasDefaultValueSql("GETDATE()");
modelBuilder.Entity()
.Property("ModifiedDate");
modelBuilder.Entity()
.Property("CreatedBy")
.HasMaxLength(100);
}
2. Accessing Shadow Properties:
// Setting shadow property values
var product = new Product { Name = "Laptop", Price = 999.99m };
context.Products.Add(product);
// Access through Entry API
context.Entry(product).Property("CreatedBy").CurrentValue = "john.doe";
context.Entry(product).Property("ModifiedDate").CurrentValue = DateTime.UtcNow;
await context.SaveChangesAsync();
// Reading shadow property values
var createdDate = context.Entry(product).Property("CreatedDate").CurrentValue;
Console.WriteLine($"Created: {createdDate}");
3. Querying Shadow Properties:
// Use EF.Property in LINQ queries
var recentProducts = context.Products
.Where(p => EF.Property(p, "CreatedDate") > DateTime.UtcNow.AddDays(-7))
.ToList();
// Order by shadow property
var products = context.Products
.OrderByDescending(p => EF.Property(p, "ModifiedDate"))
.ToList();
// Select shadow properties
var productInfo = context.Products
.Select(p => new
{
p.Name,
Created = EF.Property(p, "CreatedDate"),
Modified = EF.Property(p, "ModifiedDate")
})
.ToList();
4. Shadow Foreign Keys:
public class Order
{
public int Id { get; set; }
public Customer Customer { get; set; }
// No CustomerId property!
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.HasOne(o => o.Customer)
.WithMany()
.HasForeignKey("CustomerId"); // Shadow FK
}
// Usage
var order = new Order { Customer = customer };
context.Orders.Add(order);
await context.SaveChangesAsync();
// Access shadow FK
var customerId = context.Entry(order).Property("CustomerId").CurrentValue;
5. Audit Trail with Shadow Properties:
public abstract class AuditableEntity
{
public int Id { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(AuditableEntity).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.Property("CreatedDate")
.HasDefaultValueSql("GETDATE()");
modelBuilder.Entity(entityType.ClrType)
.Property("ModifiedDate");
modelBuilder.Entity(entityType.ClrType)
.Property("CreatedBy")
.HasMaxLength(256);
modelBuilder.Entity(entityType.ClrType)
.Property("ModifiedBy")
.HasMaxLength(256);
}
}
}
// Auto-populate on SaveChanges
public override int SaveChanges()
{
var entries = ChangeTracker.Entries()
.Where(e => e.Entity is AuditableEntity &&
(e.State == EntityState.Added || e.State == EntityState.Modified));
foreach (var entry in entries)
{
var currentUser = GetCurrentUser(); // Your user service
if (entry.State == EntityState.Added)
{
entry.Property("CreatedDate").CurrentValue = DateTime.UtcNow;
entry.Property("CreatedBy").CurrentValue = currentUser;
}
entry.Property("ModifiedDate").CurrentValue = DateTime.UtcNow;
entry.Property("ModifiedBy").CurrentValue = currentUser;
}
return base.SaveChanges();
}
6. Soft Delete with Shadow Property:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Add IsDeleted shadow property to all entities
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
modelBuilder.Entity(entityType.ClrType)
.Property("IsDeleted")
.HasDefaultValue(false);
// Global query filter
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, "IsDeleted");
var filter = Expression.Lambda(
Expression.Equal(property, Expression.Constant(false)),
parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filter);
}
}
// Soft delete implementation
public void SoftDelete(T entity) where T : class
{
context.Entry(entity).Property("IsDeleted").CurrentValue = true;
}
// Usage
var product = context.Products.Find(1);
SoftDelete(product);
context.SaveChanges();
// Query automatically filters soft-deleted records
var products = context.Products.ToList(); // Only non-deleted
// Include soft-deleted records
var allProducts = context.Products
.IgnoreQueryFilters()
.ToList();
7. Indexing Shadow Properties:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.Property("CreatedDate");
modelBuilder.Entity()
.HasIndex("CreatedDate")
.HasDatabaseName("IX_Product_CreatedDate");
}
8. Shadow Properties vs Regular Properties:
| Aspect | Shadow Properties | Regular Properties |
|---|---|---|
| In Entity Class | No | Yes |
| Type Safety | Runtime only | Compile-time |
| IntelliSense | No | Yes |
| Access | EF.Property() | Direct access |
| Refactoring | Manual | Automatic |
| Use Case | Infrastructure concerns | Domain model |
Advantages:
- Keep domain model clean
- Separate infrastructure concerns
- Database-specific features
- Flexible schema management
- Audit without polluting model
Disadvantages:
- No compile-time safety
- No IntelliSense support
- String-based access (typo-prone)
- Less discoverable
- More complex queries
Best Practices:
- Use for infrastructure concerns only
- Document shadow properties clearly
- Create helper methods for common access patterns
- Consider using interfaces for better discoverability
- Use constants for property names to avoid typos
// Good practice: Use constants
public static class ShadowProperties
{
public const string CreatedDate = nameof(CreatedDate);
public const string ModifiedDate = nameof(ModifiedDate);
public const string IsDeleted = nameof(IsDeleted);
}
// Usage
var createdDate = context.Entry(product)
.Property(ShadowProperties.CreatedDate)
.CurrentValue;
Summary
Entity Framework Core provides a powerful and flexible ORM solution for .NET applications. Key takeaways:
- EF Core vs EF6: Cross-platform, better performance, modern features
- Code First vs Database First: Choose based on project requirements
- Loading Strategies: Eager, lazy, and explicit loading - each with specific use cases
- Migrations: Track and manage database schema changes
- Patterns: Repository and Unit of Work for better architecture
- N+1 Problem: Use eager loading and projections to avoid performance issues
- Query Optimization: AsNoTracking, projections, compiled queries, and proper indexing
- Tracking: Understand when to use tracking vs no-tracking queries
- Owned Entities: Model value objects effectively
- Concurrency: Use optimistic concurrency control with RowVersion
- Async Operations: Always use async in web applications for scalability
- Shadow Properties: Keep infrastructure concerns separate from domain model
Master these concepts to build efficient, maintainable, and scalable applications with Entity Framework Core!
Related Resources
Database transactions in Entity Framework Core ensure data consistency by grouping multiple operations into a single unit of work. If any operation fails, all changes are rolled back.
1. Automatic Transactions (Default Behavior)
EF Core automatically creates a transaction for each SaveChanges() call:
public class OrderService
{
private readonly AppDbContext _context;
public OrderService(AppDbContext context)
{
_context = context;
}
// Single SaveChanges() = single transaction
public async Task CreateOrderAsync(Order order)
{
_context.Orders.Add(order);
_context.OrderItems.AddRange(order.OrderItems);
// This creates and commits a transaction automatically
await _context.SaveChangesAsync();
}
}
2. Manual Transaction Management
Use BeginTransaction() for explicit transaction control:
public async Task TransferMoneyAsync(int fromAccountId, int toAccountId, decimal amount)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Withdraw from source account
var fromAccount = await _context.Accounts.FindAsync(fromAccountId);
if (fromAccount.Balance < amount)
throw new InsufficientFundsException();
fromAccount.Balance -= amount;
// Deposit to target account
var toAccount = await _context.Accounts.FindAsync(toAccountId);
toAccount.Balance += amount;
// Create transaction record
_context.Transactions.Add(new Transaction
{
FromAccountId = fromAccountId,
ToAccountId = toAccountId,
Amount = amount,
Timestamp = DateTime.UtcNow
});
// Save all changes
await _context.SaveChangesAsync();
// Commit transaction
await transaction.CommitAsync();
}
catch
{
// Rollback happens automatically when transaction is disposed
await transaction.RollbackAsync();
throw;
}
}
3. Transaction with Isolation Levels
Control transaction isolation for different consistency requirements:
public async Task ProcessOrderWithLockAsync(int orderId)
{
using var transaction = await _context.Database.BeginTransactionAsync(
IsolationLevel.ReadCommitted);
try
{
// Lock the order row for update
var order = await _context.Orders
.FromSqlRaw("SELECT * FROM Orders WITH (UPDLOCK) WHERE Id = {0}", orderId)
.FirstOrDefaultAsync();
if (order.Status != OrderStatus.Pending)
throw new InvalidOperationException("Order already processed");
order.Status = OrderStatus.Processing;
order.ProcessedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
4. Distributed Transactions (Multiple Contexts)
Handle transactions across multiple database contexts:
public async Task ProcessOrderAcrossDatabasesAsync(Order order)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Save to main database
_context.Orders.Add(order);
await _context.SaveChangesAsync();
// Save to audit database
using var auditContext = new AuditDbContext();
auditContext.Database.UseTransaction(transaction.GetDbTransaction());
auditContext.AuditLogs.Add(new AuditLog
{
EntityType = "Order",
EntityId = order.Id,
Action = "Created",
Timestamp = DateTime.UtcNow
});
await auditContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
5. Transaction Scope (System.Transactions)
Use TransactionScope for distributed transactions:
public async Task ProcessOrderWithTransactionScopeAsync(Order order)
{
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
try
{
// Multiple operations that can span different databases
await _context.Orders.AddAsync(order);
await _context.SaveChangesAsync();
// Call external service
await _paymentService.ProcessPaymentAsync(order.PaymentInfo);
// Send notification
await _notificationService.SendOrderConfirmationAsync(order);
scope.Complete(); // Commit all operations
}
catch
{
// Automatic rollback when scope is disposed
throw;
}
}
6. Nested Transactions
Handle nested transaction scenarios:
public async Task ProcessBulkOrdersAsync(List<Order> orders)
{
using var outerTransaction = await _context.Database.BeginTransactionAsync();
try
{
foreach (var order in orders)
{
// Each order processing is a nested transaction
await ProcessSingleOrderAsync(order);
}
await outerTransaction.CommitAsync();
}
catch
{
await outerTransaction.RollbackAsync();
throw;
}
}
private async Task ProcessSingleOrderAsync(Order order)
{
using var innerTransaction = await _context.Database.BeginTransactionAsync();
try
{
_context.Orders.Add(order);
await _context.SaveChangesAsync();
// Additional processing
await UpdateInventoryAsync(order.OrderItems);
await innerTransaction.CommitAsync();
}
catch
{
await innerTransaction.RollbackAsync();
throw;
}
}
7. Transaction Best Practices
public class TransactionBestPractices
{
private readonly AppDbContext _context;
// ✅ Good: Use using statements for automatic disposal
public async Task GoodTransactionAsync()
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Your operations here
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
// ❌ Bad: Manual transaction management without proper cleanup
public async Task BadTransactionAsync()
{
var transaction = await _context.Database.BeginTransactionAsync();
try
{
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
// Missing: transaction.Dispose() - can cause connection leaks
}
// ✅ Good: Appropriate isolation level
public async Task ReadCommittedTransactionAsync()
{
using var transaction = await _context.Database.BeginTransactionAsync(
IsolationLevel.ReadCommitted);
// Operations that need to see committed data
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
// ✅ Good: Handle transaction timeouts
public async Task TransactionWithTimeoutAsync()
{
using var transaction = await _context.Database.BeginTransactionAsync();
transaction.GetDbTransaction().CommandTimeout = 30; // 30 seconds
try
{
// Long-running operations
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch (SqlException ex) when (ex.Number == -2) // Timeout
{
await transaction.RollbackAsync();
throw new TimeoutException("Transaction timed out", ex);
}
}
}
8. Transaction Monitoring and Logging
public class TransactionMonitoring
{
private readonly ILogger<TransactionMonitoring> _logger;
public async Task MonitoredTransactionAsync()
{
var stopwatch = Stopwatch.StartNew();
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
_logger.LogInformation("Transaction started: {TransactionId}",
transaction.TransactionId);
// Your operations
await _context.SaveChangesAsync();
await transaction.CommitAsync();
stopwatch.Stop();
_logger.LogInformation("Transaction committed successfully in {Duration}ms",
stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
stopwatch.Stop();
_logger.LogError(ex, "Transaction rolled back after {Duration}ms",
stopwatch.ElapsedMilliseconds);
throw;
}
}
}
Key Points:
- Automatic Transactions: Each
SaveChanges()creates a transaction automatically - Manual Control: Use
BeginTransaction()for explicit transaction management - Isolation Levels: Control data consistency with different isolation levels
- Distributed Transactions: Handle transactions across multiple databases
- Error Handling: Always rollback on exceptions
- Resource Management: Use
usingstatements for automatic cleanup - Performance: Keep transactions short to avoid blocking
- Monitoring: Log transaction duration and outcomes
Best Practices:
- Keep transactions as short as possible
- Use appropriate isolation levels
- Always handle exceptions and rollback
- Use
usingstatements for automatic disposal - Monitor transaction performance
- Avoid long-running operations in transactions
- Consider using
TransactionScopefor distributed scenarios
Related Resources
Global query filters in Entity Framework Core allow you to automatically apply filtering logic to all queries for specific entity types. They're particularly useful for implementing soft deletes, multi-tenancy, and row-level security.
1. Basic Global Query Filter
public class AppDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
public DbSet<Category> Categories { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Global filter for soft deletes
modelBuilder.Entity<Product>()
.HasQueryFilter(p => !p.IsDeleted);
modelBuilder.Entity<Category>()
.HasQueryFilter(c => !c.IsDeleted);
}
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
// Usage - filter is automatically applied
var products = await context.Products.ToListAsync();
// SQL: SELECT * FROM Products WHERE IsDeleted = 0
2. Multi-Tenancy with Global Filters
public class AppDbContext : DbContext
{
private readonly ITenantService _tenantService;
public AppDbContext(DbContextOptions<AppDbContext> options, ITenantService tenantService)
: base(options)
{
_tenantService = tenantService;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Multi-tenant filter
modelBuilder.Entity<Product>()
.HasQueryFilter(p => p.TenantId == _tenantService.GetCurrentTenantId());
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenantService.GetCurrentTenantId());
}
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public int TenantId { get; set; }
}
public interface ITenantService
{
int GetCurrentTenantId();
}
// Usage - automatically filters by tenant
var products = await context.Products.ToListAsync();
// SQL: SELECT * FROM Products WHERE TenantId = @currentTenantId
3. Complex Filter Conditions
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Complex filter with multiple conditions
modelBuilder.Entity<Product>()
.HasQueryFilter(p =>
!p.IsDeleted &&
p.IsActive &&
p.PublishedAt <= DateTime.UtcNow);
// Filter based on user permissions
modelBuilder.Entity<Document>()
.HasQueryFilter(d =>
d.IsPublic ||
d.OwnerId == _userService.GetCurrentUserId() ||
d.SharedWith.Contains(_userService.GetCurrentUserId()));
}
4. Ignoring Global Filters
Sometimes you need to bypass global filters:
public class ProductService
{
private readonly AppDbContext _context;
// Normal query - filter is applied
public async Task<List<Product>> GetActiveProductsAsync()
{
return await _context.Products.ToListAsync();
// SQL: SELECT * FROM Products WHERE IsDeleted = 0
}
// Ignore global filter - get all products including deleted
public async Task<List<Product>> GetAllProductsIncludingDeletedAsync()
{
return await _context.Products
.IgnoreQueryFilters()
.ToListAsync();
// SQL: SELECT * FROM Products (no WHERE clause)
}
// Ignore specific filter for admin operations
public async Task<List<Product>> GetProductsForAdminAsync()
{
return await _context.Products
.IgnoreQueryFilters()
.Where(p => p.IsDeleted)
.ToListAsync();
}
}
5. Dynamic Global Filters
Create filters that can be modified at runtime:
public class AppDbContext : DbContext
{
private readonly ICurrentUserService _userService;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Dynamic filter based on current user
modelBuilder.Entity<Document>()
.HasQueryFilter(d =>
d.IsPublic ||
d.OwnerId == _userService.GetCurrentUserId());
}
}
public interface ICurrentUserService
{
int? GetCurrentUserId();
}
// Usage with different users
public class DocumentService
{
private readonly AppDbContext _context;
private readonly ICurrentUserService _userService;
public async Task<List<Document>> GetUserDocumentsAsync()
{
// Filter automatically applies based on current user
return await _context.Documents.ToListAsync();
}
}
6. Global Filters with Navigation Properties
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Filter on related entities
modelBuilder.Entity<Order>()
.HasQueryFilter(o =>
!o.IsDeleted &&
o.Customer.IsActive);
// Filter with multiple levels
modelBuilder.Entity<OrderItem>()
.HasQueryFilter(oi =>
!oi.Order.IsDeleted &&
!oi.Product.IsDeleted);
}
public class Order
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; }
public List<OrderItem> OrderItems { get; set; }
}
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public Order Order { get; set; }
public int ProductId { get; set; }
public Product Product { get; set; }
}
7. Performance Considerations
public class OptimizedGlobalFilters
{
// ✅ Good: Simple, indexed conditions
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.HasQueryFilter(p => p.IsActive); // Simple boolean check
}
// ❌ Avoid: Complex calculations in filters
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.HasQueryFilter(p =>
p.CreatedAt.AddDays(30) > DateTime.UtcNow); // Complex calculation
}
// ✅ Better: Pre-calculate values
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
modelBuilder.Entity<Product>()
.HasQueryFilter(p => p.CreatedAt > thirtyDaysAgo);
}
}
8. Testing with Global Filters
public class ProductServiceTests
{
[Test]
public async Task GetProducts_ShouldExcludeDeletedProducts()
{
// Arrange
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new AppDbContext(options);
// Add test data
context.Products.AddRange(new[]
{
new Product { Id = 1, Name = "Active Product", IsDeleted = false },
new Product { Id = 2, Name = "Deleted Product", IsDeleted = true }
});
await context.SaveChangesAsync();
// Act
var products = await context.Products.ToListAsync();
// Assert
Assert.That(products.Count, Is.EqualTo(1));
Assert.That(products[0].Name, Is.EqualTo("Active Product"));
}
[Test]
public async Task GetAllProducts_WithIgnoreQueryFilters_ShouldReturnAll()
{
// Arrange
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new AppDbContext(options);
context.Products.AddRange(new[]
{
new Product { Id = 1, Name = "Active Product", IsDeleted = false },
new Product { Id = 2, Name = "Deleted Product", IsDeleted = true }
});
await context.SaveChangesAsync();
// Act
var products = await context.Products
.IgnoreQueryFilters()
.ToListAsync();
// Assert
Assert.That(products.Count, Is.EqualTo(2));
}
}
9. Advanced Global Filter Patterns
public class AdvancedGlobalFilters
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Time-based filtering
modelBuilder.Entity<Event>()
.HasQueryFilter(e => e.StartDate > DateTime.UtcNow);
// Status-based filtering
modelBuilder.Entity<Job>()
.HasQueryFilter(j => j.Status != JobStatus.Cancelled);
// Permission-based filtering
modelBuilder.Entity<File>()
.HasQueryFilter(f =>
f.IsPublic ||
f.OwnerId == _userService.GetCurrentUserId() ||
f.Permissions.Any(p => p.UserId == _userService.GetCurrentUserId()));
// Hierarchical filtering
modelBuilder.Entity<Comment>()
.HasQueryFilter(c =>
!c.IsDeleted &&
!c.Post.IsDeleted &&
c.Post.IsPublished);
}
}
Key Benefits:
- Automatic Filtering: No need to remember to add filters to every query
- Consistency: Ensures all queries follow the same filtering rules
- Security: Implements row-level security automatically
- Multi-tenancy: Easy implementation of tenant isolation
- Soft Deletes: Automatic exclusion of deleted records
Best Practices:
- Keep filters simple and performant
- Use indexed columns in filter conditions
- Test with
IgnoreQueryFilters()when needed - Consider performance impact on complex filters
- Use for security and data isolation, not business logic
- Document global filters for team understanding
Related Resources
Database connection management and pooling in EF Core are crucial for performance and scalability. EF Core uses ADO.NET connection pooling by default, but you can configure and optimize it for your specific needs.
1. Basic Connection String Configuration
// appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=MyApp;Trusted_Connection=true;TrustServerCertificate=true;",
"ProductionConnection": "Server=prod-server;Database=MyApp;User Id=appuser;Password=securepassword;TrustServerCertificate=true;"
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
2. Connection Pooling Configuration
// Connection string with pooling settings
var connectionString = "Server=localhost;Database=MyApp;Trusted_Connection=true;" +
"Min Pool Size=5;" + // Minimum connections in pool
"Max Pool Size=100;" + // Maximum connections in pool
"Connection Lifetime=300;" + // Connection lifetime in seconds
"Connection Timeout=30;" + // Connection timeout
"Command Timeout=60;" + // Command timeout
"Pooling=true;"; // Enable pooling (default: true)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
3. Advanced Connection Configuration
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.CommandTimeout(60);
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
});
}
}
}
4. Multiple Database Contexts with Different Pools
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddDbContext<AuditDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("AuditConnection")));
builder.Services.AddDbContext<ReportingDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("ReportingConnection")));
// Usage in services
public class OrderService
{
private readonly AppDbContext _context;
private readonly AuditDbContext _auditContext;
public OrderService(AppDbContext context, AuditDbContext auditContext)
{
_context = context;
_auditContext = auditContext;
}
}
5. Connection Pool Monitoring
public class ConnectionPoolMonitor
{
private readonly ILogger<ConnectionPoolMonitor> _logger;
private readonly AppDbContext _context;
public ConnectionPoolMonitor(ILogger<ConnectionPoolMonitor> logger, AppDbContext context)
{
_logger = logger;
_context = context;
}
public async Task MonitorConnectionPoolAsync()
{
try
{
// Get connection pool statistics
var connection = _context.Database.GetDbConnection();
if (connection is SqlConnection sqlConnection)
{
_logger.LogInformation("Connection Pool Statistics:");
_logger.LogInformation("Connection String: {ConnectionString}",
sqlConnection.ConnectionString);
_logger.LogInformation("Connection State: {State}",
sqlConnection.State);
_logger.LogInformation("Server Version: {Version}",
sqlConnection.ServerVersion);
}
// Test connection
await _context.Database.OpenConnectionAsync();
_logger.LogInformation("Database connection successful");
}
catch (Exception ex)
{
_logger.LogError(ex, "Database connection failed");
}
finally
{
await _context.Database.CloseConnectionAsync();
}
}
}
6. Custom Connection Factory
public class CustomConnectionFactory : IDbConnectionFactory
{
private readonly IConfiguration _configuration;
private readonly ILogger<CustomConnectionFactory> _logger;
public CustomConnectionFactory(IConfiguration configuration, ILogger<CustomConnectionFactory> logger)
{
_configuration = configuration;
_logger = logger;
}
public DbConnection CreateConnection(string connectionString)
{
var connection = new SqlConnection(connectionString);
// Add connection event handlers
connection.StateChange += OnConnectionStateChange;
connection.InfoMessage += OnConnectionInfoMessage;
return connection;
}
private void OnConnectionStateChange(object sender, StateChangeEventArgs e)
{
_logger.LogInformation("Connection state changed from {OriginalState} to {CurrentState}",
e.OriginalState, e.CurrentState);
}
private void OnConnectionInfoMessage(object sender, SqlInfoMessageEventArgs e)
{
_logger.LogInformation("SQL Info: {Message}", e.Message);
}
}
// Register custom connection factory
builder.Services.AddSingleton<IDbConnectionFactory, CustomConnectionFactory>();
7. Connection Resilience and Retry Policies
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
// Retry policy for transient failures
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
// Connection timeout
sqlOptions.CommandTimeout(60);
});
});
// Custom retry policy
public class ResilientDbContext : AppDbContext
{
public ResilientDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var retryPolicy = Policy
.Handle<SqlException>(ex => IsTransientError(ex))
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryCount, context) =>
{
Console.WriteLine($"Retry {retryCount} after {timespan} seconds");
});
return await retryPolicy.ExecuteAsync(async () =>
{
return await base.SaveChangesAsync(cancellationToken);
});
}
private static bool IsTransientError(SqlException ex)
{
// SQL Server transient error numbers
var transientErrors = new[] { 2, 53, 121, 1205, 1222, 8645, 8651 };
return transientErrors.Contains(ex.Number);
}
}
8. Connection Pool Optimization
public class ConnectionPoolOptimizer
{
private readonly IConfiguration _configuration;
public ConnectionPoolOptimizer(IConfiguration configuration)
{
_configuration = configuration;
}
public string GetOptimizedConnectionString(string baseConnectionString)
{
var builder = new SqlConnectionStringBuilder(baseConnectionString);
// Optimize for high-throughput scenarios
builder.MinPoolSize = 10; // Keep more connections ready
builder.MaxPoolSize = 200; // Allow more concurrent connections
builder.ConnectionLifetime = 600; // 10 minutes connection lifetime
builder.ConnectionTimeout = 15; // Faster connection timeout
builder.CommandTimeout = 30; // Reasonable command timeout
// Enable connection pooling
builder.Pooling = true;
// Enable multiple active result sets
builder.MultipleActiveResultSets = true;
// Optimize for read-heavy workloads
builder.ApplicationIntent = ApplicationIntent.ReadOnly;
return builder.ConnectionString;
}
public string GetOptimizedConnectionStringForWrites(string baseConnectionString)
{
var builder = new SqlConnectionStringBuilder(baseConnectionString);
// Optimize for write-heavy scenarios
builder.MinPoolSize = 5; // Fewer connections for writes
builder.MaxPoolSize = 50; // Limit concurrent writes
builder.ConnectionLifetime = 300; // Shorter connection lifetime
builder.ConnectionTimeout = 30; // Longer connection timeout for writes
builder.CommandTimeout = 60; // Longer command timeout for writes
// Enable connection pooling
builder.Pooling = true;
// Optimize for write workloads
builder.ApplicationIntent = ApplicationIntent.ReadWrite;
return builder.ConnectionString;
}
}
9. Environment-Specific Connection Management
// Program.cs
public static void ConfigureDatabase(WebApplicationBuilder builder)
{
var environment = builder.Environment.EnvironmentName;
switch (environment)
{
case "Development":
ConfigureDevelopmentDatabase(builder);
break;
case "Staging":
ConfigureStagingDatabase(builder);
break;
case "Production":
ConfigureProductionDatabase(builder);
break;
}
}
private static void ConfigureDevelopmentDatabase(WebApplicationBuilder builder)
{
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
options.LogTo(Console.WriteLine, LogLevel.Information);
});
}
private static void ConfigureProductionDatabase(WebApplicationBuilder builder)
{
builder.Services.AddDbContext<AppDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("ProductionConnection");
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30));
sqlOptions.CommandTimeout(60);
});
// Disable sensitive data logging in production
options.EnableSensitiveDataLogging(false);
options.EnableDetailedErrors(false);
});
}
10. Connection Health Checks
public class DatabaseHealthCheck : IHealthCheck
{
private readonly AppDbContext _context;
public DatabaseHealthCheck(AppDbContext context)
{
_context = context;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
// Test database connection
await _context.Database.OpenConnectionAsync(cancellationToken);
// Test simple query
await _context.Database.ExecuteSqlRawAsync("SELECT 1", cancellationToken);
// Get connection pool info
var connection = _context.Database.GetDbConnection();
var connectionState = connection.State;
return HealthCheckResult.Healthy($"Database is healthy. Connection state: {connectionState}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Database connection failed", ex);
}
finally
{
await _context.Database.CloseConnectionAsync();
}
}
}
// Register health check
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database");
Key Points:
- Default Pooling: EF Core uses ADO.NET connection pooling by default
- Connection String: Configure pool size, timeouts, and lifetime
- Multiple Contexts: Each context can have its own connection pool
- Resilience: Implement retry policies for transient failures
- Monitoring: Track connection pool health and performance
- Environment-Specific: Different configurations for different environments
- Resource Management: Proper disposal and connection lifecycle management
Best Practices:
- Configure appropriate pool sizes based on your workload
- Use connection timeouts to prevent hanging connections
- Implement retry policies for transient failures
- Monitor connection pool health and performance
- Use different connection strings for read vs write operations
- Test connection resilience under load
- Implement proper error handling and logging
- Use health checks to monitor database connectivity