EF Core Under the Hood: 11 Design Patterns You Use Every Day
Working with EF Core daily, I started recognising the same patterns everywhere - in how DbContext tracks changes, in how ModelBuilder configures entities, in how lazy loading proxies work. EF Core is not just a data access library. It is a textbook of enterprise patterns in working code.

Which design patterns does EF Core implement?
| Pattern | Where in EF Core |
|---|---|
| Unit of Work | DbContext + SaveChanges() |
| Repository | DbSet<T> |
| Identity Map | Change tracker (one instance per key per context) |
| Strategy | Execution strategies, database providers |
| Builder | ModelBuilder, EntityTypeBuilder, DbContextOptionsBuilder |
| Factory | IDbContextFactory<T> |
| Object Pool | AddDbContextPool |
| Decorator / Interceptor | IDbCommandInterceptor, ISaveChangesInterceptor |
| Template Method | OnModelCreating, OnConfiguring |
| Query Object | IQueryable<T> + LINQ |
| Value Object | Owned entities, value conversions |
Unit of Work - why DbContext is the heart of EF Core
The Unit of Work pattern groups a set of operations into a single transaction that either commits all at once or rolls back entirely. DbContext is the Unit of Work in EF Core. It tracks every change you make - additions, modifications, deletions - and when you call SaveChanges(), it wraps everything in a single database transaction.
var context = new AppDbContext();
context.Orders.Add(new Order { Total = 150 });
context.Customers.Update(customer);
context.Products.Remove(obsoleteProduct);
// All three operations commit in one transaction, or none do
await context.SaveChangesAsync();This is why DbContext is registered as Scoped in ASP.NET Core by default - one context per HTTP request, one unit of work per request. If you register it as Singleton, you share the same unit across all requests, which causes thread-safety issues and stale data.
DbContext instance is part of the same unit of work. Changes to any tracked entity are automatically included in the next SaveChanges() call - even if you did not intend to save them.Repository - is DbSet<T> already a repository?
The Repository pattern abstracts the data access layer and provides a collection-like interface for querying and persisting domain objects. DbSet<T> already does exactly this.
// DbSet<T> is a repository - you query, add, update, and remove through it
var pending = await context.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
context.Orders.Add(new Order { ... });
context.Orders.Remove(order);This is the source of the long-running debate: should you add another Repository layer on top of EF Core?
- Arguments for: hides EF Core from the domain layer, makes unit testing easier (mock the repository, not DbContext), enforces a consistent query API. In DDD specifically, the Repository interface lives in the domain layer while the EF Core implementation lives in infrastructure - this keeps the domain free of any infrastructure dependency. DDD also restricts repositories to Aggregate Roots only, which prevents you from accidentally querying child entities directly and bypassing aggregate boundaries. Repository method names can express domain language (
GetPendingOrdersForCustomer) instead of leaking LINQ expressions into the domain. - Arguments against: you are wrapping a repository with another repository. You lose
IQueryablecomposability, you have to replicate every method you need, and mocking DbContext is already straightforward withUseInMemoryDatabase.
My take: for most applications, wrapping EF Core in a Repository adds boilerplate without much gain. The cases where it makes sense are when you need to swap out the data access layer entirely - for example, switching from EF Core to Dapper for a specific aggregate.
Identity Map - how the change tracker prevents duplicate instances
The Identity Map pattern ensures that within a single unit of work, each entity is loaded only once and always returns the same object instance for the same key. EF Core's change tracker implements this.
var order1 = await context.Orders.FindAsync(1); var order2 = await context.Orders.FindAsync(1); // no second DB query Console.WriteLine(ReferenceEquals(order1, order2)); // True - same instance
FindAsync checks the change tracker first before going to the database. FirstOrDefaultAsync does not - it always queries the database, then merges the result with whatever is already tracked.
FirstOrDefaultAsync, expecting fresh data from the database. You get the modified in-memory version instead, because the change tracker already holds that entity. This is the Identity Map at work.Strategy - providers and execution strategies
The Strategy pattern defines a family of interchangeable algorithms. EF Core uses it in two places.
Database providers as strategies
The database provider (SQL Server, PostgreSQL, SQLite, In-Memory) is a strategy. You swap it by changing a single line in configuration, without touching any other code.
options.UseSqlServer(connectionString); // production
options.UseNpgsql(connectionString); // PostgreSQL
options.UseSqlite(connectionString); // integration tests
options.UseInMemoryDatabase("test"); // unit testsExecution strategies
An execution strategy defines how EF Core handles transient failures - network blips, connection timeouts, deadlocks. EnableRetryOnFailure sets a built-in retry strategy; you can also implement IExecutionStrategy yourself.
options.UseSqlServer(connectionString, sql =>
sql.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null));Builder - fluent configuration in EF Core
The Builder pattern constructs complex objects step by step through a fluent API. EF Core has three builders, each for a different scope.
// DbContextOptionsBuilder - configures the context itself
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connectionString)
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
.Options;
// ModelBuilder - configures the entire model in OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
modelBuilder.HasDefaultSchema("app");
}
// EntityTypeBuilder - configures a single entity
modelBuilder.Entity<Order>(entity =>
{
entity.HasKey(o => o.Id);
entity.Property(o => o.Total).HasPrecision(18, 2).IsRequired();
entity.HasIndex(o => o.CustomerId);
entity.HasMany(o => o.Items).WithOne(i => i.Order).OnDelete(DeleteBehavior.Cascade);
});Factory and Object Pool - controlling context lifetime
Factory - IDbContextFactory<T>
The standard AddDbContext gives you a scoped context tied to the HTTP request. In Blazor Server, or in background services where you need multiple short-lived contexts, that model does not work. IDbContextFactory<T> gives you a factory you can call on demand.
// Registration
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// In a Blazor component or background service
@inject IDbContextFactory<AppDbContext> DbFactory
async Task LoadData()
{
using var context = DbFactory.CreateDbContext(); // explicit lifetime
Orders = await context.Orders.ToListAsync();
}Object Pool - AddDbContextPool
Creating a DbContext has overhead - setting up the change tracker, loading the model, opening the connection. AddDbContextPool maintains a pool of pre-created, reset contexts and hands them out per request instead of creating new ones each time.
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseSqlServer(connectionString),
poolSize: 128); // default is 1024DbContext stores anything beyond EF Core's own state, use AddDbContext instead.Decorator and Template Method - hooks into EF Core's pipeline
Interceptors as Decorators
IDbCommandInterceptor and ISaveChangesInterceptor wrap EF Core's internal operations and let you add behaviour before or after without modifying the source. Classic Decorator pattern.
public class QueryLoggingInterceptor : DbCommandInterceptor
{
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
Console.WriteLine($"[{eventData.Duration.TotalMs}ms] {command.CommandText}");
return base.ReaderExecuted(command, eventData, result);
}
}
// Registration
options.AddInterceptors(new QueryLoggingInterceptor());Template Method - OnModelCreating and OnConfiguring
The Template Method pattern defines the skeleton of an algorithm in a base class and lets subclasses fill in specific steps. DbContext calls OnModelCreating and OnConfiguring at defined points during initialisation. You override them to inject your configuration - the framework controls when they are called.
public class AppDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
optionsBuilder.UseSqlServer("fallback-connection");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder); // always call base
modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}
}Query Object and Value Object - data modeling patterns
Query Object - IQueryable as a composable query
The Query Object pattern represents a database query as an object that can be built incrementally and executed later. IQueryable<T> is exactly this - an expression tree that EF Core translates to SQL only when enumerated.
IQueryable<Order> query = context.Orders.AsQueryable();
// Build the query progressively based on runtime conditions
if (customerId.HasValue)
query = query.Where(o => o.CustomerId == customerId);
if (status.HasValue)
query = query.Where(o => o.Status == status);
if (fromDate.HasValue)
query = query.Where(o => o.CreatedAt >= fromDate);
// Single SQL query generated here - all conditions combined
var results = await query.OrderByDescending(o => o.CreatedAt).ToListAsync();This is also why the IEnumerable vs IQueryable distinction matters so much - returning IEnumerable from a repository method materialises the query immediately and loses the ability to compose further conditions in SQL.
Value Object - owned entities and value conversions
A Value Object is defined by its properties, not its identity - two addresses with the same street and city are equal. EF Core maps Value Objects with OwnsOne / OwnsMany, storing their columns in the owner's table rather than a separate table with a primary key.
public class Order
{
public int Id { get; set; }
public Address ShippingAddress { get; set; } // Value Object - no Id
}
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
}
// Configuration
modelBuilder.Entity<Order>().OwnsOne(o => o.ShippingAddress, addr =>
{
addr.Property(a => a.Street).HasColumnName("ShippingStreet");
addr.Property(a => a.City).HasColumnName("ShippingCity");
});
// Stored as ShippingStreet, ShippingCity, ShippingPostalCode columns in Orders tableFrequently asked questions
Both. DbContext is the Unit of Work - it tracks changes and commits them in one transaction via SaveChanges(). DbSet<T> is the Repository - it provides the collection-like interface for querying and persisting a specific entity type. They are separate but work together.
For most applications, no. DbSet<T> is already a repository, and wrapping it adds boilerplate without much benefit. A custom Repository layer makes sense when you need to fully hide EF Core from your domain (for example, to swap data access libraries) or when you want to enforce a strict set of allowed queries across the team.
AddDbContext creates a new DbContext instance per scope (per request). AddDbContextPool maintains a pool of pre-created instances and resets them between uses, reducing the overhead of construction. Use AddDbContextPool when your context has no custom constructor state and you need to reduce allocation pressure under high load.
Use IDbContextFactory when the standard scoped lifetime does not fit - Blazor Server components, background services, or any scenario where you need multiple short-lived contexts within the same scope. It gives you explicit control over the context lifetime via using blocks.
OwnsOne maps a Value Object - the owned type has no identity of its own and its columns live in the owner's table. HasOne maps a relationship between two independent entities that each have their own primary key and table. Use OwnsOne for things like Address or Money that only exist as part of a parent entity.