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>();
Enter fullscreen mode Exit fullscreen mode
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();
}
}
Enter fullscreen mode Exit fullscreen mode
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>();
Enter fullscreen mode Exit fullscreen mode
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();
}
}
Enter fullscreen mode Exit fullscreen mode
๐ 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>();
Enter fullscreen mode Exit fullscreen mode
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);
}
}
Enter fullscreen mode Exit fullscreen mode
๐ 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.
๋ต๊ธ ๋จ๊ธฐ๊ธฐ