Anyone who has worked with dependency injection in .NET, might have registered something like this:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
It works. app runs. But why does AddDbContext default to Scoped, and what would break if it didn't?
Let's find out — by breaking it on purpose.
The three lifetimes
Before touching any code, here's what each lifetime actually promises:
Transient — A new instance is created every single time it's requested. Ask for it twice in the same method, and get two different instances.
Scoped — One instance is created per scope, and reused for every request within that scope. In a web app, a scope is one HTTP request — so everything inside that request shares the same instance. The next request gets a fresh one.
Singleton — Exactly one instance, ever, for the entire lifetime of the app. Every request, every user, every caller — all sharing that one object, forever, until the app shuts down.
That's the theory. Now let's watch it actually happen.
Setting up a DbContext that tells us who it is
The trick to seeing lifetime behavior instead of just reading about it: give the DbContext a fingerprint.
public class AppDbContext : DbContext
{
public Guid InstanceId { get; } = Guid.NewGuid();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
}
Every time a new AppDbContext gets constructed, it stamps itself with a new Guid. If two services end up holding the same InstanceId, we know for certain they got handed the same object — not two copies that look alike.
Two services that both need a database connection
In any real app, most cases never have just one class touching the database. Let's add two:
public class OrderWriter
{
private readonly AppDbContext _db;
public OrderWriter(AppDbContext db) => _db = db;
public void ShowInstance(string label) =>
Console.WriteLine($"[OrderWriter] {label} → {_db.InstanceId}");
}
public class InventoryWriter
{
private readonly AppDbContext _db;
public InventoryWriter(AppDbContext db) => _db = db;
public void ShowInstance(string label) =>
Console.WriteLine($"[InventoryWriter] {label} → {_db.InstanceId}");
}
Both just print which AppDbContext they were handed. Think of these as stand-ins for an OrderRepository and an InventoryService in a real order-processing flow — two unrelated classes that both happen to need the same database context.
We still haven't registered anything with DI yet.
Wiring it up — starting with Singleton
Here's where it gets interesting. Let's register AppDbContext as a Singleton and see what happens across two separate "requests":
var services = new ServiceCollection();
services.AddSingleton<AppDbContext>(_ =>
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("SingletonTest").Options;
return new AppDbContext(options);
});
services.AddTransient<OrderWriter>();
services.AddTransient<InventoryWriter>();
var provider = services.BuildServiceProvider();
I used an in-memory database here on purpose — no real SQL Server needed, so anyone can copy this straight into a console app and run it.
Quick note on OrderWriter and InventoryWriter being Transient: that's deliberate. We want them rebuilt fresh every time, so the only thing that can explain a repeated InstanceId is the DbContext's own lifetime — not some side effect of caching the writer itself.
Simulating two HTTP requests
In a real web app, a "scope" is created automatically for you on every incoming request. In a console app, we have to make that explicit:
using (var requestOne = provider.CreateScope())
{
var order = requestOne.ServiceProvider.GetRequiredService<OrderWriter>();
var inventory = requestOne.ServiceProvider.GetRequiredService<InventoryWriter>();
order.ShowInstance("Request 1");
inventory.ShowInstance("Request 1");
}
using (var requestTwo = provider.CreateScope())
{
var order = requestTwo.ServiceProvider.GetRequiredService<OrderWriter>();
order.ShowInstance("Request 2");
}
Each using block is standing in for one separate incoming HTTP request, completely unrelated to the other.
Run this with the Singleton registration from above, and here's what prints:
[OrderWriter] Request 1 → 3f1a2b4c-...
[InventoryWriter] Request 1 → 3f1a2b4c-...
[OrderWriter] Request 2 → 3f1a2b4c-...
Same InstanceId, every single time — even across two completely separate requests.
Why that's actually a problem
In a real app: two unrelated users, hitting your API at the same time, would be sharing one AppDbContext.
DbContext isn't thread-safe. If request 1 and request 2 land close enough together, you'll hit:
InvalidOperationException: A second operation was started on this
context instance before a previous operation completed.
And even if you never hit that exact exception, there's a quieter problem: EF Core's change tracker never gets cleared. With Scoped, a new DbContext — and therefore a new empty change tracker — gets created for every request. When the request ends, that DbContext gets disposed, and the tracker (along with everything it was holding onto) gets thrown away with it. But in this case, every entity ever loaded by any request stays tracked in that one singleton context, forever, growing without bound for as long as the app runs.
Singleton is wrong here. Let's try the opposite extreme.
Swapping in Transient
Same harness — change the registration line:
services.AddTransient<AppDbContext>(_ =>
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("TransientTest").Options;
return new AppDbContext(options);
});
Run the same two-request simulation, and now you get:
[OrderWriter] Request 1 → 9c2e7f10-...
[InventoryWriter] Request 1 → 7b44d821-...
[OrderWriter] Request 2 → a91f3c05-...
Every InstanceId is different — including OrderWriter and InventoryWriter, within the same request.
Why that's also a problem —
Picture OrderService written like this:
public class OrderService
{
private readonly AppDbContext _db;
private readonly IInventoryService _inventory;
private readonly IOrderRepository _repository;
public OrderService(AppDbContext db, IInventoryService inventory, IOrderRepository repository)
{
_db = db;
_inventory = inventory;
_repository = repository;
}
public async Task PlaceOrderAsync(Order order, OrderItem item)
{
await _inventory.ReserveAsync(item.ProductId, item.Quantity);
await _repository.SaveAsync(order);
await _db.SaveChangesAsync();
}
}
ReserveAsync and SaveAsync are written to only stage their changes — no internal SaveChangesAsync() calls — trusting that one final commit at the bottom will save everything. That's the correct pattern, but only if every _db in this picture is the same object.
What happens with Transient
If AppDbContext is registered Transient, OrderService's own constructor gets handed its own fresh instance — call it DbContext#3. That's separate from whatever IInventoryService was given (DbContext#1) and whatever IOrderRepository was given (DbContext#2).
So:
-
_inventory.ReserveAsyncstages its change onDbContext#1. -
_repository.SaveAsyncstages its change onDbContext#2. -
_db.SaveChangesAsync()commitsDbContext#3— which nothing ever touched.
The method runs to completion. No exception. No error. And nothing was saved — because the one context that actually got told to commit was never the one holding any staged changes. The changes inside DbContext#1 and DbContext#2 are simply discarded the moment those objects are disposed, since nobody ever called SaveChangesAsync() on either of them specifically.
Now the one that actually works: Scoped
Last swap:
services.AddScoped<AppDbContext>(_ =>
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("ScopedTest").Options;
return new AppDbContext(options);
});
Run it again:
[OrderWriter] Request 1 → c450e9a2-...
[InventoryWriter] Request 1 → c450e9a2-...
[OrderWriter] Request 2 → e702b614-...
Look at that pattern closely — OrderWriter and InventoryWriter share an InstanceId within Request 1, but Request 2 gets a completely different one.
That's exactly the property we need:
-
Within one request, every service shares one
DbContext— one change tracker, one real unit of work.OrderWriterandInventoryWritercan both stage changes and commit them together. - Across requests, nothing leaks. Request 2 starts clean, with no leftover tracked entities from Request 1, and no thread-safety conflict if both requests happen to run at the same time.
Putting it side by side
| Lifetime | Within one request | Across two requests | What goes wrong |
|---|---|---|---|
| Singleton | Same instance | Same instance | Thread-safety crashes, unbounded change tracker growth |
| Transient | Different instances | Different instances | Two services in one request can't share a transaction |
| Scoped | Same instance | Different instances | Nothing — this is the one that fits |
Try it yourself
The full thing runs as a plain console app — no real database required, since we're using EF Core's in-memory provider for the experiment. You'll need:
dotnet add package Microsoft.EntityFrameworkCore.InMemory
Then just swap the single registration line between AddSingleton, AddTransient, and AddScoped, rerun, and watch the GUIDs tell the story. [gist link] Once you've seen all three side by side like this, the rule stops being something you memorized and starts being something you actually understand: match the lifetime to how long the state inside the object is supposed to live. A DbContext should live exactly as long as one unit of work — which, in a web app, is exactly one request. That's not a coincidence. That's the entire reason Scoped exists.
That covers Scoped pretty thoroughly. But Scoped isn't the only lifetime that earns its place — Singleton and Transient each have a scenario where they're not just "safe," they're the better choice than the other two. Let's look at one real example of each.
When Singleton is the right call
Use Singleton for stateless objects, require heavy initialization, or hold global application state. Common examples:
- Caching services
- Configuration providers
- Logging utilities
- Background workers and hosted services
When to avoid Singleton:
- Thread-unsafe state — if multiple requests can hit the same instance at the same time, concurrent modifications to mutable data will cause bugs that are hard to reproduce.
-
Captive dependencies — never inject a shorter-lived service (Scoped or Transient) into a Singleton. Doing so forces that shorter-lived service to live far longer than it was designed to, which is exactly the bug our
AppDbContextexperiment above demonstrated: a ScopedDbContexttrapped inside something longer-lived stops behaving like a per-request unit of work and starts leaking state across requests instead.
Picture an app-wide pricing settings provider — tax rates, currency exchange rates, feature flags — loaded once from configuration or a slow-changing table, then read over and over by every request that calculates a price.
If this were Scoped, every single request would re-read config and rebuild that exchange-rate data from scratch — paying the same cost over and over for a result that never changes between requests. Transient would be worse, potentially rebuilding it multiple times within one request if several services injected it.
When Transient is the right call
Use Transient when:
- The service is stateless — the class doesn't hold or track data across multiple calls.
- You're building lightweight utilities — simple validators, formatters, or data mappers are a natural fit.
- The service isn't thread-safe — since you get a fresh instance every time, you sidestep the concurrency issues that come from sharing one object across multiple threads.
- You need to avoid state contamination — you want one operation's execution to never leak data into another operation's execution, even within the same request.
When not to use Transient:
- Heavily used dependencies — since the container builds a new object every single time, excessive use of Transient services increases allocations and adds pressure on the garbage collector, which can hurt performance at scale.
-
When sharing context is required — if multiple services need to work on the same unit of work at the same time (Entity Framework's
DbContextbeing the textbook case), Transient breaks that sharing apart. That's exactly what we watched happen in the experiment above — use Scoped instead.
Now picture a report builder that accumulates lines as it works — say, one service builds the text for an invoice summary, and a separate, unrelated service builds the text for a shipping label. These aren't two pieces of one shared document; they're two completely independent outputs that just happen to use the same kind of builder. Transient guarantees each service gets its own instance, starting from a clean slate, so the invoice service's lines never show up inside the shipping service's result, and vice versa.
The pattern across all three
| Lifetime | What actually justifies it | What goes wrong with the wrong choice |
|---|---|---|
| Singleton | Holds no per-request data; nothing mutates after construction | Scoped/Transient would rebuild expensive, identical state on every request for no benefit |
| Scoped | Wraps a per-request unit of work (like DbContext) that multiple services need to share |
Singleton leaks state across users; Transient splits one logical operation into disconnected pieces |
| Transient | Mutates its own state mid-operation, and that state must never be shared between callers | Scoped or Singleton would let one caller's leftover state corrupt another's result |
Every lifetime decision in this article comes down to the same one question, asked about the specific object in front of you: what does this object store on itself, who else might be holding the same instance at the same time, and what would happen if they were? Answer that honestly for any service in your own app, and the right registration call — AddSingleton, AddScoped, or AddTransient — stops being a guess.
Top comments (0)