Entity Framework Core - Principal Engineer Deep Dive
A comprehensive guide covering EF Core internals, query optimization, advanced modeling, and production patterns for Principal/Staff Engineers.
Table of Contents
- Loading Strategies Deep Dive
- Query Optimization
- Advanced Modeling
- Concurrency & Transactions
- Production Patterns
- Raw SQL & Advanced Operations
1. Loading Strategies Deep Dive
1.1 Eager Loading
Eager loading uses the Include() and ThenInclude() methods to load related data in a single query.
Basic Usage
// Single level Include
var orders = await context.Orders
.Include(o => o.Customer)
.Include(o => o.ShippingAddress)
.ToListAsync();
// Multi-level Include with ThenInclude
var orders = await context.Orders
.Include(o => o.OrderItems)
.ThenInclude(oi => oi.Product)
.ThenInclude(p => p.Category)
.Include(o => o.Customer)
.ThenInclude(c => c.Address)
.ToListAsync();
[SQL] Generated SQL Analysis
// This query:
var order = await context.Orders
.Include(o => o.OrderItems)
.ThenInclude(oi => oi.Product)
.Include(o => o.Customer)
.FirstOrDefaultAsync(o => o.Id == orderId);
// Generates SQL like:
/*
SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate], [o].[Total],
[c].[Id], [c].[Name], [c].[Email],
[t].[Id], [t].[OrderId], [t].[ProductId], [t].[Quantity],
[t].[Id0], [t].[Name], [t].[Price]
FROM [Orders] AS [o]
LEFT JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id]
LEFT JOIN (
SELECT [oi].[Id], [oi].[OrderId], [oi].[ProductId], [oi].[Quantity],
[p].[Id] AS [Id0], [p].[Name], [p].[Price]
FROM [OrderItems] AS [oi]
INNER JOIN [Products] AS [p] ON [oi].[ProductId] = [p].[Id]
) AS [t] ON [o].[Id] = [t].[OrderId]
WHERE [o].[Id] = @orderId
ORDER BY [o].[Id], [c].[Id], [t].[Id]
*/
The Cartesian Explosion Problem
// PROBLEM: Multiple collection Includes cause Cartesian explosion
var orders = await context.Orders
.Include(o => o.OrderItems) // 10 items per order
.Include(o => o.Payments) // 3 payments per order
.Include(o => o.StatusHistory) // 5 statuses per order
.ToListAsync();
// For 100 orders: 100 ร 10 ร 3 ร 5 = 15,000 rows returned!
// Each order data is duplicated 150 times
// SOLUTION: Use AsSplitQuery or separate queries
var orders = await context.Orders
.Include(o => o.OrderItems)
.Include(o => o.Payments)
.Include(o => o.StatusHistory)
.AsSplitQuery() // Generates separate queries
.ToListAsync();
[BENCHMARK] Performance with Different Relationship Depths
// Benchmark: Loading 100 orders with varying Include depths
// Database: SQL Server, 10,000 orders, 5 items/order average
| Depth | Includes | Rows Returned | Query Time | Memory |
|-------|----------|---------------|------------|--------|
| 1 | Customer | 100 | 5ms | 2MB |
| 2 | +Items | 500 | 12ms | 8MB |
| 3 | +Product | 500 | 18ms | 12MB |
| 4 | +Category| 500 | 25ms | 15MB |
// With Cartesian explosion (multiple collections):
| Collections | Rows Returned | Query Time | Memory |
|-------------|---------------|------------|--------|
| 1 (Items) | 500 | 12ms | 8MB |
| 2 (+Payments)| 1,500 | 45ms | 25MB |
| 3 (+History)| 7,500 | 180ms | 95MB |
1.2 Explicit Loading
Explicit loading allows you to load related data on demand after the parent entity is loaded.
// Load the order first
var order = await context.Orders.FindAsync(orderId);
// Then explicitly load related data
await context.Entry(order)
.Collection(o => o.OrderItems)
.LoadAsync();
await context.Entry(order)
.Reference(o => o.Customer)
.LoadAsync();
// Conditional loading with Query()
await context.Entry(order)
.Collection(o => o.OrderItems)
.Query()
.Where(oi => oi.Quantity > 5)
.LoadAsync();
When to Use Explicit Loading
public class OrderService
{
public async Task<OrderDto> GetOrderWithDetailsAsync(int orderId, bool includeItems)
{
var order = await _context.Orders
.Include(o => o.Customer)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null) return null;
// Only load items if needed
if (includeItems)
{
await _context.Entry(order)
.Collection(o => o.OrderItems)
.Query()
.Include(oi => oi.Product)
.LoadAsync();
}
return MapToDto(order);
}
}
[INTERNALS] Change Tracker Interaction
// Explicit loading uses the Change Tracker to avoid duplicate queries
var order = await context.Orders.FindAsync(orderId);
// First load - executes query
await context.Entry(order).Collection(o => o.OrderItems).LoadAsync();
// Second load - checks IsLoaded flag, skips query
await context.Entry(order).Collection(o => o.OrderItems).LoadAsync();
// Check if already loaded
var entry = context.Entry(order);
bool itemsLoaded = entry.Collection(o => o.OrderItems).IsLoaded;
// Force reload even if already loaded
await context.Entry(order)
.Collection(o => o.OrderItems)
.Query()
.LoadAsync(); // Query() creates new query, ignores IsLoaded
1.3 Lazy Loading
Lazy loading automatically loads related data when the navigation property is accessed.
Setup with Proxies
// Package: Microsoft.EntityFrameworkCore.Proxies
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString)
.UseLazyLoadingProxies());
// Entities must have virtual navigation properties
public class Order
{
public int Id { get; set; }
public virtual Customer Customer { get; set; } // virtual required
public virtual ICollection<OrderItem> Items { get; set; }
}
Setup with ILazyLoader (No Proxies)
public class Order
{
private readonly ILazyLoader _lazyLoader;
private Customer _customer;
private ICollection<OrderItem> _items;
public Order() { }
public Order(ILazyLoader lazyLoader)
{
_lazyLoader = lazyLoader;
}
public int Id { get; set; }
public Customer Customer
{
get => _lazyLoader.Load(this, ref _customer);
set => _customer = value;
}
public ICollection<OrderItem> Items
{
get => _lazyLoader.Load(this, ref _items);
set => _items = value;
}
}
The N+1 Problem
// PROBLEM: Each iteration triggers a new query
var orders = await context.Orders.ToListAsync(); // Query 1
foreach (var order in orders)
{
// Each access triggers a query
Console.WriteLine(order.Customer.Name); // Query 2, 3, 4...
foreach (var item in order.Items) // More queries!
{
Console.WriteLine(item.Product.Name);
}
}
// Result: 1 + N + N*M queries for N orders with M items each
// SOLUTION: Use Eager Loading when you know you need the data
var orders = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.ToListAsync();
[DEBUGGING] Identifying Lazy Loading Issues
// Enable sensitive data logging in development
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString)
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
.LogTo(Console.WriteLine, LogLevel.Information));
// Or use interceptors for production monitoring
public class LazyLoadingInterceptor : DbCommandInterceptor
{
private readonly ILogger<LazyLoadingInterceptor> _logger;
private int _queryCount = 0;
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
_queryCount++;
if (_queryCount > 10)
{
_logger.LogWarning(
"Potential N+1 detected. Query #{Count}: {Sql}",
_queryCount,
command.CommandText);
}
return base.ReaderExecuting(command, eventData, result);
}
}
[PRODUCTION] When to Use Lazy Loading (Rarely!)
// AVOID lazy loading in:
// - Web applications (unpredictable query count)
// - High-traffic scenarios
// - When you know you'll need related data
// CONSIDER lazy loading in:
// - Desktop applications with connected scenarios
// - Exploration/admin tools
// - When related data is rarely needed
// Better alternatives:
// 1. Eager loading with Include
// 2. Projection with Select
// 3. Explicit loading for conditional cases
1.4 Loading Strategy Comparison
Decision Matrix
| Scenario | Recommended Strategy | Reason |
|---|---|---|
| Always need related data | Eager Loading | Single query, predictable |
| Sometimes need related data | Explicit Loading | Load on demand |
| Rarely need related data | Lazy Loading* | Auto-load when accessed |
| Read-only scenarios | Projection (Select) | Best performance |
| Multiple collections | Split Query | Avoid Cartesian explosion |
| Mobile/Low bandwidth | Projection | Minimal data transfer |
*Use with caution - prefer Explicit Loading in most cases
[BENCHMARK] Real-World Scenarios
// Scenario: Load 1000 orders with customer and 5 items each
| Strategy | Queries | Time | Memory | Best For |
|----------|---------|------|--------|----------|
| Eager (Single) | 1 | 45ms | 50MB | Small datasets |
| Eager (Split) | 3 | 35ms | 40MB | Multiple collections |
| Lazy | ~6000 | 2.5s | 80MB | Never for lists |
| Explicit | 3 | 40ms | 42MB | Conditional loading |
| Projection | 1 | 15ms | 15MB | Read-only scenarios |
// Projection example (fastest):
var orders = await context.Orders
.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
ItemCount = o.Items.Count,
Total = o.Items.Sum(i => i.Price * i.Quantity)
})
.ToListAsync();
2. Query Optimization Deep Dive
2.1 AsSplitQuery
Single Query vs Split Query
// SINGLE QUERY (default) - One query with JOINs
var orders = await context.Orders
.Include(o => o.OrderItems)
.Include(o => o.Payments)
.ToListAsync();
// Generated SQL: One query with LEFT JOINs
// Problem: Cartesian explosion with multiple collections
// SPLIT QUERY - Separate queries per Include
var orders = await context.Orders
.Include(o => o.OrderItems)
.Include(o => o.Payments)
.AsSplitQuery()
.ToListAsync();
// Generated SQL: Three separate queries
// Query 1: SELECT * FROM Orders
// Query 2: SELECT * FROM OrderItems WHERE OrderId IN (...)
// Query 3: SELECT * FROM Payments WHERE OrderId IN (...)
[BENCHMARK] Performance Comparison
// Scenario: 100 orders, 10 items each, 3 payments each
| Query Type | Rows Returned | DB Round Trips | Time | Memory |
|------------|---------------|----------------|------|--------|
| Single | 3,000 | 1 | 85ms | 45MB |
| Split | 1,400 | 3 | 55ms | 25MB |
// When Split Query wins:
// - Multiple collection navigations
// - Large result sets
// - High item count per parent
// When Single Query wins:
// - Simple queries (1-2 includes)
// - Low network latency to DB
// - Small datasets
When Split Queries Help/Hurt
// HELPS: Multiple collections
var orders = await context.Orders
.Include(o => o.Items) // Collection
.Include(o => o.Payments) // Collection
.Include(o => o.AuditLogs) // Collection
.AsSplitQuery() // Avoids 3-way Cartesian explosion
.ToListAsync();
// DOESN'T HELP: Reference navigations (no Cartesian explosion)
var orders = await context.Orders
.Include(o => o.Customer) // Reference (1:1)
.Include(o => o.ShipAddress) // Reference (1:1)
.ToListAsync(); // Single query is fine
// HURTS: When data consistency matters
// Split queries may see different data if changes occur between queries
var orders = await context.Orders
.Include(o => o.Items)
.AsSplitQuery()
.ToListAsync();
// If an item is added between Query 1 and Query 2, you might get inconsistent data
Configure as Default
// Set split query as default for all queries
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString)
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
// Override per query if needed
var orders = await context.Orders
.Include(o => o.Customer)
.AsSingleQuery() // Override default
.ToListAsync();
2.2 Compiled Queries
EF.CompileQuery Usage
// Define compiled query as static field
private static readonly Func<ApplicationDbContext, int, Task<Order?>> GetOrderById =
EF.CompileAsyncQuery(
(ApplicationDbContext context, int id) =>
context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.FirstOrDefault(o => o.Id == id));
// Usage
public async Task<Order?> GetOrderAsync(int id)
{
return await GetOrderById(_context, id);
}
// More complex example with multiple parameters
private static readonly Func<ApplicationDbContext, int, DateTime, DateTime, IAsyncEnumerable<Order>>
GetOrdersByCustomerInDateRange = EF.CompileAsyncQuery(
(ApplicationDbContext context, int customerId, DateTime start, DateTime end) =>
context.Orders
.Where(o => o.CustomerId == customerId &&
o.OrderDate >= start &&
o.OrderDate <= end)
.OrderByDescending(o => o.OrderDate));
// Usage with IAsyncEnumerable
public async IAsyncEnumerable<Order> GetOrdersAsync(
int customerId, DateTime start, DateTime end)
{
await foreach (var order in GetOrdersByCustomerInDateRange(
_context, customerId, start, end))
{
yield return order;
}
}
Limitations and Gotchas
// LIMITATION 1: No dynamic filters
// WRONG - Can't compile with dynamic Where clauses
// var query = EF.CompileQuery((context, filter) =>
// context.Orders.Where(filter)); // Error!
// LIMITATION 2: No navigation fixup
var getOrder = EF.CompileAsyncQuery(
(ApplicationDbContext ctx, int id) =>
ctx.Orders.FirstOrDefault(o => o.Id == id));
var order1 = await getOrder(_context, 1);
var order2 = await getOrder(_context, 1);
// order1 != order2 (different instances)
// LIMITATION 3: Limited to simple return types
// IEnumerable<T>, IAsyncEnumerable<T>, T, T?
// WORKAROUND for dynamic queries: Use expression building
public async Task<List<Order>> GetOrdersAsync(OrderFilter filter)
{
var query = _context.Orders.AsQueryable();
if (filter.CustomerId.HasValue)
query = query.Where(o => o.CustomerId == filter.CustomerId);
if (filter.MinTotal.HasValue)
query = query.Where(o => o.Total >= filter.MinTotal);
// Can't be compiled, but at least optimized with query caching
return await query.ToListAsync();
}
[BENCHMARK] Compilation Overhead vs Query Speedup
// Benchmark: Execute same query 10,000 times
| Query Type | First Execution | Subsequent | Total Time |
|------------|-----------------|------------|------------|
| Regular | 15ms | 0.8ms | 8.0s |
| Compiled | 0.5ms | 0.3ms | 3.0s |
// Compiled queries skip:
// - LINQ expression parsing
// - Query plan generation
// - Parameter binding setup
// Best use cases:
// 1. Hot paths executed frequently
// 2. Simple queries with known parameters
// 3. High-throughput scenarios
[PRODUCTION] When to Use Compiled Queries
// USE compiled queries for:
// - Queries executed > 1000 times/minute
// - Simple CRUD operations (GetById, GetAll)
// - Queries in hot loops
// DON'T USE for:
// - Dynamic/filterable queries
// - Queries executed rarely
// - Complex queries that change often
// Example: Repository with compiled queries
public class OrderRepository
{
private static readonly Func<ApplicationDbContext, int, Task<Order?>> _getById =
EF.CompileAsyncQuery((ApplicationDbContext ctx, int id) =>
ctx.Orders.FirstOrDefault(o => o.Id == id));
private static readonly Func<ApplicationDbContext, int, IAsyncEnumerable<Order>> _getByCustomer =
EF.CompileAsyncQuery((ApplicationDbContext ctx, int customerId) =>
ctx.Orders.Where(o => o.CustomerId == customerId));
public Task<Order?> GetByIdAsync(int id) => _getById(_context, id);
public IAsyncEnumerable<Order> GetByCustomerAsync(int customerId) =>
_getByCustomer(_context, customerId);
}
2.3 LINQ to SQL Translation
Expression Tree to SQL Conversion
// LINQ expression
var expensiveOrders = context.Orders
.Where(o => o.Total > 1000 && o.Customer.Country == "USA")
.OrderByDescending(o => o.OrderDate)
.Select(o => new { o.Id, o.Total, CustomerName = o.Customer.Name })
.Take(10);
// EF Core builds an expression tree, then translates to SQL:
/*
SELECT TOP(10) [o].[Id], [o].[Total], [c].[Name] AS [CustomerName]
FROM [Orders] AS [o]
INNER JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id]
WHERE [o].[Total] > 1000 AND [c].[Country] = N'USA'
ORDER BY [o].[OrderDate] DESC
*/
Client vs Server Evaluation
// SERVER EVALUATION (Good) - Runs in database
var orders = await context.Orders
.Where(o => o.Total > 1000)
.Where(o => o.OrderDate.Year == 2024) // Translated to SQL
.ToListAsync();
// CLIENT EVALUATION (Bad) - Loads all data, filters in memory
var orders = await context.Orders
.Where(o => MyCustomMethod(o.Total)) // Can't translate!
.ToListAsync();
// Warning: This will load ALL orders, then filter in memory
// Mixed evaluation (Partial client evaluation)
var orders = await context.Orders
.Where(o => o.Total > 1000) // Server
.AsEnumerable() // Switch to client
.Where(o => MyCustomMethod(o.Total)) // Client
.ToList();
[GOTCHAS] Client Evaluation Detection
// EF Core 3.0+ throws by default on client evaluation
// Configure to warn instead (not recommended for production):
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString)
.ConfigureWarnings(w => w.Throw(RelationalEventId.QueryPossibleUnintendedUseOfEqualsWarning)));
// Common patterns that can't be translated:
// 1. Custom methods
.Where(o => CalculateDiscount(o.Total) > 100) // Error
// 2. Complex string operations
.Where(o => Regex.IsMatch(o.Name, pattern)) // Error
// 3. Collection methods not supported
.Where(o => o.Tags.Any(t => customList.Contains(t))) // May error
// SOLUTIONS:
// 1. Use EF.Functions for DB operations
.Where(o => EF.Functions.Like(o.Name, "%search%"))
// 2. Compute before query
var discount = CalculateDiscount(1000);
.Where(o => o.Total > discount)
// 3. Use raw SQL for complex operations
context.Orders.FromSqlRaw("SELECT * FROM Orders WHERE ...")
Query Tags for Debugging
// Add tags to identify queries in logs and query store
var orders = await context.Orders
.TagWith("GetRecentOrders - OrderService.cs:42")
.TagWith($"User: {userId}, Date: {DateTime.UtcNow}")
.Where(o => o.OrderDate > DateTime.UtcNow.AddDays(-30))
.ToListAsync();
// Generated SQL includes comments:
/*
-- GetRecentOrders - OrderService.cs:42
-- User: 123, Date: 2024-01-15
SELECT [o].[Id], [o].[OrderDate], [o].[Total]
FROM [Orders] AS [o]
WHERE [o].[OrderDate] > @date
*/
[DEBUGGING] Logging Generated SQL
// Method 1: Configure logging in DbContext
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging()); // Shows parameter values
// Method 2: Use ToQueryString() for specific queries
var query = context.Orders
.Where(o => o.Total > 1000)
.Include(o => o.Customer);
var sql = query.ToQueryString();
Console.WriteLine(sql);
// Method 3: Interceptor for production logging
public class QueryLoggingInterceptor : DbCommandInterceptor
{
private readonly ILogger _logger;
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken ct = default)
{
_logger.LogDebug(
"Executing query: {Query}\nParameters: {Parameters}",
command.CommandText,
string.Join(", ", command.Parameters.Cast<DbParameter>()
.Select(p => $"{p.ParameterName}={p.Value}")));
return base.ReaderExecutingAsync(command, eventData, result, ct);
}
}
2.4 Global Query Filters
Multi-Tenancy Implementation
public class ApplicationDbContext : DbContext
{
private readonly ITenantService _tenantService;
public ApplicationDbContext(
DbContextOptions options,
ITenantService tenantService) : base(options)
{
_tenantService = tenantService;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply tenant filter to all tenant entities
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenantService.CurrentTenantId);
modelBuilder.Entity<Customer>()
.HasQueryFilter(c => c.TenantId == _tenantService.CurrentTenantId);
modelBuilder.Entity<Product>()
.HasQueryFilter(p => p.TenantId == _tenantService.CurrentTenantId);
}
}
// All queries automatically filtered:
var orders = await context.Orders.ToListAsync();
// SQL: SELECT * FROM Orders WHERE TenantId = @currentTenantId
Soft Delete with Global Filters
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
}
public class Order : ISoftDeletable
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply to all soft-deletable entities
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
{
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted));
var condition = Expression.Equal(property, Expression.Constant(false));
var lambda = Expression.Lambda(condition, parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
}
Bypassing Filters (IgnoreQueryFilters)
// Normal query - filter applied
var activeOrders = await context.Orders.ToListAsync();
// SQL: SELECT * FROM Orders WHERE IsDeleted = 0
// Bypass filter for admin operations
var allOrders = await context.Orders
.IgnoreQueryFilters()
.ToListAsync();
// SQL: SELECT * FROM Orders
// Bypass for specific scenarios
public async Task<Order?> GetOrderIncludingDeletedAsync(int id)
{
return await _context.Orders
.IgnoreQueryFilters()
.FirstOrDefaultAsync(o => o.Id == id);
}
// Note: IgnoreQueryFilters removes ALL filters, not just soft delete
// For selective bypass, use conditional logic:
public async Task<List<Order>> GetOrdersAsync(bool includeDeleted = false)
{
var query = _context.Orders.AsQueryable();
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
return await query.ToListAsync();
}
Filter Composition and Conflicts
// Multiple filters are combined with AND
modelBuilder.Entity<Order>()
.HasQueryFilter(o => !o.IsDeleted && o.TenantId == _tenantId);
// Or apply multiple filters separately (they stack)
modelBuilder.Entity<Order>()
.HasQueryFilter(o => !o.IsDeleted);
// In a second configuration:
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenantId);
// WARNING: Second filter REPLACES the first one!
// SOLUTION: Combine in single filter or use interface approach
[CODE] Full Multi-Tenant Implementation
// Tenant service
public interface ITenantService
{
Guid CurrentTenantId { get; }
}
public class TenantService : ITenantService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Guid CurrentTenantId =>
Guid.Parse(_httpContextAccessor.HttpContext?
.User.FindFirst("tenant_id")?.Value
?? throw new UnauthorizedAccessException("No tenant"));
}
// Base entity
public abstract class TenantEntity
{
public Guid TenantId { get; set; }
}
// DbContext
public class MultiTenantDbContext : DbContext
{
private readonly Guid _tenantId;
public MultiTenantDbContext(
DbContextOptions options,
ITenantService tenantService) : base(options)
{
_tenantId = tenantService.CurrentTenantId;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(TenantEntity).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.HasQueryFilter(CreateTenantFilter(entityType.ClrType));
}
}
}
private LambdaExpression CreateTenantFilter(Type entityType)
{
var parameter = Expression.Parameter(entityType, "e");
var tenantProperty = Expression.Property(parameter, nameof(TenantEntity.TenantId));
var tenantValue = Expression.Constant(_tenantId);
var condition = Expression.Equal(tenantProperty, tenantValue);
return Expression.Lambda(condition, parameter);
}
public override int SaveChanges()
{
SetTenantId();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
SetTenantId();
return base.SaveChangesAsync(ct);
}
private void SetTenantId()
{
foreach (var entry in ChangeTracker.Entries<TenantEntity>()
.Where(e => e.State == EntityState.Added))
{
entry.Entity.TenantId = _tenantId;
}
}
}
3. Advanced Modeling
3.1 Shadow Properties
Definition and Configuration
// Shadow properties exist in the model but not in the entity class
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Define shadow property
modelBuilder.Entity<Order>()
.Property<DateTime>("CreatedAt");
modelBuilder.Entity<Order>()
.Property<DateTime>("ModifiedAt");
modelBuilder.Entity<Order>()
.Property<string>("CreatedBy")
.HasMaxLength(100);
}
Audit Fields Implementation
public interface IAuditable
{
// Interface doesn't need the properties - they're shadow properties
}
public class Order : IAuditable
{
public int Id { get; set; }
public decimal Total { get; set; }
// CreatedAt, ModifiedAt, CreatedBy, ModifiedBy are shadow properties
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(IAuditable).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.Property<DateTime>("CreatedAt")
.HasDefaultValueSql("GETUTCDATE()");
modelBuilder.Entity(entityType.ClrType)
.Property<DateTime>("ModifiedAt");
modelBuilder.Entity(entityType.ClrType)
.Property<string>("CreatedBy")
.HasMaxLength(100);
modelBuilder.Entity(entityType.ClrType)
.Property<string>("ModifiedBy")
.HasMaxLength(100);
}
}
}
// Set values on SaveChanges
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var entries = ChangeTracker.Entries()
.Where(e => e.Entity is IAuditable &&
(e.State == EntityState.Added || e.State == EntityState.Modified));
foreach (var entry in entries)
{
entry.Property("ModifiedAt").CurrentValue = DateTime.UtcNow;
entry.Property("ModifiedBy").CurrentValue = _currentUser.Id;
if (entry.State == EntityState.Added)
{
entry.Property("CreatedAt").CurrentValue = DateTime.UtcNow;
entry.Property("CreatedBy").CurrentValue = _currentUser.Id;
}
}
return base.SaveChangesAsync(ct);
}
Accessing Shadow Properties
// Read shadow property value
var order = await context.Orders.FindAsync(id);
var createdAt = context.Entry(order).Property<DateTime>("CreatedAt").CurrentValue;
// Query using shadow property
var recentOrders = await context.Orders
.Where(o => EF.Property<DateTime>(o, "CreatedAt") > DateTime.UtcNow.AddDays(-7))
.OrderByDescending(o => EF.Property<DateTime>(o, "CreatedAt"))
.ToListAsync();
// Set shadow property value
context.Entry(order).Property("ModifiedAt").CurrentValue = DateTime.UtcNow;
// Include in projection
var orders = await context.Orders
.Select(o => new
{
o.Id,
o.Total,
CreatedAt = EF.Property<DateTime>(o, "CreatedAt"),
CreatedBy = EF.Property<string>(o, "CreatedBy")
})
.ToListAsync();
3.2 Owned Types
Value Objects with Owned Types
// Value object (no identity)
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string ZipCode { get; set; }
public string Country { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public Address BillingAddress { get; set; }
public Address ShippingAddress { get; set; }
}
// Configuration
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(builder =>
{
builder.OwnsOne(c => c.BillingAddress, address =>
{
address.Property(a => a.Street).HasColumnName("BillingStreet");
address.Property(a => a.City).HasColumnName("BillingCity");
address.Property(a => a.State).HasColumnName("BillingState");
address.Property(a => a.ZipCode).HasColumnName("BillingZipCode");
address.Property(a => a.Country).HasColumnName("BillingCountry");
});
builder.OwnsOne(c => c.ShippingAddress, address =>
{
address.Property(a => a.Street).HasColumnName("ShippingStreet");
// ... other properties
});
});
}
// Result: Single Customers table with columns:
// Id, Name, BillingStreet, BillingCity, ..., ShippingStreet, ShippingCity, ...
Table Splitting Configuration
// Store owned type in separate table
modelBuilder.Entity<Customer>(builder =>
{
builder.OwnsOne(c => c.BillingAddress, address =>
{
address.ToTable("CustomerBillingAddresses");
});
});
// Result: Two tables:
// Customers: Id, Name
// CustomerBillingAddresses: CustomerId, Street, City, State, ...
Owned Collections
public class Order
{
public int Id { get; set; }
public List<OrderLine> Lines { get; set; } // Owned collection
}
public class OrderLine // No Id - owned by Order
{
public string ProductName { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.OwnsMany(o => o.Lines, line =>
{
line.ToTable("OrderLines");
line.WithOwner().HasForeignKey("OrderId");
line.Property<int>("Id"); // Shadow property for PK
line.HasKey("Id");
});
}
JSON Column Mapping (.NET 7+)
// Store complex type as JSON column
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public ProductMetadata Metadata { get; set; }
}
public class ProductMetadata
{
public List<string> Tags { get; set; }
public Dictionary<string, string> Attributes { get; set; }
public ProductDimensions Dimensions { get; set; }
}
public class ProductDimensions
{
public decimal Width { get; set; }
public decimal Height { get; set; }
public decimal Depth { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.OwnsOne(p => p.Metadata, metadata =>
{
metadata.ToJson(); // Store as JSON column
metadata.OwnsOne(m => m.Dimensions);
});
}
// Query JSON properties
var products = await context.Products
.Where(p => p.Metadata.Tags.Contains("featured"))
.Where(p => p.Metadata.Dimensions.Width > 10)
.ToListAsync();
3.3 Inheritance Strategies
Table-per-Hierarchy (TPH)
// Base class
public abstract class Payment
{
public int Id { get; set; }
public decimal Amount { get; set; }
public DateTime ProcessedAt { get; set; }
}
// Derived classes
public class CreditCardPayment : Payment
{
public string CardNumber { get; set; }
public string CardHolderName { get; set; }
}
public class BankTransferPayment : Payment
{
public string AccountNumber { get; set; }
public string RoutingNumber { get; set; }
}
public class CryptoPayment : Payment
{
public string WalletAddress { get; set; }
public string CryptoCurrency { get; set; }
}
// TPH Configuration (default)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Payment>()
.HasDiscriminator<string>("PaymentType")
.HasValue<CreditCardPayment>("CreditCard")
.HasValue<BankTransferPayment>("BankTransfer")
.HasValue<CryptoPayment>("Crypto");
}
// Result: Single table with discriminator column
// Payments: Id, Amount, ProcessedAt, PaymentType, CardNumber, CardHolderName,
// AccountNumber, RoutingNumber, WalletAddress, CryptoCurrency
// NULL values for columns not applicable to each type
Table-per-Type (TPT)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Payment>().ToTable("Payments");
modelBuilder.Entity<CreditCardPayment>().ToTable("CreditCardPayments");
modelBuilder.Entity<BankTransferPayment>().ToTable("BankTransferPayments");
modelBuilder.Entity<CryptoPayment>().ToTable("CryptoPayments");
}
// Result: Separate tables
// Payments: Id, Amount, ProcessedAt
// CreditCardPayments: Id, CardNumber, CardHolderName (FK to Payments)
// BankTransferPayments: Id, AccountNumber, RoutingNumber (FK to Payments)
// CryptoPayments: Id, WalletAddress, CryptoCurrency (FK to Payments)
Table-per-Concrete-Type (TPC) - .NET 7+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Payment>().UseTpcMappingStrategy();
modelBuilder.Entity<CreditCardPayment>().ToTable("CreditCardPayments");
modelBuilder.Entity<BankTransferPayment>().ToTable("BankTransferPayments");
modelBuilder.Entity<CryptoPayment>().ToTable("CryptoPayments");
}
// Result: Separate tables, each with ALL columns
// CreditCardPayments: Id, Amount, ProcessedAt, CardNumber, CardHolderName
// BankTransferPayments: Id, Amount, ProcessedAt, AccountNumber, RoutingNumber
// CryptoPayments: Id, Amount, ProcessedAt, WalletAddress, CryptoCurrency
// No base Payments table!
[BENCHMARK] Performance Comparison
// Query: Get all payments over $1000
| Strategy | Tables | JOINs | Rows Scanned | Query Time |
|----------|--------|-------|--------------|------------|
| TPH | 1 | 0 | All rows | 15ms |
| TPT | 4 | 3 | Base + matched| 45ms |
| TPC | 3 | 0 | All 3 tables | 25ms |
// TPH: Best for polymorphic queries, simple schema
// TPT: Best for many derived types with many properties
// TPC: Best for querying single concrete types
// INSERT performance:
| Strategy | Tables Written | Insert Time |
|----------|---------------|-------------|
| TPH | 1 | 2ms |
| TPT | 2 | 8ms |
| TPC | 1 | 2ms |
[PRODUCTION] Migration Between Strategies
// Migrating from TPH to TPT requires:
// 1. Create new tables
// 2. Migrate data with SQL
// 3. Update EF configuration
// 4. Drop discriminator column
// Migration SQL example (TPH to TPT):
migrationBuilder.Sql(@"
-- Create derived tables
CREATE TABLE CreditCardPayments (
Id INT PRIMARY KEY REFERENCES Payments(Id),
CardNumber NVARCHAR(20),
CardHolderName NVARCHAR(100)
);
-- Migrate data
INSERT INTO CreditCardPayments (Id, CardNumber, CardHolderName)
SELECT Id, CardNumber, CardHolderName
FROM Payments
WHERE PaymentType = 'CreditCard';
-- Clean up base table
ALTER TABLE Payments DROP COLUMN CardNumber, CardHolderName;
ALTER TABLE Payments DROP COLUMN PaymentType;
");
4. Concurrency & Transactions
4.1 Optimistic Concurrency
Concurrency Tokens Configuration
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } // SQL Server rowversion
}
// Or via Fluent API
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.Property(p => p.RowVersion)
.IsRowVersion();
// Alternative: Use specific property as concurrency token
modelBuilder.Entity<Product>()
.Property(p => p.Price)
.IsConcurrencyToken();
}
RowVersion/Timestamp Implementation
// SQL Server automatically updates rowversion on each UPDATE
// Generated SQL:
/*
UPDATE [Products]
SET [Name] = @Name, [Price] = @Price
WHERE [Id] = @Id AND [RowVersion] = @OriginalRowVersion;
SELECT [RowVersion]
FROM [Products]
WHERE @@ROWCOUNT = 1 AND [Id] = @Id;
*/
Handling DbUpdateConcurrencyException
public async Task<bool> UpdateProductPriceAsync(int id, decimal newPrice)
{
var maxRetries = 3;
var retryCount = 0;
while (retryCount < maxRetries)
{
try
{
var product = await _context.Products.FindAsync(id);
if (product == null) return false;
product.Price = newPrice;
await _context.SaveChangesAsync();
return true;
}
catch (DbUpdateConcurrencyException ex)
{
retryCount++;
if (retryCount >= maxRetries)
{
_logger.LogError(ex,
"Concurrency conflict for product {Id} after {Retries} retries",
id, retryCount);
throw;
}
// Get the current database values
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync();
if (databaseValues == null)
{
// Entity was deleted
throw new InvalidOperationException("Product was deleted");
}
// Refresh with database values
entry.OriginalValues.SetValues(databaseValues);
_logger.LogWarning(
"Concurrency conflict for product {Id}, retry {Retry}/{Max}",
id, retryCount, maxRetries);
}
}
return false;
}
[CODE] Full Conflict Handling Implementation
public class ConflictResolutionService<T> where T : class
{
public enum ResolutionStrategy
{
ClientWins, // Overwrite database with client values
DatabaseWins, // Discard client changes
Merge, // Custom merge logic
ThrowException // Let caller handle
}
public async Task<T> ResolveConflictAsync(
DbContext context,
DbUpdateConcurrencyException exception,
ResolutionStrategy strategy,
Func<T, T, T>? mergeFunc = null)
{
var entry = exception.Entries.Single();
var clientValues = entry.CurrentValues;
var databaseEntry = await entry.GetDatabaseValuesAsync();
if (databaseEntry == null)
{
throw new InvalidOperationException("Entity was deleted");
}
switch (strategy)
{
case ResolutionStrategy.ClientWins:
// Keep client values, update original to match DB
entry.OriginalValues.SetValues(databaseEntry);
await context.SaveChangesAsync();
return (T)entry.Entity;
case ResolutionStrategy.DatabaseWins:
// Discard client changes, reload from DB
entry.CurrentValues.SetValues(databaseEntry);
entry.OriginalValues.SetValues(databaseEntry);
return (T)entry.Entity;
case ResolutionStrategy.Merge:
if (mergeFunc == null)
throw new ArgumentNullException(nameof(mergeFunc));
var clientEntity = entry.CurrentValues.ToObject() as T;
var databaseEntity = databaseEntry.ToObject() as T;
var merged = mergeFunc(clientEntity!, databaseEntity!);
entry.CurrentValues.SetValues(merged);
entry.OriginalValues.SetValues(databaseEntry);
await context.SaveChangesAsync();
return merged;
case ResolutionStrategy.ThrowException:
default:
throw exception;
}
}
}
// Usage
public async Task UpdateInventoryAsync(int productId, int quantityChange)
{
try
{
var product = await _context.Products.FindAsync(productId);
product.StockQuantity += quantityChange;
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
// Merge strategy: Add the change to whatever the current stock is
await _conflictService.ResolveConflictAsync(
_context,
ex,
ConflictResolutionService<Product>.ResolutionStrategy.Merge,
(client, database) =>
{
var originalStock = database.StockQuantity;
database.StockQuantity = originalStock + quantityChange;
return database;
});
}
}
4.2 Cascade Delete Behavior
DeleteBehavior Enum Explained
public enum DeleteBehavior
{
// When principal is deleted:
Cascade, // Dependents are also deleted
Restrict, // Prevent deletion if dependents exist
SetNull, // Set FK to null (requires nullable FK)
NoAction, // Database decides (usually Restrict)
ClientCascade, // EF deletes dependents in memory only
ClientSetNull, // EF sets FK to null in memory only
ClientNoAction // EF does nothing, DB may error
}
Configuring Relationships
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Cascade delete (default for required relationships)
modelBuilder.Entity<Order>()
.HasMany(o => o.OrderItems)
.WithOne(oi => oi.Order)
.HasForeignKey(oi => oi.OrderId)
.OnDelete(DeleteBehavior.Cascade);
// Restrict (prevent delete if children exist)
modelBuilder.Entity<Customer>()
.HasMany(c => c.Orders)
.WithOne(o => o.Customer)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
// SetNull (orphan the children)
modelBuilder.Entity<Department>()
.HasMany(d => d.Employees)
.WithOne(e => e.Department)
.HasForeignKey(e => e.DepartmentId)
.OnDelete(DeleteBehavior.SetNull);
}
[GOTCHAS] Common Cascade Delete Issues
// ISSUE 1: Multiple cascade paths
// SQL Server doesn't allow multiple cascade paths to the same table
public class Order
{
public int Id { get; set; }
public int BillingAddressId { get; set; }
public int ShippingAddressId { get; set; }
public Address BillingAddress { get; set; } // Cascade
public Address ShippingAddress { get; set; } // Cascade - ERROR!
}
// SOLUTION: Use Restrict or ClientCascade for one path
modelBuilder.Entity<Order>()
.HasOne(o => o.BillingAddress)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Order>()
.HasOne(o => o.ShippingAddress)
.OnDelete(DeleteBehavior.Restrict);
// ISSUE 2: Circular cascade
public class Employee
{
public int Id { get; set; }
public int? ManagerId { get; set; }
public Employee Manager { get; set; }
public ICollection<Employee> DirectReports { get; set; }
}
// SOLUTION: Use SetNull or handle in code
modelBuilder.Entity<Employee>()
.HasOne(e => e.Manager)
.WithMany(e => e.DirectReports)
.OnDelete(DeleteBehavior.SetNull);
[PRODUCTION] Safe Deletion Patterns
public class SafeDeleteService
{
public async Task<DeleteResult> SafeDeleteCustomerAsync(int customerId)
{
var customer = await _context.Customers
.Include(c => c.Orders)
.FirstOrDefaultAsync(c => c.Id == customerId);
if (customer == null)
return DeleteResult.NotFound;
if (customer.Orders.Any())
{
return DeleteResult.HasDependents(
$"Customer has {customer.Orders.Count} orders");
}
_context.Customers.Remove(customer);
await _context.SaveChangesAsync();
return DeleteResult.Success;
}
public async Task<DeleteResult> CascadeDeleteCustomerAsync(int customerId)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Delete in correct order
await _context.OrderItems
.Where(oi => oi.Order.CustomerId == customerId)
.ExecuteDeleteAsync();
await _context.Orders
.Where(o => o.CustomerId == customerId)
.ExecuteDeleteAsync();
await _context.Customers
.Where(c => c.Id == customerId)
.ExecuteDeleteAsync();
await transaction.CommitAsync();
return DeleteResult.Success;
}
catch (Exception ex)
{
await transaction.RollbackAsync();
return DeleteResult.Error(ex.Message);
}
}
}
5. Production Patterns
5.1 Migrations in Production
Migration Scripts Generation
# Generate SQL script from migrations
dotnet ef migrations script --output migrations.sql
# Generate script from specific migration
dotnet ef migrations script InitialCreate AddOrderTable --output upgrade.sql
# Generate idempotent script (can run multiple times safely)
dotnet ef migrations script --idempotent --output idempotent-migrations.sql
Idempotent Migrations
// Generated idempotent migration SQL checks if already applied:
/*
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20240115_AddOrderTable'
)
BEGIN
CREATE TABLE [Orders] (...)
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20240115_AddOrderTable', N'8.0.0');
END;
GO
*/
Data Migrations with SQL
public partial class SplitFullName : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Step 1: Add new columns
migrationBuilder.AddColumn<string>(
name: "FirstName",
table: "Customers",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastName",
table: "Customers",
nullable: true);
// Step 2: Migrate data
migrationBuilder.Sql(@"
UPDATE Customers
SET FirstName = SUBSTRING(FullName, 1, CHARINDEX(' ', FullName) - 1),
LastName = SUBSTRING(FullName, CHARINDEX(' ', FullName) + 1, LEN(FullName))
WHERE FullName IS NOT NULL AND CHARINDEX(' ', FullName) > 0;
UPDATE Customers
SET FirstName = FullName, LastName = ''
WHERE FullName IS NOT NULL AND CHARINDEX(' ', FullName) = 0;
");
// Step 3: Make columns required
migrationBuilder.AlterColumn<string>(
name: "FirstName",
table: "Customers",
nullable: false,
defaultValue: "");
migrationBuilder.AlterColumn<string>(
name: "LastName",
table: "Customers",
nullable: false,
defaultValue: "");
// Step 4: Drop old column
migrationBuilder.DropColumn(
name: "FullName",
table: "Customers");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Reverse migration
migrationBuilder.AddColumn<string>(
name: "FullName",
table: "Customers",
nullable: true);
migrationBuilder.Sql(@"
UPDATE Customers
SET FullName = CONCAT(FirstName, ' ', LastName)
");
migrationBuilder.DropColumn(name: "FirstName", table: "Customers");
migrationBuilder.DropColumn(name: "LastName", table: "Customers");
}
}
[PRODUCTION] CI/CD Pipeline Integration
# Azure DevOps pipeline example
stages:
- stage: Build
jobs:
- job: BuildAndTest
steps:
- task: DotNetCoreCLI@2
displayName: 'Generate Migration Script'
inputs:
command: 'custom'
custom: 'ef'
arguments: 'migrations script --idempotent --output $(Build.ArtifactStagingDirectory)/migrations.sql --project src/MyApp.Data'
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)/migrations.sql'
ArtifactName: 'migrations'
- stage: Deploy
jobs:
- deployment: DeployDatabase
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- task: SqlAzureDacpacDeployment@1
displayName: 'Apply Migrations'
inputs:
azureSubscription: 'Production'
ServerName: 'prod-sql.database.windows.net'
DatabaseName: 'MyAppDb'
SqlFile: '$(Pipeline.Workspace)/migrations/migrations.sql'
Rollback Strategies
// Strategy 1: Use Down() migration (risky in production)
dotnet ef database update PreviousMigration
// Strategy 2: Generate rollback script beforehand
dotnet ef migrations script CurrentMigration PreviousMigration --output rollback.sql
// Strategy 3: Blue-green deployment
// - Deploy new version to "green" environment
// - Run migrations on green database
// - Test thoroughly
// - Switch traffic to green
// - Keep blue as fallback
// Strategy 4: Forward-only migrations (recommended)
// Instead of rolling back, create a new migration that undoes changes
public partial class RevertSplitFullName : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Re-add the old column
migrationBuilder.AddColumn<string>(
name: "FullName",
table: "Customers",
nullable: true);
// Migrate data back
migrationBuilder.Sql(@"
UPDATE Customers
SET FullName = CONCAT(FirstName, ' ', LastName)
");
}
}
5.2 Connection Pooling
How Pooling Works Internally
// Connection pooling is handled by ADO.NET, not EF Core
// Connections are pooled by connection string
// Each unique connection string gets its own pool
// Default: Min pool size = 0, Max pool size = 100
// Lifecycle:
// 1. First request: Create new connection, add to pool
// 2. Request completes: Return connection to pool (not closed)
// 3. Next request: Reuse connection from pool
// 4. Pool full: Wait for available connection or timeout
Connection String Configuration
// Configure pooling in connection string
var connectionString = new SqlConnectionStringBuilder
{
DataSource = "server",
InitialCatalog = "database",
IntegratedSecurity = true,
// Pooling settings
Pooling = true, // Enable pooling (default: true)
MinPoolSize = 5, // Minimum connections to keep open
MaxPoolSize = 100, // Maximum connections allowed
ConnectionLifetime = 300, // Max age in seconds before recycling
ConnectTimeout = 30 // Timeout when waiting for connection
}.ConnectionString;
Pool Size Optimization
// Rule of thumb: MaxPoolSize = (Number of CPU cores * 2) + Disk spindles
// For cloud databases: Start with 20-30 and adjust based on metrics
// Monitor pool usage
// SQL Server:
/*
SELECT
DB_NAME(dbid) as DatabaseName,
COUNT(*) as ConnectionCount,
loginame as LoginName,
status
FROM sys.sysprocesses
WHERE dbid > 0
GROUP BY dbid, loginame, status
ORDER BY COUNT(*) DESC
*/
// .NET Core metrics (via EventCounters)
using var listener = new EventListener();
listener.EnableEvents(
EventSource.GetSourceByName("Microsoft.Data.SqlClient.EventSource"),
EventLevel.Informational,
EventKeywords.All);
[DEBUGGING] Connection Pool Exhaustion
// Symptom: "Timeout expired. The timeout period elapsed prior to obtaining
// a connection from the pool."
// Common causes and fixes:
// 1. Connection leak - forgetting to dispose
// BAD:
public void ProcessData()
{
var context = new ApplicationDbContext(); // Never disposed!
var data = context.Orders.ToList();
}
// GOOD:
public void ProcessData()
{
using var context = new ApplicationDbContext();
var data = context.Orders.ToList();
}
// 2. Long-running queries blocking pool
// Use async methods and timeouts
context.Database.SetCommandTimeout(TimeSpan.FromSeconds(30));
// 3. Too many concurrent requests for pool size
// Increase MaxPoolSize or reduce concurrency
// 4. Debug with connection string
var connectionString = "...;Application Name=MyApp;Min Pool Size=0;Max Pool Size=10";
// Application Name helps identify in SQL Server
// 5. Clear pool on errors
SqlConnection.ClearAllPools(); // Nuclear option - clears all pools
5.3 High-Traffic Optimization
Read Replicas Configuration
// Configure separate contexts for read and write
services.AddDbContext<WriteDbContext>(options =>
options.UseSqlServer(config["ConnectionStrings:Write"]));
services.AddDbContext<ReadDbContext>(options =>
options.UseSqlServer(config["ConnectionStrings:Read"])
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
// Repository pattern with read/write separation
public class OrderRepository
{
private readonly WriteDbContext _writeContext;
private readonly ReadDbContext _readContext;
public async Task<Order?> GetByIdAsync(int id)
{
// Read from replica
return await _readContext.Orders
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == id);
}
public async Task<Order> CreateAsync(Order order)
{
// Write to primary
_writeContext.Orders.Add(order);
await _writeContext.SaveChangesAsync();
return order;
}
}
Projection-Only Queries
// Instead of loading full entities:
var orders = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ToListAsync(); // Loads all columns, tracks entities
// Use projection for read-only scenarios:
var orderSummaries = await context.Orders
.Select(o => new OrderSummaryDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
ItemCount = o.Items.Count,
Total = o.Items.Sum(i => i.Price * i.Quantity),
OrderDate = o.OrderDate
})
.ToListAsync(); // Only fetches needed columns, no tracking
No-Tracking Queries
// Per-query no tracking
var orders = await context.Orders
.AsNoTracking()
.ToListAsync();
// Context-level no tracking
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
// AsNoTrackingWithIdentityResolution - no tracking but fixes duplicates
var orders = await context.Orders
.Include(o => o.Customer)
.AsNoTrackingWithIdentityResolution()
.ToListAsync();
// If multiple orders have same customer, they'll reference same Customer instance
Streaming Large Result Sets
// Instead of loading all into memory:
var orders = await context.Orders.ToListAsync(); // Loads all at once
// Stream with IAsyncEnumerable:
public async IAsyncEnumerable<Order> StreamOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in context.Orders
.AsNoTracking()
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return order;
}
}
// Usage
await foreach (var order in StreamOrdersAsync(cancellationToken))
{
await ProcessOrderAsync(order);
}
[BENCHMARK] Optimization Impact Numbers
// Benchmark: Load 100,000 orders
| Technique | Time | Memory | CPU |
|-----------|------|--------|-----|
| Full entity load | 12s | 2.1GB | High |
| With AsNoTracking | 8s | 1.4GB | Medium |
| Projection (Select) | 3s | 400MB | Low |
| Streaming | 4s | 50MB | Low |
// Best practices combination:
var orders = await context.Orders
.AsNoTracking() // No change tracking
.AsSplitQuery() // Avoid Cartesian explosion
.Where(o => o.Date > cutoffDate) // Filter early
.Select(o => new OrderDto {...}) // Project only needed data
.ToListAsync();
6. Raw SQL & Advanced Operations
6.1 Raw SQL Queries
FromSqlRaw vs FromSqlInterpolated
// FromSqlRaw - Use when you have dynamic SQL or need full control
// WARNING: Vulnerable to SQL injection if using string concatenation!
var tableName = "Orders"; // NEVER concatenate user input!
var orders = await context.Orders
.FromSqlRaw($"SELECT * FROM {tableName} WHERE Total > {{0}}", minTotal)
.ToListAsync();
// FromSqlInterpolated - SAFE with interpolation (parameterized)
var minTotal = 1000m;
var orders = await context.Orders
.FromSqlInterpolated($"SELECT * FROM Orders WHERE Total > {minTotal}")
.ToListAsync();
// Parameters are properly escaped
// Can chain with LINQ
var orders = await context.Orders
.FromSqlInterpolated($"SELECT * FROM Orders WHERE Total > {minTotal}")
.Include(o => o.Customer)
.Where(o => o.Status == OrderStatus.Pending)
.OrderBy(o => o.OrderDate)
.ToListAsync();
[SECURITY] Safe SQL Patterns
// SAFE: Using parameters
var search = userInput; // Could be malicious
var products = await context.Products
.FromSqlInterpolated($"SELECT * FROM Products WHERE Name LIKE {'%' + search + '%'}")
.ToListAsync();
// SAFE: Using SqlParameter for complex scenarios
var param = new SqlParameter("@search", $"%{userInput}%");
var products = await context.Products
.FromSqlRaw("SELECT * FROM Products WHERE Name LIKE @search", param)
.ToListAsync();
// UNSAFE: String concatenation
var products = await context.Products
.FromSqlRaw($"SELECT * FROM Products WHERE Name LIKE '%{userInput}%'") // SQL INJECTION!
.ToListAsync();
// For dynamic column/table names (can't parameterize):
// Use whitelist validation
var validColumns = new[] { "Name", "Price", "CreatedAt" };
if (!validColumns.Contains(sortColumn))
throw new ArgumentException("Invalid sort column");
var sql = $"SELECT * FROM Products ORDER BY {sortColumn}";
Stored Procedure Execution
// Simple stored procedure
var orders = await context.Orders
.FromSqlRaw("EXEC GetOrdersByCustomer @CustomerId = {0}", customerId)
.ToListAsync();
// With output parameters
var customerIdParam = new SqlParameter("@CustomerId", customerId);
var totalParam = new SqlParameter
{
ParameterName = "@Total",
SqlDbType = SqlDbType.Decimal,
Direction = ParameterDirection.Output
};
await context.Database.ExecuteSqlRawAsync(
"EXEC CalculateCustomerTotal @CustomerId, @Total OUTPUT",
customerIdParam, totalParam);
var total = (decimal)totalParam.Value;
// Stored procedure returning multiple result sets
using var command = context.Database.GetDbConnection().CreateCommand();
command.CommandText = "EXEC GetOrderWithDetails @OrderId";
command.Parameters.Add(new SqlParameter("@OrderId", orderId));
await context.Database.OpenConnectionAsync();
using var reader = await command.ExecuteReaderAsync();
// First result set - Order
var orders = new List<Order>();
while (await reader.ReadAsync())
{
orders.Add(new Order
{
Id = reader.GetInt32(0),
Total = reader.GetDecimal(1)
});
}
// Second result set - OrderItems
await reader.NextResultAsync();
var items = new List<OrderItem>();
while (await reader.ReadAsync())
{
items.Add(new OrderItem
{
Id = reader.GetInt32(0),
ProductName = reader.GetString(1)
});
}
6.2 Soft Delete Implementation
Global Query Filter Approach
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
string? DeletedBy { get; set; }
}
public class Order : ISoftDeletable
{
public int Id { get; set; }
public decimal Total { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply filter to all soft-deletable entities
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
{
var param = Expression.Parameter(entityType.ClrType, "e");
var prop = Expression.Property(param, nameof(ISoftDeletable.IsDeleted));
var condition = Expression.Equal(prop, Expression.Constant(false));
var lambda = Expression.Lambda(condition, param);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
}
Shadow Property Approach
// Don't pollute entity with soft delete properties
public class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
// No IsDeleted property!
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property<bool>("IsDeleted")
.HasDefaultValue(false);
modelBuilder.Entity<Order>()
.Property<DateTime?>("DeletedAt");
modelBuilder.Entity<Order>()
.HasQueryFilter(o => EF.Property<bool>(o, "IsDeleted") == false);
}
// Override SaveChanges to handle soft delete
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries()
.Where(e => e.State == EntityState.Deleted))
{
if (entry.Metadata.FindProperty("IsDeleted") != null)
{
entry.State = EntityState.Modified;
entry.Property("IsDeleted").CurrentValue = true;
entry.Property("DeletedAt").CurrentValue = DateTime.UtcNow;
}
}
return base.SaveChangesAsync(ct);
}
Cascade Soft Delete
public class SoftDeleteService
{
public async Task SoftDeleteOrderAsync(int orderId)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var order = await _context.Orders
.Include(o => o.OrderItems)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null) return;
// Soft delete order items
foreach (var item in order.OrderItems)
{
item.IsDeleted = true;
item.DeletedAt = DateTime.UtcNow;
}
// Soft delete order
order.IsDeleted = true;
order.DeletedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
// Restore soft-deleted entities
public async Task RestoreOrderAsync(int orderId)
{
var order = await _context.Orders
.IgnoreQueryFilters()
.Include(o => o.OrderItems)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null || !order.IsDeleted) return;
order.IsDeleted = false;
order.DeletedAt = null;
foreach (var item in order.OrderItems)
{
item.IsDeleted = false;
item.DeletedAt = null;
}
await _context.SaveChangesAsync();
}
}
[CODE] Complete Soft Delete Implementation
// Base class for soft-deletable entities
public abstract class SoftDeletableEntity
{
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}
// DbContext with full soft delete support
public class ApplicationDbContext : DbContext
{
private readonly ICurrentUserService _currentUser;
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
HandleSoftDelete();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
CancellationToken ct = default)
{
HandleSoftDelete();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
private void HandleSoftDelete()
{
var deletedEntries = ChangeTracker.Entries<SoftDeletableEntity>()
.Where(e => e.State == EntityState.Deleted);
foreach (var entry in deletedEntries)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
entry.Entity.DeletedBy = _currentUser.UserId;
}
}
// Hard delete method for when you really need to remove data
public async Task HardDeleteAsync<T>(T entity) where T : SoftDeletableEntity
{
Entry(entity).State = EntityState.Deleted;
await SaveChangesAsync();
}
// Query including deleted
public IQueryable<T> QueryIncludingDeleted<T>() where T : SoftDeletableEntity
{
return Set<T>().IgnoreQueryFilters();
}
// Query only deleted
public IQueryable<T> QueryOnlyDeleted<T>() where T : SoftDeletableEntity
{
return Set<T>()
.IgnoreQueryFilters()
.Where(e => e.IsDeleted);
}
}
Interview Questions & Answers
Q1: Whatโs the difference between Eager, Explicit, and Lazy loading?
Answer:
- Eager Loading: Related data loaded in the same query using
Include()/ThenInclude(). Best when you know youโll need the related data. - Explicit Loading: Related data loaded on demand after parent entity using
Entry().Collection().Load()orEntry().Reference().Load(). Best for conditional loading. - Lazy Loading: Related data automatically loaded when navigation property is accessed. Requires proxies or ILazyLoader. Generally avoided due to N+1 problem.
Q2: What is the Cartesian explosion problem and how do you solve it?
Answer: When using multiple Include() for collection navigations, EF generates a single query with JOINs that produces a Cartesian product. For 100 orders with 10 items and 3 payments each, you get 100 ร 10 ร 3 = 3,000 rows instead of 1,400.
Solutions:
- Use
AsSplitQuery()to generate separate queries - Use projection with
Select()to only fetch needed data - Load collections separately with explicit loading
Q3: How do you handle optimistic concurrency in EF Core?
Answer: Use row versioning or concurrency tokens:
- Add
[Timestamp]attribute or configureIsRowVersion()on abyte[]property - EF includes the original value in the WHERE clause on updates
- If no rows are updated, EF throws
DbUpdateConcurrencyException - Handle the exception by refreshing data or implementing merge strategies
Q4: Explain the difference between TPH, TPT, and TPC inheritance strategies.
Answer:
- TPH (Table-per-Hierarchy): Single table with discriminator column. Best performance for polymorphic queries but has nullable columns for type-specific properties.
- TPT (Table-per-Type): Separate table for each type with foreign keys. Clean schema but requires JOINs for queries.
- TPC (Table-per-Concrete-Type): Separate table for each concrete type with all columns. Best for querying single types but complex for polymorphic queries.
Q5: How would you implement soft delete with cascade?
Answer:
- Add
IsDeleted,DeletedAt, andDeletedByproperties (or shadow properties) - Configure global query filter to exclude deleted entities
- Override
SaveChangesAsync()to convertDeleteoperations to updates - For cascade, load related entities and soft delete them in a transaction
- Use
IgnoreQueryFilters()for admin operations that need to see deleted data
Summary
This guide covered essential EF Core concepts for Principal Engineers:
- Loading Strategies: Eager vs Explicit vs Lazy loading with benchmarks and the Cartesian explosion problem
- Query Optimization: Split queries, compiled queries, LINQ translation, and global query filters
- Advanced Modeling: Shadow properties, owned types, JSON columns, and inheritance strategies
- Concurrency: Optimistic concurrency with row versioning and conflict resolution
- Production Patterns: Migrations, connection pooling, read replicas, and high-traffic optimization
- Raw SQL: Safe SQL execution, stored procedures, and soft delete implementation
Each section includes internals, benchmarks, production patterns, and debugging tips to help you build efficient data access layers.