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 (1)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw •

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 SaveChangesAsync and then lose the acknowledgement, causing the same event to arrive again.

await using var scope = scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider.GetRequiredService<IEventProcessor>();
await processor.ProcessAsync(evt, stoppingToken);
await receiver.CompleteMessageAsync(message, stoppingToken);
Enter fullscreen mode Exit fullscreen mode

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 DbContext per 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?