Back to blog
Backend Systemsintermediate

EF Core DbContext Design: When to Split and When to Stop

Most EF Core codebases split their DbContext too early and for the wrong reasons. This guide covers what you actually lose when you split prematurely, how large models perform, and the exact patterns for every scenario where splitting is genuinely justified.

LearnixoApril 19, 202615 min read
.NETC#Entity Framework CoreEF CoreArchitectureDatabaseASP.NET Core
Share:𝕏

The most common piece of EF Core advice on the internet is wrong.

"Split your DbContext when it gets large." Developers do it at 20 tables. Some do it at 10. They call it clean architecture.

What they've actually done is trade one problem (a large context) for three harder ones: broken joins, distributed transactions, and migration chaos.

This guide explains what actually happens when you split — and the exact, narrow set of situations where splitting is the right call.


What a DbContext Actually Is

Before splitting anything, it's worth understanding what you're splitting.

┌────────────────────────────────────────────────────────────────────┐
│                         DbContext                                   │
│                                                                     │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────────────┐   │
│  │ Change       │   │ Identity     │   │  Model / Metadata    │   │
│  │ Tracker      │   │ Map          │   │  (compiled at boot)  │   │
│  └──────────────┘   └──────────────┘   └──────────────────────┘   │
│                                                                     │
│  DbSet       DbSet     DbSet             │
│  DbSet     DbSet     DbSet
│ │ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ Single Database Connection (per request) │ │ │ └──────────────────────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────────┘

Key properties:

  • One connection per DbContext instance
  • One change tracker — it knows about all modified entities
  • One transaction scopeSaveChangesAsync() wraps everything in a single DB transaction
  • One compiled model — built once at startup, cached for the lifetime of the app

When you split into two contexts, each of these becomes independent. That's not always wrong — but it has real consequences.


What You Lose When You Split Prematurely

Loss 1: Cross-Entity Joins in LINQ

With one context, EF Core translates this entire expression to SQL:

C#
// ✅ One context — EF translates this to a single SQL JOIN
var result = await _db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines)
        .ThenInclude(l => l.Product)
    .Where(o => o.Customer.Region == "EU" && o.Lines.Any(l => l.Product.IsActive))
    .ToListAsync(ct);

Generated SQL:

SQL
SELECT o.*, c.*, l.*, p.*
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_lines l ON l.order_id = o.id
JOIN products p ON l.product_id = p.id
WHERE c.region = 'EU'
  AND EXISTS (
    SELECT 1 FROM order_lines il
    JOIN products ip ON il.product_id = ip.id
    WHERE il.order_id = o.id AND ip.is_active = 1
  )

Split Orders and Products into separate contexts and the same query becomes:

C#
// ❌ Two contexts — now you're doing this in application memory
var activeProductIds = await _productDb.Products
    .Where(p => p.IsActive)
    .Select(p => p.Id)
    .ToListAsync(ct);  // load all active product IDs into memory

var orders = await _orderDb.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines)
    .Where(o => o.Customer.Region == "EU")
    .ToListAsync(ct);  // load all EU orders into memory

// filter in C# — no index, no pushdown, O(n*m) in application memory
var result = orders
    .Where(o => o.Lines.Any(l => activeProductIds.Contains(l.ProductId)))
    .ToList();

The difference:

One context:
  DB does: JOIN + WHERE + index scan — returns exactly what you need

Two contexts:
  Round trip 1: SELECT id FROM products WHERE is_active = 1  → 50,000 rows into memory
  Round trip 2: SELECT * FROM orders JOIN customers ... WHERE region = 'EU' → 200,000 rows
  Application: O(n*m) Contains check in C# heap
  Result: same data, 100x more memory, 2 network round trips, no index benefit

Loss 2: ACID Transactions

With one context, any group of changes is one database transaction:

C#
// ✅ One context — atomic, ACID guaranteed
public async Task PlaceOrderAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
    var order = Order.Create(cmd.CustomerId, cmd.Items);
    var payment = Payment.Create(order.Id, cmd.CardToken, order.Total);

    foreach (var item in cmd.Items)
    {
        var product = await _db.Products.FindAsync(item.ProductId, ct);
        product.ReserveStock(item.Quantity); // mutates entity in change tracker
    }

    _db.Orders.Add(order);
    _db.Payments.Add(payment);

    await _db.SaveChangesAsync(ct);
    // All three changes committed in one transaction.
    // If the payment INSERT fails, the order INSERT and stock UPDATE roll back.
}

Split contexts, and you now need to coordinate two separate connections:

C#
// ❌ Two contexts — how do you make this atomic?
public async Task PlaceOrderAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
    var order = Order.Create(cmd.CustomerId, cmd.Items);
    _orderDb.Orders.Add(order);
    await _orderDb.SaveChangesAsync(ct); // commit 1 — order exists in DB now

    var payment = Payment.Create(order.Id, cmd.CardToken, order.Total);
    _paymentDb.Payments.Add(payment);
    await _paymentDb.SaveChangesAsync(ct); // commit 2 — what if this throws?
    // The order was written but the payment was not.
    // Your data is now inconsistent.
    // You need compensating transactions or a saga to recover.
}

The fix — TransactionScope or System.Transactions — only works when both contexts share the same database connection (same connection string), and even then it escalates to a distributed transaction under some providers. It's complex, error-prone, and entirely avoidable.

Loss 3: Migration Management

With one context, you run:

Bash
dotnet ef migrations add AddPaymentFields
dotnet ef database update

With two contexts:

Bash
# Two contexts = two migration histories = two sets of files to coordinate

dotnet ef migrations add AddPaymentFields --context OrderContext
dotnet ef migrations add AddPaymentFields --context PaymentContext

# Deployment order matters now
dotnet ef database update --context OrderContext
dotnet ef database update --context PaymentContext

# Foreign keys across contexts? You have to manage them manually.
# One context's migration adds a column the other references?
# Now you need to synchronize migration order across both pipelines.
One context migration history:
  20260401_InitialCreate
  20260408_AddOrderStatus
  20260415_AddPaymentFields
  ↑ one file, one history, one command to deploy

Two context migration histories:
  Orders/
    20260401_InitialCreate
    20260408_AddOrderStatus
  Payments/
    20260401_InitialCreate
    20260415_AddPaymentFields  ← references orders.id — which runs first?

  → Need to manually ensure OrderContext migrates before PaymentContext
  → Need to handle failure recovery across two migration runs
  → Need two separate DbContextFactory registrations
  → PR reviews now touch two separate Migrations/ folders

How EF Core Handles Large Models

The concern behind most premature splits is performance: "won't a 50-entity DbContext be slow?"

Understanding what actually happens at startup:

Application start:
  EF Core reads all entity configurations
  Builds the internal model (CLR types → relational schema mapping)
  Compiles the model → IModel (cached for app lifetime)
  
  Time cost: O(entities + relationships) — once, at boot
  Memory cost: the compiled model lives in the DI singleton
  Per-request cost: zero — compiled model is reused
C#
// Compiled model — pre-generate at build time for maximum startup performance
// Run: dotnet ef dbcontext optimize
// This generates a pre-compiled model class and eliminates runtime compilation

// AppDbContextModel.cs (generated)
public partial class AppDbContextModel : RuntimeModel
{
    // pre-compiled entity types, relationships, indexes
    // startup time drops from ~800ms to ~50ms for a 60-entity model
}

// Program.cs
builder.Services.AddDbContext<AppDbContext>(opt =>
{
    opt.UseSqlServer(connectionString);
    opt.UseModel(AppDbContextModel.Instance); // use pre-compiled model
});

Real benchmark data from the EF Core team (60 entity types):

Without compiled model:  first query ~900ms (model build + compile)
With compiled model:     first query ~45ms  (pre-compiled, no build cost)
Per-query overhead:      ~0.05ms regardless of context size

The model size is not your bottleneck. Your queries are.


The Decision Matrix

                     Do these entity groups share
                     joins or transactions regularly?
                              │
                   ┌──────────┴──────────┐
                  Yes                    No
                   │                     │
                   ▼                     ▼
          Keep in same context    Do they live in
                                  separate databases?
                                          │
                               ┌──────────┴──────────┐
                              Yes                     No
                               │                      │
                               ▼                      ▼
                      Must split           Do they belong to
                      (no choice)          separate bounded contexts
                                           in a modular monolith?
                                                    │
                                         ┌──────────┴──────────┐
                                        Yes                     No
                                         │                      │
                                         ▼                      ▼
                                  Split per module         Keep together.
                                  (one context              Entity count
                                  per module)               is not a reason.

When Splitting Is Genuinely Right

Reason 1: Separate Databases

If two groups of entities are in different databases, they must have separate contexts. There is no choice here — one DbContext maps to one database.

C#
// Orders in SQL Server, Analytics in Postgres — separate physical databases
services.AddDbContext<OrderContext>(opt =>
    opt.UseSqlServer(config["ConnectionStrings:Orders"]));

services.AddDbContext<AnalyticsContext>(opt =>
    opt.UseNpgsql(config["ConnectionStrings:Analytics"]));
┌──────────────────────┐     ┌──────────────────────────┐
│   OrderContext        │     │   AnalyticsContext        │
│                       │     │                           │
│   SQL Server          │     │   PostgreSQL              │
│   (transactional)     │     │   (read-heavy analytics)  │
└──────────────────────┘     └──────────────────────────┘
         ↑                               ↑
    different hosts,               different hosts,
    different providers            different providers
    → must be separate contexts    → must be separate contexts

Reason 2: Bounded Contexts in a Modular Monolith

Each module owns its data. The context boundary enforces that no module reaches into another module's tables directly:

src/
├── YourApp.Orders/
│   └── Infrastructure/
│       └── OrdersDbContext.cs    ← DbSet, DbSet
│                                    HasDefaultSchema("orders")
│
├── YourApp.Payments/
│   └── Infrastructure/
│       └── PaymentsDbContext.cs  ← DbSet, DbSet
│                                    HasDefaultSchema("payments")
│
└── YourApp.Catalog/
    └── Infrastructure/
        └── CatalogDbContext.cs   ← DbSet, DbSet
                                     HasDefaultSchema("catalog")
C#
// ✅ Each module's context only knows about its own schema
public class OrdersDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; }
    public DbSet<OrderLine> Lines { get; set; }

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.HasDefaultSchema("orders");
        mb.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
    }
}

public class PaymentsDbContext : DbContext
{
    public DbSet<Payment> Payments { get; set; }
    public DbSet<Refund> Refunds { get; set; }

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.HasDefaultSchema("payments");
        mb.ApplyConfigurationsFromAssembly(typeof(PaymentsDbContext).Assembly);
    }
}

Critical rule: no DbSet<Order> in PaymentsDbContext. If Payments needs to reference an Order, it stores the OrderId (a primitive) and crosses the boundary via an event or service call — not via a navigation property.

C#
// ❌ PaymentsDbContext should not know about Orders
public class PaymentsDbContext : DbContext
{
    public DbSet<Payment> Payments { get; set; }
    public DbSet<Order> Orders { get; set; }    // ← violation — Orders belongs to OrdersDbContext
}

// ✅ Reference by ID only
public class Payment
{
    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }   // ← primitive reference, not a navigation property
    public decimal Amount { get; private set; }
}

Reason 3: Read Replica Pattern

Write operations go to the primary, read operations go to a read replica:

┌──────────────────────────┐     ┌──────────────────────────┐
│   WriteDbContext          │     │   ReadDbContext            │
│                           │     │                           │
│   Primary DB (read/write) │     │   Read Replica (read only)│
│   - Orders                │     │   - Orders (replicated)   │
│   - Payments              │     │   - Payments (replicated) │
│   - Customers             │     │   - Customers (replicated)│
│                           │     │                           │
│   Used for: commands,     │     │   Used for: queries,      │
│   SaveChangesAsync()      │     │   reporting, dashboards   │
└──────────────────────────┘     └──────────────────────────┘
         ↑                                   ↑
   CQRS write side                    CQRS read side
C#
// WriteDbContext — full tracking, used in command handlers
public class WriteDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; }
    public DbSet<Payment> Payments { get; set; }
}

// ReadDbContext — no tracking, optimized for queries
public class ReadDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; }
    public DbSet<Payment> Payments { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder opt)
    {
        opt.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
    }
}

// Registration
services.AddDbContext<WriteDbContext>(opt =>
    opt.UseSqlServer(config["ConnectionStrings:Primary"]));

services.AddDbContext<ReadDbContext>(opt =>
    opt.UseSqlServer(config["ConnectionStrings:ReadReplica"]));

Note: if you don't have a physical read replica, you don't need a second context for this. Use .AsNoTracking() on individual queries instead:

C#
// No second context needed — just AsNoTracking() for read-only queries
var orders = await _db.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == customerId)
    .ToListAsync(ct);

Reason 4: Multi-Tenant Isolation

When tenants share a database but need strict data isolation enforced at the query level:

C#
// Global query filter — every query automatically scopes to the current tenant
public class AppDbContext : DbContext
{
    private readonly ICurrentTenant _tenant;

    public AppDbContext(DbContextOptions<AppDbContext> options, ICurrentTenant tenant)
        : base(options)
    {
        _tenant = tenant;
    }

    public DbSet<Order> Orders { get; set; }
    public DbSet<Customer> Customers { get; set; }

    protected override void OnModelCreating(ModelBuilder mb)
    {
        // Applied to every query automatically — no manual WHERE tenant_id = ? needed
        mb.Entity<Order>().HasQueryFilter(o => o.TenantId == _tenant.Id);
        mb.Entity<Customer>().HasQueryFilter(c => c.TenantId == _tenant.Id);
    }
}

This is not a reason to split contexts — it's a reason to use global query filters within one context.

The rare case where multi-tenancy does justify splitting: each tenant has their own physical database (database-per-tenant isolation model). Then each tenant's requests use a context configured with their specific connection string, resolved at runtime.

C#
// Database-per-tenant: context factory resolves connection string per tenant
public class TenantDbContextFactory
{
    private readonly ITenantConnectionResolver _resolver;
    private readonly IDbContextFactory<AppDbContext> _factory;

    public AppDbContext CreateForCurrentTenant(string tenantId)
    {
        var connectionString = _resolver.Resolve(tenantId);
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(connectionString)
            .Options;
        return new AppDbContext(options);
    }
}

Common Mistakes in Context Design

Mistake 1: Splitting by Feature Instead of by Boundary

C#
// ❌ "Separation of concerns" — but these are not separate bounded contexts
public class OrderReadContext : DbContext { public DbSet<Order> Orders; }
public class OrderWriteContext : DbContext { public DbSet<Order> Orders; }
// Same table, two contexts. Now migrations for orders.orders live in two places.

// ✅ Use AsNoTracking() instead
var order = await _db.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, ct);

Mistake 2: Putting Cross-Module Navigation Properties in the Context

C#
// ❌ PaymentsDbContext references Orders — now they're coupled at the DB level
public class Payment
{
    public Guid OrderId { get; set; }
    public Order Order { get; set; }  // ← navigation property into a different module's table
}

// ✅ ID reference only — cross-module reads go through a service or query
public class Payment
{
    public Guid OrderId { get; set; }
    // no Order navigation property
}

Mistake 3: Creating Multiple Contexts for DI "Organization"

C#
// ❌ Not a bounded context — just arbitrary grouping for DI cleanliness
services.AddDbContext<UserDbContext>(...);     // Users, Roles, Permissions
services.AddDbContext<OrderDbContext>(...);    // Orders, Lines, Products
services.AddDbContext<BillingDbContext>(...);  // Invoices, Payments, Refunds

// Now a query that needs Order + Invoice + User touches three contexts.
// Three round trips. Three change trackers. Three transaction scopes.

// ✅ One context unless you have one of the four valid reasons
services.AddDbContext<AppDbContext>(...);

Mistake 4: Splitting Then Re-Joining with In-Memory Joins

C#
// ❌ Split contexts, now "fixing" cross-context queries with in-memory joins
var userIds = await _userDb.Users
    .Where(u => u.Plan == "Pro")
    .Select(u => u.Id)
    .ToListAsync(ct);

var orders = await _orderDb.Orders
    .Where(o => userIds.Contains(o.UserId))  // ← IN clause with potentially 100k IDs
    .ToListAsync(ct);

// EF Core generates: WHERE user_id IN (id1, id2, id3, ... id99999)
// SQL Server limit: 2100 parameters
// This throws SqlException for large sets

The Single Large Context: What It Looks Like Well-Organized

A 50-entity context doesn't have to be a mess. Organize with IEntityTypeConfiguration<T>:

AppDbContext.cs                    ← DbSets and OnModelCreating only
Configurations/
  OrderConfiguration.cs           ← table/column/index config for Order
  OrderLineConfiguration.cs
  CustomerConfiguration.cs
  ProductConfiguration.cs
  PaymentConfiguration.cs
  ...
C#
// AppDbContext.cs — clean, no configuration noise
public class AppDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; }
    public DbSet<OrderLine> OrderLines { get; set; }
    public DbSet<Customer> Customers { get; set; }
    public DbSet<Product> Products { get; set; }
    public DbSet<Payment> Payments { get; set; }
    // ... 45 more sets

    protected override void OnModelCreating(ModelBuilder mb)
    {
        // One line — discovers and applies all IEntityTypeConfiguration<T> in this assembly
        mb.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
C#
// Configurations/OrderConfiguration.cs — all Order-specific config here
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> b)
    {
        b.ToTable("orders");
        b.HasKey(o => o.Id);
        b.Property(o => o.Status).HasConversion<string>().HasMaxLength(50);
        b.Property(o => o.Total).HasPrecision(18, 2);

        b.HasMany(o => o.Lines)
         .WithOne(l => l.Order)
         .HasForeignKey(l => l.OrderId)
         .OnDelete(DeleteBehavior.Cascade);

        b.HasIndex(o => o.CustomerId);
        b.HasIndex(o => new { o.Status, o.CreatedAt });
    }
}

This structure scales to 100+ entities without the context class itself becoming unreadable. The DbContext is a registry; the IEntityTypeConfiguration<T> classes carry the detail.


Performance: What Actually Matters

Common belief:         Large context = slow queries
Reality:               Query performance depends on indexes and query shape, not context size

Common belief:         Split contexts = faster startup
Reality:               dotnet ef dbcontext optimize eliminates startup cost entirely

Common belief:         Change tracker is slow for large models
Reality:               Change tracker cost scales with tracked entities, not registered DbSets
                       Track fewer entities by using AsNoTracking() on read-only queries

Common belief:         Multiple contexts = better for concurrency
Reality:               Each DbContext instance is already scoped per-request (AddDbContext default)
                       You're already getting per-request isolation

The change tracker tip in practice:

C#
// ❌ Tracking entities you only read — wasted change tracker overhead
var products = await _db.Products
    .Include(p => p.Category)
    .Where(p => p.IsActive)
    .ToListAsync(ct);
// EF tracks all returned entities — costs memory, costs time on SaveChanges

// ✅ AsNoTracking for reads — zero change tracker cost
var products = await _db.Products
    .AsNoTracking()
    .Include(p => p.Category)
    .Where(p => p.IsActive)
    .ToListAsync(ct);

For an entire context that is read-only (read replica, analytics):

C#
services.AddDbContext<ReadDbContext>(opt =>
{
    opt.UseSqlServer(connectionString);
    opt.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); // global default
});

Summary

| Scenario | Split? | Pattern | |---|---|---| | > 20 entities in one context | No | Use ApplyConfigurationsFromAssembly | | "Separation of concerns" | No | Use folders + IEntityTypeConfiguration<T> | | Read-heavy queries | No | Use AsNoTracking() | | Different databases | Yes (required) | One context per database | | Bounded contexts (modular monolith) | Yes | One context per module, separate schema | | Read replica / CQRS | Yes | WriteDbContext + ReadDbContext | | Multi-tenant (separate DBs) | Yes | Context factory resolves connection per tenant | | Multi-tenant (shared DB) | No | Global query filters on tenant ID |

The rule is simple: if two entity groups join or share transactions, they belong together. The size of the context is not a reason to split. The location of the data and the ownership of the boundary is.

A 50-entity AppDbContext with well-organized IEntityTypeConfiguration<T> classes and a pre-compiled model is faster, simpler, and less error-prone than five 10-entity contexts that need to coordinate.

Split on architecture. Not on entity count.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.