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 (1)
One extra boundary I’d make explicit is message settlement. A fresh scope fixes the lifetime problem, but it doesn’t make processing exactly once. With a peek-lock broker, the handler can commit
SaveChangesAsyncand then lose the acknowledgement, causing the same event to arrive again.I’d put the event ID behind a unique constraint in an inbox table and record it in the same database transaction as the business change. I’d also bound concurrent scopes against the connection pool: one
DbContextper event is safe, but unbounded delivery can turn a concurrency bug into pool starvation. What delivery guarantee and maximum handler concurrency is the listener designed around?