As .NET developers, we often reach for Entity Framework Core (EF Core) as our default data access layer. Typically, we spin up a single AppDbContext, register it in our Dependency Injection (DI) container, and inject it everywhere.
For a Proof of Concept (PoC) or a small app, this is fine. But as your application scales—whether it’s a high-traffic Blazor Server app, a complex MAUI Hybrid solution, or a REST API—using a single context for everything can become a bottleneck.
There is a simple architectural pattern that yields massive benefits in performance, security, and scalability: Splitting your Database Context into two—a standard Write Context and a specialized Read-Only Context.
Here is why you should consider this approach and how to implement it.
1. Performance: The “No-Tracking” Default
By default, EF Core uses Change Tracking. When you query data, EF constructs a snapshot of the entities. When you call SaveChanges(), it compares the current state against the snapshot to generate SQL updates.
This is expensive. It consumes memory and CPU cycles.
When you are simply populating a UI grid in Blazor or returning a JSON list in an API, you don’t need change tracking. You aren’t going to update those specific objects.
While you can add .AsNoTracking() to every LINQ query, it is brittle. Developers forget.
With a dedicated ReadOnlyDbContext, you configure this behavior globally:
public class ReadOnlyAppDbContext : AppDbContext
{
public ReadOnlyAppDbContext(DbContextOptions<ReadOnlyAppDbContext> options)
: base(options)
{
// Global setting for this context
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
}
Now, every query runs lean by default. No snapshots, no tracking overhead.
2. Scalability: Utilizing Read Replicas
This is the strongest argument for enterprise applications.
Most applications follow the 80/20 rule: 80% of database operations are reads, and 20% are writes.
Modern databases (PostgreSQL, SQL Server, Aurora) support Read Replicas. You have one Primary node for writing and several Read-Only nodes for querying.
If you only have one AppDbContext, you are forced to point it at the Primary node, creating a bottleneck.
By splitting your contexts, you can configure different connection strings in your appsettings.json:
- WriteContext Points to Primary Node (Port 5432)
- ReadOnlyContext Points to Replica Node (Port 5433)
This instantly offloads 80% of your traffic away from your transactional database, allowing your application to scale horizontally at the database layer with almost no code changes in your business logic.
3. Security: The Principle of Least Privilege
Security is best implemented in layers (Defense in Depth).
If a hacker manages to perform a SQL Injection attack (rare with EF, but possible with raw SQL interpolation) or compromises a connection string in a specific microservice, you want to limit the blast radius.
The database user credentials used for your ReadOnlyDbContext should have SELECT permissions only.
If someone tries to execute a cheeky DROP TABLE Users or UPDATE Admin SET Password via the Read-Only context, the database engine itself will reject the command. You cannot achieve this protection with a single context that requires write permissions.
4. Code Intent and Architecture (CQRS-Lite)
Command Query Responsibility Segregation (CQRS) is a powerful pattern, but implementing it fully with MediatR and separate Read/Write models can be overkill for many projects.
Using two contexts offers a “CQRS-Lite” experience. It forces the developer to think about intent:
“Am I changing state, or am I just looking at data?”
If you inject IReadOnlyAppDbContext, the intent is clear: This method does not modify state. It makes code reviews easier and prevents accidental side effects where a “Get” method secretly modifies data.
Implementation Strategy
You don’t need to duplicate your DbSet properties. Use inheritance.
Step 1: The Base Context (Write)
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions options) : base(options) { }
public DbSet<User> Users { get; set; }
public DbSet<Order> Orders { get; set; } // etc...
}
Step 2: The Read-Only Context
public class ReadOnlyAppDbContext : AppDbContext
{
public ReadOnlyAppDbContext(DbContextOptions<ReadOnlyAppDbContext> options)
: base(options)
{
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
// Override SaveChanges to throw an exception as a fail-safe
public override int SaveChanges()
{
throw new InvalidOperationException("This context is read-only.");
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
throw new InvalidOperationException("This context is read-only.");
}
}
Step 3: Registration (Program.cs)
// Register the Write Context (Primary DB)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PrimaryConnection")));
// Register the Read-Only Context (Replica DB)
builder.Services.AddDbContext<ReadOnlyAppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("ReplicaConnection")));
Note for Blazor Server / Hybrid Developers
If you are working with Blazor Server or .NET MAUI Blazor Hybrid, remember that DbContext is not thread-safe and Scoped lifetimes can be tricky with SignalR circuits.
You should use IDbContextFactory instead. You can register factories for both:
builder.Services.AddDbContextFactory<AppDbContext>(...);
builder.Services.AddDbContextFactory<ReadOnlyAppDbContext>(...);
Conclusion
Splitting your DbContext is a high-reward, low-effort architectural decision. It prepares your application for scaling (Read Replicas), optimizes performance by default (No Tracking), and hardens your security posture (Least Privilege).
Next time you initialize a project in Visual Studio or Rider, consider creating that ReadOnlyUser right from the start. Your future infrastructure will thank you.