Every backend I have worked on eventually hits the same problem. There is one endpoint, usually a listing or a report, that reads a lot and returns a lot, and it slowly becomes the reason the database has a bad day. Someone opens the catalog page, the query goes through a few million rows, and every write happening at that moment waits in line behind it.
The textbook answer is boring and correct: send those reads to a replica. You probably already have one, or you can create one in a few minutes on any managed database. The primary handles writes, the replica handles the heavy reads.
The part the textbook skips is the annoying one: how do you route a specific request to the replica without passing a connection object through every method that might touch the database?
I got this wrong a few times before I got it boring. This is the boring version, in .NET. It is smaller than the title suggests: it routes a whole request to one server, not each query, and that limitation is what keeps it clean.
The naive fix
The first instinct is to pass the connection down. You add a second DbContext, a ReplicaCatalogDbContext, and inject it into the one controller that needs it.
That works for exactly one endpoint. Then the report calls a service, the service calls another service, and now you are passing a "which database" flag through five layers that had no business knowing a database exists. Every new read path becomes a decision. Every refactor drags the flag along.
I have written that version. It looks fine in the pull request and it is annoying six months later.
What I wanted was this: the service layer stays exactly as it is, and somewhere else, out of the way, something decides which connection this request uses. One place. One line per endpoint.
Context, not a parameter
"Which database does this request use" is not a parameter, it is context. It belongs to the request, not to any function inside it. So instead of passing it down, you put it somewhere the whole request can read, and you read it back when you open the connection.
The simpler version of this uses a static AsyncLocal. I prefer a scoped service: it already has a per request lifetime, it is easier to test, and it does not leak request state into work that outlives the request.
public enum DatabaseTarget
{
Primary,
Replica
}
public sealed class RequestDbTarget
{public DatabaseTarget Target { get; set; } = DatabaseTarget.Primary;
}
That is the whole state, and it defaults to the primary so forgetting to decide is the safe option. Register it as scoped:
builder.Services.AddScoped<RequestDbTarget>();
A note on the name. My first version called it ReadOnly, which was a lie: nothing here makes anything read only, and the compiler will happily send a SaveChangesAsync to a replica that rejects it at runtime. DatabaseTarget.Replica says what it actually does, which is pick a server.
Flipping the target without touching the handler
I want to mark an endpoint the same way I mark any other cross-cutting concern: with an attribute. Nothing inside the action changes, I just tag it.
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class UseReplicaAttribute : Attribute { }
The attribute does nothing on its own. A small middleware, placed right after routing so the endpoint is already resolved, is what reads it and sets the target:
app.UseRouting();
app.Use(async (context, next) =>
{var useReplica = context.GetEndpoint()?.Metadata.GetMetadata<UseReplicaAttribute>() is not null;
if (useReplica){
context.RequestServices
.GetRequiredService<RequestDbTarget>().Target = DatabaseTarget.Replica;}
await next();
});
There is a requirement hiding here: this middleware has to run before anything resolves the DbContext. Usually that is free, since the context is created when the controller asks for it and the controller runs after routing. But if some earlier middleware resolves CatalogDbContext for its own reasons, the connection string is already chosen and your target arrives too late. The order is part of the design, not a detail.
The DbContext picks a side
Two connection strings in configuration, nothing exotic:
{"ConnectionStrings": {"Primary": "Server=primary;Database=Shop;...","Replica": "Server=replica;Database=Shop;..."}
}
And the registration reads the target when it builds the options:
builder.Services.AddDbContext<CatalogDbContext>((sp, options) =>
{var target = sp.GetRequiredService<RequestDbTarget>().Target;var config = sp.GetRequiredService<IConfiguration>();
var connectionString = target == DatabaseTarget.Replica
? config.GetConnectionString("Replica"): config.GetConnectionString("Primary");
options.UseSqlServer(connectionString);
});
That is it. No branching inside a controller, no second context injected by hand, no flag travelling through the service layer. The decision lives in two places that already existed for infrastructure reasons.
One note: this assumes the default EF Core lifetime, one context per request, connection chosen when the context is built. If you use AddDbContextPool or an IDbContextFactory the timing changes, because a pooled or factory built context does not necessarily read your scoped target when you expect it to.
What the endpoint looks like now
The heavy listing that used to bully the primary:
[UseReplica]
[HttpGet("products")]
public async Task<IEnumerable<ProductDto>> ListProducts([FromQuery] ProductFilter filter)
{return await _catalog.Search(filter);
}
One attribute. Now picture the calls underneath:
ProductsController
-> CatalogService
-> InventoryService
-> PriceService
None of those services knows a replica exists. CatalogService.Search runs the same query it always ran, against a connection it never chose, and that connection now points at the replica. The write endpoints next to it keep hitting the primary, because they are not tagged.
When a new report shows up next quarter, pointing it at the replica is a one line diff. Nobody has to open a service to do it.
It routes a request, not an operation
This is where the pattern stops being magic. The target is set once, for the whole request, before any database work happens. Every query in that request goes to the same server. Usually that is what you want. Sometimes it is not.
Picture an endpoint that writes and then reads its own write:
public async Task<IActionResult> Save(ProductInput input)
{await _catalog.Update(input);
var refreshed = await _catalog.ListProducts();return Ok(refreshed);
}
You cannot tag that one with [UseReplica]. The update goes to the primary, and the read right after it also has to go to the primary, because the replica may not have caught up and would return the version from before you saved. That is not a read only request. It is a write request that happens to read afterwards, and both halves belong on the primary.
So be honest about what this buys you. It has no answer for "write to the primary, then run a heavy report on the replica, in the same request". When a request genuinely needs both, this is the wrong tool. Per operation routing exists, you pick a connection at each query, it just costs a lot more machinery and pushes the concern back into the code you were trying to keep clean. Most apps do not need it.
Replicas lag
A replica is not a faster copy of the truth. It lags, and it lags in ways that matter.
The classic trap is read after write across two requests. A user updates their shipping address, the browser immediately refetches the profile, and if that read went to the replica it may come back with the old address. The user thinks the save failed. It did not, you just asked the wrong server.
The rule I settled on is simple: the replica is for reads that tolerate slightly stale data. Catalogs, dashboards, reports, search. Anything a user just wrote and expects to see back stays on the primary.
Closing thoughts
This pattern hides in the two places that were already infrastructure. The middleware was going to exist. The DbContext registration was going to exist. The service layer, the part that is actually mine to get right, never learns any of this is happening.
That is the same idea I keep writing about from different angles: spend your cleverness once, in a corner, and be boring everywhere else. A read replica is about as boring as infrastructure gets, and switching an endpoint onto it now costs one word above a method. The 10PM version of me, debugging a slow query during an incident, will be glad the routing is in one obvious place and that its one limitation is written down instead of discovered.