DI025

Event Subscription On Longer-Lived Publisher Without Unsubscribe

A transient- or scoped-registered service that subscribes (+=) an instance-capturing handler — an instance method group, a this-capturing lambda, or a stored instance-bound delegate field — to an event on a longer-lived publisher and never unsubscribes. Subscriber lifetime is evaluated per effective service slot: a shorter-lived slot keeps the leak report even when the same implementation also has a singleton slot, while an overridden same-slot transient descriptor does not. Longer-lived publishers are injected dependencies whose registration is provably singleton — closed registrations preferred, open-generic singleton registrations matched for constructed injections — via a constructor parameter or a field/property assigned only from a constructor parameter, and static events. Identity and reference casts preserve that proof, so ((IBaseBus)_bus).Changed += H reports for direct injected receivers and already-proven stable chains. Chained receivers (_host.Bus.Changed += H) report when the publisher is a stable projection of an injected root: the lifetime proof anchors on the chain root's registration, and every intermediate segment must be a readonly field, a get-only auto-property, or a getter returning one, with interface segments proven through the root's registered implementation types. Because C# forbids assigning another type's field-like event, the cross-type delegate leak lives on a delegate-typed field or property of the publisher instead: _bus.Handlers += OnMessage and the equivalent self-assignment _bus.Handlers = (EventHandler)Delegate.Combine(_bus.Handlers, OnMessage) report identically to an event +=, with a mirrored Delegate.Remove self-assignment recognized as the matching unsubscription. A -= written with a different lambda instance is recognized as the classic no-op unsubscribe bug: the subscription still reports and the diagnostic points at the ineffective -=.

Warning Default severity · Code fix: Yes

Why it matters

The publisher's delegate list holds a strong reference to every handler target, so a singleton publisher roots every subscriber instance the container ever creates — the most common managed memory leak in .NET, plus stale handlers executing against released state on every event raise.

If every visitor ties a balloon to the school gate and nobody ever unties one, the gate ends up dragging a thousand balloons.

README problem example

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

public class OrderHandler
{
    private readonly IMessageBus _bus;

    public OrderHandler(IMessageBus bus)
    {
        _bus = bus;
        _bus.MessageReceived += OnMessage; // every OrderHandler instance stays rooted
    }

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

README better pattern

public class OrderHandler : IDisposable
{
    private readonly IMessageBus _bus;

    public OrderHandler(IMessageBus bus)
    {
        _bus = bus;
        _bus.MessageReceived += OnMessage;
    }

    public void Dispose() => _bus.MessageReceived -= OnMessage;

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

Yes, in three tiers, all gated on a method-group handler whose receiver (a field/property, a field/property-rooted chain, or a static event) still resolves inside Dispose. (1) Insert into an existing Dispose — when the type already declares a block-bodied Dispose(), Dispose(bool), or DisposeAsync() and implements the matching disposal interface (IDisposable/IAsyncDisposable), the fix inserts the mirrored -= at the top of that method. (2) Create the Dispose path when the contract is inherited — when disposability comes from a base type that follows the standard virtual Dispose(bool) pattern, the fix adds a protected override void Dispose(bool disposing) that unsubscribes and chains to base.Dispose(disposing); overriding the pattern is what guarantees the unsubscribe actually runs (through the base's Dispose()Dispose(true) dispatch). Inherited shapes with no such hook — a non-virtual or explicitly-implemented base Dispose — are refused, because an added method the container never calls would be a fake repair. (3) Implement IDisposable outright for scoped subscribers — a subscriber registered scoped that implements neither disposal interface gets IDisposable added to its base list plus a public void Dispose() that unsubscribes; its owning scope disposes it deterministically, so no leak is introduced. Introducing IDisposable on a transient subscriber is refused — that is exactly the DI008 disposable-transient-capture shape, so the fix must never trade a DI025 for a DI008 — and hoisting a lambda into a field stays refused because it changes capture semantics.

Guardrails

When DI025 stays silent

Singleton subscribers stay silent (a population of one cannot grow the delegate list — hosted services subscribing to singleton buses are the canonical safe shape), as do transient publishers (scoped publishers report the DI026 Info tier instead), any matching -= anywhere in the type (Dispose, StopAsync, teardown methods, the unsubscribe-then-resubscribe idiom) with the same method group — override chains normalized — or the same stored delegate field/local, static handlers and this-free lambdas, publishers assigned from new or ordinary method parameters, user-defined or value-changing receiver conversions, chained receivers whose projection is not provably stable (settable or computed segments, metadata-only or virtual segments), unregistered subscriber or publisher types, keyed-only publisher registrations, EventSource-derived publishers, and factory registrations with unknown implementation types. Casted and uncast receiver syntax canonicalize to the same publisher identity when matching += with -=. Removals are recorded structurally, so a branch-conditional -= (if (_attached) { _bus.E -= H; }) suppresses unconditionally — a deliberate, documented FN that favours FP-safety over proving the guard always runs.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app event-subscription leak warning

    public class Bad_SubscribeWithoutUnsubscribe
    {
        private readonly IMessageBus _bus;

        public Bad_SubscribeWithoutUnsubscribe(IMessageBus bus)
        {
            _bus = bus;
            // [DI025] 'Bad_SubscribeWithoutUnsubscribe' is registered as transient but never unsubscribes
            _bus.MessageReceived += OnMessage;
        }

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

Sample app unsubscribe-in-dispose pattern

    public class Good_UnsubscribeInDispose : IDisposable
    {
        private readonly IMessageBus _bus;

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

        public void Dispose()
        {
            // The matching -= releases this instance from the singleton'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