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 — same receiver, identity/reference-cast, handler, and unsubscription proofs as DI025 — and never unsubscribes. The publisher's registration lifetime is resolved with 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, because only the scope-bounded claim is provable.

Default severity: Info · 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. In a short per-request scope the accumulation usually dies quickly; in long-lived scopes — SignalR connections, Blazor circuits, hosted-service loop scopes — it is a real leak. DI026 reports at Info because the impact depends on scope longevity; raise it per team policy:

README problem example

README better pattern

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, because DI026 only fires for **transient** subscribers and making a transient `IDisposable` is exactly the DI008 shape the fixer refuses.

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.