Hereβs a short, easy-to-digest article that explains the pitfalls and best practices of mixing lifetimes in .NET dependency injection.
π¦ Event Listeners, Processors, and DbContext β Pitfalls & Best Practices
Imagine you have two buddies in your app:
- EventListener β sits around forever, waiting for events (a singleton).
- EventProcessor β does the actual work when an event arrives.
Now the big question: what lifetime should EventProcessor have, especially if it needs a DbContext?
β Pitfall: Singleton Processor + Scoped DbContext
services.AddSingleton<IEventProcessor, EventProcessor>();
services.AddDbContext<AppDbContext>();
And inside EventProcessor:
public class EventProcessor : IEventProcessor
{
private readonly AppDbContext _db;
public EventProcessor(AppDbContext db)
{
_db = db;
}
public Task ProcessAsync(Event evt)
{
_db.Events.Add(evt);
return _db.SaveChangesAsync();
}
}
What goes wrong?
-
DbContextis scoped (new per request). -
EventProcessoris singleton (one forever). - You end up holding onto one DbContext instance for the entire app lifetime. β That DbContext gets stale, tracks too much, and blows up with concurrency errors. β Basically, youβre trying to use a toothbrush for the whole office β gross and unsafe.
β Best Practice #1: Keep Processor Singleton, Use Scope Factory
services.AddSingleton<IEventProcessor, EventProcessor>();
services.AddDbContext<AppDbContext>();
public class EventProcessor : IEventProcessor
{
private readonly IServiceScopeFactory _scopeFactory;
public EventProcessor(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task ProcessAsync(Event evt)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Events.Add(evt);
await db.SaveChangesAsync();
}
}
π Each event gets a fresh DbContext. No stale data, no concurrency nightmares.
β Best Practice #2: Make Processor Scoped
services.AddScoped<IEventProcessor, EventProcessor>();
services.AddSingleton<IEventListener, EventListener>();
And inside the listener:
public class EventListener
{
private readonly IServiceScopeFactory _scopeFactory;
public EventListener(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task OnEventAsync(Event evt)
{
using var scope = _scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<IEventProcessor>();
await processor.ProcessAsync(evt);
}
}
π The listener stays singleton, but it spins up a scoped processor (and DbContext) per event.
π TL;DR
- Never inject scoped services (like DbContext) directly into a singleton.
- If your processor is stateless β make it singleton.
- If it needs DbContext β either:
- Use
IServiceScopeFactoryinside the singleton processor, or - Make the processor scoped and resolve it per event.
- Use
Think of it like this:
- Singleton = one toothbrush for life.
- Scoped = one toothbrush per person/request.
- Transient = disposable toothbrush every time.
You wouldnβt share one toothbrush forever, right? Same rule applies to DbContext.
Top comments (0)