Viorel Buliga
About me
← All articles
.NETEF CoreArchitectureDesign PatternsBackend

EF Core Under the Hood: 11 Design Patterns You Use Every Day

June 18, 2026·10 min read
Quick answer: EF Core implements at least 11 design patterns. The three most fundamental are Unit of Work (DbContext), Repository (DbSet<T>), and Identity Map (change tracker). Understanding these patterns explains most of EF Core's behaviour - and most of its surprises. The rest - Builder, Strategy, Factory, Object Pool, Interceptor, Template Method, Query Object, Value Object - explain everything else.

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.

EF Core design patterns illustration

Which design patterns does EF Core implement?

PatternWhere in EF Core
Unit of WorkDbContext + SaveChanges()
RepositoryDbSet<T>
Identity MapChange tracker (one instance per key per context)
StrategyExecution strategies, database providers
BuilderModelBuilder, EntityTypeBuilder, DbContextOptionsBuilder
FactoryIDbContextFactory<T>
Object PoolAddDbContextPool
Decorator / InterceptorIDbCommandInterceptor, ISaveChangesInterceptor
Template MethodOnModelCreating, OnConfiguring
Query ObjectIQueryable<T> + LINQ
Value ObjectOwned 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.

Consequence: everything you load within the same 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 IQueryable composability, you have to replicate every method you need, and mocking DbContext is already straightforward with UseInMemoryDatabase.

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.

Common surprise: you modify an entity in memory, then query it again with 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 tests

Execution 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 1024
Constraint: pooled contexts cannot have instance-level state (fields, injected services stored in the context). The pool resets the change tracker between uses but does not call your constructor again. If your DbContext 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 table

Frequently asked questions

Is DbContext a Unit of Work or a Repository?

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.

Should I add a Repository layer on top of EF Core?

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.

What is the difference between AddDbContext and AddDbContextPool?

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.

When should I use IDbContextFactory instead of injecting DbContext directly?

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.

What is the difference between OwnsOne and HasOne in EF Core?

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.

Official resources

  • DbContext configuration and lifetime - Microsoft Learn
  • Lazy loading of related data - Microsoft Learn
  • Owned entity types - Microsoft Learn
  • Interceptors in EF Core - Microsoft Learn
  • Connection resiliency and execution strategies - Microsoft Learn
Share on LinkedIn