Appouse.EFCore.ReplicaManager
2.1.0
dotnet add package Appouse.EFCore.ReplicaManager --version 2.1.0
NuGet\Install-Package Appouse.EFCore.ReplicaManager -Version 2.1.0
<PackageReference Include="Appouse.EFCore.ReplicaManager" Version="2.1.0" />
<PackageVersion Include="Appouse.EFCore.ReplicaManager" Version="2.1.0" />
<PackageReference Include="Appouse.EFCore.ReplicaManager" />
paket add Appouse.EFCore.ReplicaManager --version 2.1.0
#r "nuget: Appouse.EFCore.ReplicaManager, 2.1.0"
#:package Appouse.EFCore.ReplicaManager@2.1.0
#addin nuget:?package=Appouse.EFCore.ReplicaManager&version=2.1.0
#tool nuget:?package=Appouse.EFCore.ReplicaManager&version=2.1.0
Appouse.EFCore.ReplicaManager
English · Türkçe
Transparent master/replica database splitting for EF Core 8+.
One master, as many replicas as you like. You say which queries go where — with an attribute, a
using block, or a default — and a replica that stops answering is skipped in favour of the next
one. Provider agnostic: SQL Server, PostgreSQL, MySQL, Oracle, SQLite and anything else with an EF
Core provider.
dotnet add package Appouse.EFCore.ReplicaManager
Adding this package moves no traffic on its own. Routing is explicit. A query with no attribute
and no scope goes to DefaultTarget and nowhere else, so you can install it into a running
application and turn routing on one endpoint at a time.
30 seconds
using Appouse.EFCore.ReplicaManager;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEfCoreMasterReplica(options =>
{
options.MasterConnectionString = builder.Configuration.GetConnectionString("Master")!;
options.ReplicaConnectionString = builder.Configuration.GetConnectionString("Replica1")!;
options.ReplicaConnectionStrings.Add(builder.Configuration.GetConnectionString("Replica2")!);
options.DefaultTarget = DbTarget.Master;
});
// You choose the provider; the package hands you the connection string.
builder.Services.AddMasterReplicaDbContext<AppDbContext>((options, connectionString) =>
options.UseSqlServer(connectionString));
builder.Services.AddControllers().AddDbTargetRouting();
var app = builder.Build();
app.MapControllers();
app.Run();
Your DbContext needs no changes at all. No base class, no second context, no repository layer.
Routing rules
Resolved in this order — the first match wins:
| # | Rule | Result |
|---|---|---|
| 1 | [UseMasterDb] / [UseReplicaDb] on the action |
that target |
| 2 | [UseMasterDb] / [UseReplicaDb] on the controller |
that target |
| 3 | SaveChanges is running |
always Master |
| 4 | A transaction is active | always Master |
| 5 | An enclosing UseTarget(...) scope |
that target |
| 6 | HTTP verb — only if you enable RouteByHttpMethod |
GET/HEAD/OPTIONS/TRACE → Replica, else Master |
| 7 | Nothing above applies | options.DefaultTarget |
Rule 6 is off by default. A GET is not reliably a read — plenty of them stamp a
LastSeenAt, fill a cache or write an audit row — so moving every one of them onto a replica is a
behaviour change you should opt into deliberately:
options.RouteByHttpMethod = true;
Once enabled, unknown verbs fall back to the master: guessing wrong towards a replica breaks writes, guessing wrong towards the master only costs a little capacity.
Saying where a query goes
By attribute, per action or per controller — an attribute on the action always wins:
[HttpGet("{id:int}")]
[UseMasterDb] // a GET that must not read stale data
public Task<Order?> Get(int id) => db.Orders.FindAsync(id).AsTask();
[HttpPost("search")]
[UseReplicaDb] // a POST that only reads
public Task<List<Order>> Search(SearchRequest r) { /* ... */ }
By scope, anywhere — including where there is no HttpContext at all:
using (dbTarget.UseReplicaDb())
{
var ids = await db.Orders.Where(o => !o.Settled).Select(o => o.Id).ToListAsync();
}
By Minimal API helper, per endpoint or per group:
app.MapGet("/orders/{id:int}", handler).UseMasterDb();
var reports = app.MapGroup("/reports").UseReplicaDb();
A Minimal API endpoint is routed only if you use one of those helpers or add
app.UseDbTargetRouting()to the pipeline. Without either it falls back toDefaultTarget.
Many replicas, and what happens when one dies
The topology is one master and any number of replicas. Replica reads are spread round-robin. When a replica refuses a connection, the package does not surface the error: it opens the connection against the next replica instead, and only gives up once every replica has been tried.
options.MasterConnectionString = "..."; // exactly one
options.ReplicaConnectionString = "...replica-1...";
options.ReplicaConnectionStrings.Add("...replica-2...");
options.ReplicaConnectionStrings.Add("...replica-3...");
IReplicaSelectorpicks a starting replica — round-robin by default.- Each replica is dialled in turn until one accepts the connection.
- A replica that refused is stood down for
ReplicaFailureCooldown(30 seconds by default), so one dead node does not cost every request a connection timeout. It is moved to the back of the queue, never banned — if all the others fail too, it is still tried. - If no replica answers, the read is served by the master, unless
AllowReplicaFallbackToMasterisfalse— then aReplicaUnavailableExceptionis thrown, carrying every provider failure in anAggregateException.
Writes are never failed over, because there is only one master to write to.
Proving a replica is really there
Opening a connection is not the same as reaching a server. ADO.NET pools connections, so when a
replica dies while the pool still holds warm handles to it, OpenAsync hands one back without a
network round trip and reports success. The socket is dead, but nothing discovers that until the
first command runs — far too late to pick a different replica for that attempt.
A one-row query forces the round trip while the interceptor is still choosing a replica:
options.ValidateReplicaConnections = true; // off by default
With it on, a dead node is skipped inside the failover loop and the application never sees an error. With it off, the first request after an outage surfaces one connection error, the replica is taken out of rotation, and everything after that lands on the survivor. Both behaviours are covered by the live tests, on every provider.
The cost is one extra round trip per replica connection, and EF Core opens a connection per
operation — a real price on a busy read path, which is why it is opt-in. ReplicaValidationQuery
defaults to SELECT 1, or SELECT 1 FROM DUAL on Oracle, and
ReplicaValidationTimeout (5 seconds) bounds a replica that accepts connections but answers nothing.
Two more things help when validation is off:
- A command failing on a connection routed to a replica marks that replica down immediately, so the next request avoids it instead of drawing another dead handle from the same pool.
- Enable EF Core's retrying execution strategy. The retry opens a fresh connection, which routes away from the node already marked down, so the outage stays invisible without paying for validation on every read.
builder.Services.AddMasterReplicaDbContext<AppDbContext>((options, cs) =>
options.UseNpgsql(cs, npgsql => npgsql.EnableRetryOnFailure(maxRetryCount: 5)));
Asking, on demand
ProbeReplicasAsync() answers "is every replica actually reachable right now?" — one connection and
one query per replica, run concurrently, without touching the context's own connection:
foreach (var result in await db.Database.ProbeReplicasAsync(cancellationToken))
{
Console.WriteLine(result.IsReachable
? $"replica #{result.ReplicaIndex}: up in {result.Duration.TotalMilliseconds:N0} ms"
: $"replica #{result.ReplicaIndex}: DOWN — {result.Error}");
}
Results feed the health monitor, so a probe on a schedule — or from a health check endpoint — keeps routing away from a node that is down without waiting for a request to fail on it.
ReplicaUnavailableException is deliberately not transient: every replica was dialled and refused,
so retrying cannot help until one comes back.
Background workers, Hangfire, Quartz
IDbTargetContext is a singleton whose state lives in an AsyncLocal, so it works identically
outside the web stack:
public sealed class SettlementWorker(
IServiceScopeFactory scopeFactory,
IDbTargetContext dbTarget) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
List<int> candidates;
using (dbTarget.UseReplicaDb()) // heavy, lag-tolerant scan
{
candidates = await db.Orders.Where(o => !o.Settled).Select(o => o.Id).ToListAsync(stoppingToken);
}
using (dbTarget.UseMasterDb()) // authoritative update
{
/* ... */
await db.SaveChangesAsync(stoppingToken);
}
}
}
Registration is the same call as in the web app — AddEfCoreMasterReplica deliberately touches no
ASP.NET Core type, so a Microsoft.NET.Sdk.Worker project on a runtime-only container image builds
and runs with no ASP.NET Core shared framework installed.
Dapper and raw ADO.NET
The interceptor fires on the paths EF Core itself opens. Reaching past it to the connection object steps outside that:
| What you write | Routed? |
|---|---|
db.Database.OpenRoutedConnectionAsync(...) |
✔ routed, with failover |
db.Database.OpenConnectionAsync(), then Dapper on GetDbConnection() |
✔ routed, with failover |
db.Database.GetDbConnection(), then Open() yourself |
✘ not routed |
db.Database.GetDbConnection(), then Dapper (which opens it for you) |
✘ not routed |
The sharp edge. An unrouted connection is not neutral: it carries whatever connection string was
last written to it. After an EF query that went to a replica, EF closes the connection but leaves the
replica's connection string on it, so raw access afterwards inherits that route — even under a
UseMasterDb() scope. A Dapper INSERT issued that way reaches a read-only replica.
Do this instead. Say which database you want, and let EF Core open it:
// The master.
await using var routed = await db.Database.OpenRoutedConnectionAsync(DbTarget.Master, cancellationToken);
var order = await routed.Connection.QuerySingleAsync<Order>("SELECT * FROM Orders WHERE Id = @id", new { id });
// A healthy replica, with failover between them.
await using var routed = await db.Database.OpenRoutedConnectionAsync(DbTarget.Replica, cancellationToken);
var rows = await routed.Connection.QueryAsync<Row>("SELECT ... /* heavy, lag-tolerant */");
// Say nothing and you get the target already in effect - an enclosing UseTarget scope if there is
// one, and your configured DefaultTarget otherwise.
await using var routed = await db.Database.OpenRoutedConnectionAsync(cancellationToken);
There is a synchronous OpenRoutedConnection() with the same two shapes. Disposing the handle
returns the connection to the context rather than closing it directly, because EF Core
reference-counts it; disposing twice is a no-op.
The target binds while the connection is being opened. If the context is already holding an open connection, EF Core hands back the existing one and its route stands — a connection string cannot be changed while the connection is open.
A connection you construct yourself, rather than taking from a DbContext, is outside this package
entirely: resolve IDbConnectionStringResolver and choose the string explicitly.
Read your own writes
Two mechanisms, both on by default:
ForceMasterOnSaveChanges—SaveChangesswitches to the master before touching the database, so aGETaction that stampsLastSeenAtor writes an audit row works instead of failing against a read-only replica.StickToMasterAfterSaveChanges— after a successful save, the rest of the scope stays on the master, so everything read after a write is read back from the master.
Beyond the current scope the package cannot detect replication lag; nothing can, from inside the
application. If a later request must observe an earlier write, pin it explicitly with
UseMasterDb().
Start-up checks
Two ways of wiring this package up wrongly used to produce no error at all. Both are now caught before the first request.
A DbContext registered without the interceptors throws. The message names the context and both
ways to fix it. It is an error rather than a warning because the package is doing literally nothing
for that context. A second context that deliberately lives elsewhere is declared rather than
silenced:
options.UnroutedDbContextTypes.Add(typeof(AuditDbContext));
Controllers registered with no routing mechanism warn. If the application called
AddControllers() but neither services.AddDbTargetMvcFilter() nor app.UseDbTargetRouting(), a
warning is logged naming both. A warning rather than an error, because routing purely through
UseTarget scopes is a legitimate design.
Whether the application is a web application is decided by matching service type names, never by
touching an MVC type, so the check stays safe on a host with no ASP.NET Core shared framework. Both
checks need an IHost. Turn them off with options.ValidateStartupWiring = false.
Registering the DbContext
| Registration | Result |
|---|---|
AddMasterReplicaDbContext<T> |
Routed |
AddMasterReplicaDbContextPool<T> |
Routed; no routing state sticks to a pooled instance |
AddMasterReplicaDbContextFactory<T> |
Routed; a produced context follows the flow that uses it |
AddDbContext<T> + UseMasterReplicaSplitting(sp) |
Routed, identically |
AddDbContext<T> alone |
Not routed — the host refuses to start |
services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseNpgsql(masterConnectionString);
options.UseMasterReplicaSplitting(sp);
});
Database providers
The package never names a provider type. Its entire provider-facing surface is four members declared
on the ADO.NET base class — DbConnection.State, DbConnection.ConnectionString, Open/OpenAsync
and Close — so any EF Core relational provider works.
| Provider | Package | Verified against a live server |
|---|---|---|
| SQL Server | Microsoft.EntityFrameworkCore.SqlServer |
✔ |
| PostgreSQL | Npgsql.EntityFrameworkCore.PostgreSQL |
✔ |
| MySQL / MariaDB | Pomelo.EntityFrameworkCore.MySql |
✔ |
| Oracle | Oracle.EntityFrameworkCore |
✔ |
| SQLite | Microsoft.EntityFrameworkCore.Sqlite |
✔ |
Every one of these runs against three real database servers — one master and two replicas, each holding a different marker row so a test can tell which one answered. The integration suite starts them in containers, then stops a replica's container mid-test and proves reads keep being served by the survivor, that a total outage falls back to the master, and that a recovered node is used again once its cooldown expires.
dotnet test tests/Appouse.EFCore.ReplicaManager.IntegrationTests # needs Docker
Without Docker those tests skip themselves rather than fail.
Provider notes
MySQL — do not use ServerVersion.AutoDetect. It opens a connection while the service provider
is being built, so start-up would depend on the master being reachable before any routing exists.
Pass an explicit MySqlServerVersion.
SQL Server — mark the replica connection strings read-only. When the replicas are Always On
secondaries, add ApplicationIntent=ReadOnly to each replica connection string. The master
connection string must not carry it.
PostgreSQL — Npgsql's multi-host support is complementary. It can balance connections itself with
Host=h1,h2,h3 and Target Session Attributes, but it cannot tell a read from a write the way an
attribute or a scope does. Use either style, or both.
Oracle — an Active Data Guard standby is read-only and works as a replica. Note that Oracle's EF
Core provider ships no EnableRetryOnFailure, unlike the other three, so transparent recovery
from a warm-pool outage needs an execution strategy of your own;
tests/Appouse.EFCore.ReplicaManager.IntegrationTests/OracleCluster.cs has a working one to copy.
SQLite has no replication. Useful for tests — this repository's own suite routes between two real SQLite files — but not a production topology.
Every connection string gets its own ADO.NET pool. A master plus three replicas is four pools,
each with its own Max Pool Size. And the model is built from the master connection string, so
replicas must expose the same schema.
Migrations
MasterConnectionString is the canonical connection string: it is what the provider gets for model
building, what dotnet ef uses, and what Database.Migrate() connects to. Keep DefaultTarget at
Master, or wrap migration explicitly:
using (dbTarget.UseMasterDb())
{
await db.Database.MigrateAsync();
}
Options
| Option | Default | Meaning |
|---|---|---|
MasterConnectionString |
(required) | The single master. |
ReplicaConnectionString |
(required) | First replica. Set it equal to the master to disable splitting. |
ReplicaConnectionStrings |
empty | Additional replicas, load-balanced and failed over. |
DefaultTarget |
Master |
Used when no rule applies. |
RouteByHttpMethod |
false |
Apply the HTTP verb convention. Off, so adding the package moves no traffic. |
ForceMasterInsideTransaction |
true |
Transactions always use the master. |
ForceMasterOnSaveChanges |
true |
SaveChanges always uses the master. |
StickToMasterAfterSaveChanges |
true |
Read-after-write consistency within the scope. |
AllowReplicaFallbackToMaster |
true |
Use the master when no replica answers. |
ValidateReplicaConnections |
false |
Prove each replica connection with a one-row query before use. |
ReplicaValidationQuery |
(auto) | SELECT 1, or SELECT 1 FROM DUAL on Oracle. |
ReplicaValidationTimeout |
5s |
How long that query may take. |
ReplicaFailureCooldown |
30s |
How long a failed replica is stood down. |
ValidateStartupWiring |
true |
Fail fast on a context or a web app that was never wired up. |
UnroutedDbContextTypes |
empty | Contexts deliberately left unrouted. |
MvcActionFilterOrder |
int.MinValue |
Filter position; lowest runs first. |
Bind from configuration instead, if you prefer:
builder.Services.AddEfCoreMasterReplica(
builder.Configuration.GetSection(MasterReplicaOptions.SectionName)); // "EfCoreMasterReplica"
Configuration is validated at host start-up, so a missing connection string stops the application instead of surfacing as a failure on the first query.
Extension points
// Pick replicas by latency, health or locality instead of round-robin.
services.Replace(ServiceDescriptor.Singleton<IReplicaSelector, MyReplicaSelector>());
// Track replica availability your own way.
services.Replace(ServiceDescriptor.Singleton<IReplicaHealthMonitor, MyHealthMonitor>());
// Source connection strings from a tenant catalogue or a secret store.
services.Replace(ServiceDescriptor.Singleton<IDbConnectionStringResolver, MyResolver>());
How it works
A singleton DbConnectionInterceptor rewrites DbConnection.ConnectionString in
ConnectionOpening / ConnectionOpeningAsync, immediately before EF Core opens the connection. For
a replica read it suppresses EF Core's own open and performs it, which is the only way to retry a
different connection string within one operation — that is what makes failover possible.
Three design decisions are load-bearing:
- Everything is a singleton. Interceptors are captured inside
DbContextOptions, and EF Core keys its internal service-provider cache on those options. A per-scope interceptor would make EF build a fresh internal provider per request — unbounded memory growth and EF's "More than twenty IServiceProvider instances have been created" warning. All per-request state lives in anAsyncLocalinstead, which is also why noHttpContextis needed. - The ambient value is a mutable holder, not a bare value. Assigning
AsyncLocal<T>.Valueis invisible to callers further up the stack, so a write detected deep insideSaveChangesAsynccould never pin the master for the rest of the request. Storing a small mutable object and mutating a field on it makes that work, whileUseTargetinstalls a new holder so its effect stays flow-local and is undone onDispose. - No MVC type is referenced from any method a non-web host calls. The CLR resolves type
references when it JITs a method, before executing it, so a single mention of
MvcOptionsinAddEfCoreMasterReplicawould crash a Worker Service on a runtime-only image — atry/catcharound it would not help. MVC wiring lives inAddDbTargetMvcFilter, and the package'sFrameworkReferencecarriesPrivateAssets="all"so the ASP.NET Core shared framework never becomes a consumer's runtime requirement.
Limitations
- A connection string can only be assigned while the connection is closed. EF Core opens late
and closes early, so each operation is routed independently — but not while an explicit
transaction is open, or after
Database.OpenConnection(). Start that work inside aUseTarget(...)scope; the route is fixed when the connection opens. - Deferred execution follows the target in effect when the query runs, not when it is composed.
- Connection strings are never written to the log, at any level.
License
MIT. See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Microsoft.EntityFrameworkCore.Relational (>= 8.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Logging (>= 8.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2.1.0 | 103 | 9/1/2026 |