DI026

Event Subscription On Scoped Publisher Without Unsubscribe

The scope-bounded tier of DI025: a transient-registered service subscribes an instance-capturing handler to an event on a scoped registered publisher — the receiver, identity/reference-cast, handler, and unsubscription proofs are exactly DI025's — and never unsubscribes. Publisher lifetime resolution follows the same rules (most conservative registration wins, closed registrations preferred over open-generic fallbacks, keyed-only registrations excluded), so a publisher registered both scoped and singleton reports DI026: only the scope-bounded claim is provable.

Info Default severity · Code fix: Yes

Why it matters

A transient injected with a scoped publisher is resolved from that same scope, so every transient instance the scope creates stays rooted in the publisher's delegate list until the scope is disposed, and the event keeps invoking handlers on instances the container has already released. Per-request scopes make this mostly benign; long-lived scopes — SignalR connections, Blazor circuits, hosted-service loop scopes — make it a real accumulation. DI026 reports at Info because the impact depends on scope longevity; raise it per team policy:

Balloons tied to the classroom door instead of the school gate — they all pop when the classroom closes for the day, but a classroom that stays open all year still ends up dragging a lot of balloons.

README problem example

services.AddScoped<IMessageBus, MessageBus>();
services.AddTransient<OrderHandler>();

public class OrderHandler
{
    public OrderHandler(IMessageBus bus)
    {
        bus.MessageReceived += OnMessage; // rooted by the scoped bus until the scope is disposed
    }

    private void OnMessage(object sender, EventArgs e) { }
}

Code fix

Yes — the same tier-1 (insert into existing Dispose) and tier-2 (override an inherited virtual Dispose(bool)) repairs as DI025, with the same gates. The tier-3 implement-IDisposable assist is never offered here: DI026 only fires for transient subscribers, and making a transient IDisposable is precisely the DI008 shape the fixer refuses.

Guardrails

When DI026 stays silent

Every DI025 guardrail applies unchanged. Additionally, scoped subscribers on scoped publishers stay silent — equal lifetimes resolve from the same scope and are torn down together.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app scoped-publisher subscription info

    public class Bad_TransientSubscribesToScopedPublisher
    {
        private readonly IScopedMessageBus _bus;

        public Bad_TransientSubscribesToScopedPublisher(IScopedMessageBus bus)
        {
            _bus = bus;
            // [DI026] Rooted by the scoped bus for as long as its scope lives
            _bus.MessageReceived += OnMessage;
        }

        private void OnMessage(object sender, EventArgs e) { }
    }

Sample app unsubscribe-in-dispose pattern

    public class Good_UnsubscribeInDispose : IDisposable
    {
        private readonly IScopedMessageBus _bus;

        public Good_UnsubscribeInDispose(IScopedMessageBus bus)
        {
            _bus = bus;
            _bus.MessageReceived += OnMessage;
        }

        public void Dispose()
        {
            // The matching -= releases this instance from the scoped publisher's delegate list.
            _bus.MessageReceived -= OnMessage;
        }

        private void OnMessage(object sender, EventArgs e) { }
    }

Related guides

  • No problem-guide pages point here yet.

Nearby diagnostics

Other rules in this family

All 37 rules