DEV Community

Rahul Kumar Jha
Rahul Kumar Jha

Posted on

🚦 Event Listeners, Processors, and DbContext β€” Pitfalls & Best Practices - .NET 10

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?

  • DbContext is scoped (new per request).
  • EventProcessor is 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 IServiceScopeFactory inside the singleton processor, or
    • Make the processor scoped and resolve it per event.

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)