← Open in the full interactive course (progress tracking, search & more)

You know what event restricts. Now let's open the compiler's output, write your own add/remove, and confront the leak that restriction quietly enables.

Lesson 105 told you exactly what public event Action? OnClick; restricts external code to: += and -=, nothing else. It also told you, in "Under the Hood," that this compiles to a private backing delegate field plus a compiler-generated add accessor and remove accessor. That single sentence deserves a full lesson of its own — because once you can write those accessors yourself, a whole category of framework-level techniques opens up, and one very real, very common .NET memory leak stops being mysterious and starts being obvious.

What Is It?

The Simple Explanation

Every event you've written so far — public event EventHandler<T>? Something; — is what's called a field-like event. It looks like a field declaration, and behind the scenes the compiler builds you a real private field plus two methods you never see: one that runs when someone does +=, and one that runs when someone does -=. C# also lets you write those two methods yourself, explicitly, exactly the way you'd write a property's custom get/set instead of accepting the auto-generated ones.

The Technical Definition

A custom-accessor event (sometimes called an "event with explicit add/remove") declares the event without any backing field of its own, and instead provides add and remove blocks that run whatever code you write whenever external code performs += or -=. You are entirely responsible for where the subscribed delegates actually get stored — a private field, a dictionary keyed by event name, anything you choose.

public class Counter
{
    private EventHandler? _thresholdReached; // YOU declare the backing field

    public event EventHandler? ThresholdReached
    {
        add    { _thresholdReached += value; }   // runs on every "+="
        remove { _thresholdReached -= value; }    // runs on every "-="
    }

    protected virtual void OnThresholdReached(EventArgs e)
        => _thresholdReached?.Invoke(this, e);
}

The keyword value inside add/remove is the delegate instance being added or removed — exactly like value inside a property's set accessor represents the value being assigned. Behaviorally, this hand-written version is identical to public event EventHandler? ThresholdReached; — you've simply made explicit what the compiler was already doing for you.

Field-Like Event (Compiler-Generated)

Custom-Accessor Event

Why Does It Exist?

The Problem — Sometimes the Default Storage Isn't Right

A field-like event always allocates a dedicated field, whether or not anyone ever actually subscribes to it. That's a perfectly reasonable default for a class with one or two events. But consider a UI control class — the kind that ships in a widget framework — with forty possible events (Click, MouseEnter, MouseLeave, GotFocus, LostFocus, KeyDown, ...). In real applications, any given control instance typically has handlers wired up for two or three of those forty, if that. Forty dedicated delegate fields on every single control instance — most of them permanently null — is real, wasted memory multiplied across every control on screen. Separately, sometimes you need subscribe/unsubscribe itself to do more than just combine a delegate: acquire a lock, log the subscription, validate the handler, or reject a duplicate subscription outright.

The Solution — Take Control of Storage and Subscription Logic

Custom add/remove accessors let you decouple "the event exists as a public subscription point" from "the event has a dedicated field." The classic technique — genuinely used internally by WPF and WinForms-style frameworks — stores every subscribed handler for every event in one shared dictionary keyed by an object that identifies the event, instead of one field per event. A control with forty events but only three actively subscribed pays for three dictionary entries, not forty fields.

The key insight

Custom accessors don't change what an event is from the outside — external code still only gets +=/-=, exactly as lesson 105 established. They change how the subscriber list is stored and managed on the inside. This is encapsulation working exactly as intended: the public contract stays fixed while the implementation is free to change.

Big Picture

ONE FIELD PER EVENT vs ONE SHARED STORE
40 FIELD-LIKE EVENTS
_click       = null
_mouseEnter  = null
_mouseLeave  = null
_gotFocus    = null
... (36 more, mostly null)
40 fields allocated per instance, regardless of usage
1 SHARED DICTIONARY
_handlers["Click"]      = h1
_handlers["GotFocus"]   = h2
// 38 events never subscribed:
// no entry exists for them at all
Only entries that are actually subscribed cost any memory

How It Works

WRITING A THREAD-SAFE CUSTOM ACCESSOR, STEP BY STEP
1. DECLARE YOUR OWN BACKING STORAGE AND A LOCK
private readonly object _gate = new();
private EventHandler? _priceChanged;
2. WRITE add — RUNS ON EVERY +=
add
{
    lock (_gate) { _priceChanged += value; }
}
3. WRITE remove — RUNS ON EVERY -=
remove
{
    lock (_gate) { _priceChanged -= value; }
}
4. RAISE FROM A LOCAL SNAPSHOT, EXACTLY AS BEFORE
EventHandler? handlers;
lock (_gate) { handlers = _priceChanged; }
handlers?.Invoke(this, EventArgs.Empty);

Simple Example

using System;

public class LoggingPublisher
{
    private EventHandler? _somethingHappened;

    public event EventHandler? SomethingHappened
    {
        add
        {
            Console.WriteLine($"[SUBSCRIBE] A handler was added.");
            _somethingHappened += value;
        }
        remove
        {
            Console.WriteLine($"[UNSUBSCRIBE] A handler was removed.");
            _somethingHappened -= value;
        }
    }

    public void DoWork() => _somethingHappened?.Invoke(this, EventArgs.Empty);
}

class Program
{
    static void Main()
    {
        var publisher = new LoggingPublisher();
        EventHandler handler = (s, e) => Console.WriteLine("Handled!");

        publisher.SomethingHappened += handler; // [SUBSCRIBE] A handler was added.
        publisher.DoWork();                      // Handled!
        publisher.SomethingHappened -= handler; // [UNSUBSCRIBE] A handler was removed.

        // publisher.SomethingHappened = null;   //  still a compile error from outside — unchanged
    }
}

Code → Meaning → Result: Subscribing and unsubscribing now run your own code — here, a log line — in addition to the actual delegate combination. Everything about external accessibility (only +=/-= allowed) is exactly as strict as a plain field-like event; custom accessors add capability on the inside without loosening anything on the outside.

Real-World Example — A Shared Event-Table Control

Here's the memory-saving technique from the Big Picture, in full: a control with many possible events, backed by one dictionary instead of one field per event. This is the shape of technique that real UI frameworks use internally so that a control class can expose dozens of events without every instance paying for dozens of null fields.

using System;
using System.Collections.Generic;

public class WidgetControl
{
    // ONE shared store for every event this control could ever raise.
    // Keyed by a string here for clarity; real frameworks often use a
    // lightweight private key object instead of a string for speed.
    private readonly Dictionary<string, Delegate> _handlers = new();
    private readonly object _gate = new();

    private void AddHandler(string key, Delegate value)
    {
        lock (_gate)
        {
            _handlers.TryGetValue(key, out var existing);
            _handlers[key] = Delegate.Combine(existing, value)!;
        }
    }

    private void RemoveHandler(string key, Delegate value)
    {
        lock (_gate)
        {
            if (!_handlers.TryGetValue(key, out var existing)) return;
            var combined = Delegate.Remove(existing, value);
            if (combined is null) _handlers.Remove(key);
            else _handlers[key] = combined;
        }
    }

    public event EventHandler? Click
    {
        add => AddHandler(nameof(Click), value!);
        remove => RemoveHandler(nameof(Click), value!);
    }

    public event EventHandler? GotFocus
    {
        add => AddHandler(nameof(GotFocus), value!);
        remove => RemoveHandler(nameof(GotFocus), value!);
    }

    // ... imagine 38 more events, all following the identical pattern ...

    public void RaiseClick()
    {
        lock (_gate) { _handlers.TryGetValue(nameof(Click), out var d); (d as EventHandler)?.Invoke(this, EventArgs.Empty); }
    }
}

class Program
{
    static void Main()
    {
        var widget = new WidgetControl();
        widget.Click += (s, e) => Console.WriteLine("Clicked!");
        // widget.GotFocus was never subscribed — it costs ZERO memory beyond
        // the shared, empty-until-used dictionary. A field-like event would
        // have allocated a dedicated (permanently null) field for it regardless.

        widget.RaiseClick(); // Clicked!
    }
}

Notice what didn't change: widget.Click += handler; reads and behaves identically to any event you've written since lesson 105. The entire redesign — dictionary storage, locking, key lookup — is invisible from the outside. That's the point.

Analogy

A Hotel's Mailboxes, Not a House's Mailbox

A field-like event is a house with a single, permanently installed mailbox — simple, and fine for one household. A custom-accessor event backed by a shared dictionary is more like a hotel front desk: it doesn't build a physical mailbox slot for every one of its 500 possible guests up front. Instead, it keeps one shared filing system, and only creates an entry the moment a specific guest (an event) actually needs one. Guests who check the front desk for a room that never had mail (an event nobody subscribed to) find nothing there — not an empty box sitting around wasting space, but no box at all.

Under the Hood — The Lapsed Listener Problem, Precisely

Lesson 106 introduced the lapsed listener problem — a subscriber that never unsubscribes can never be garbage collected while the publisher is alive. With custom add/remove now fully demystified, you can see precisely, mechanically, why this happens, no matter which storage a class uses underneath:

WHY THE PUBLISHER'S INVOCATION LIST IS A STRONG REFERENCE
1. A DELEGATE INSTANCE HOLDS TWO THINGS: A METHOD POINTER AND A TARGET
2. THE PUBLISHER'S STORAGE — WHATEVER IT IS — HOLDS THAT DELEGATE
3. THE SUBSCRIBER OBJECT IS REACHABLE THROUGH THAT CHAIN

Custom accessors don't cause this problem, and they don't fix it either — it's a property of ordinary object reachability, completely independent of which storage mechanism an event uses. It's exactly as real for the dictionary-backed WidgetControl above as for the simplest field-like event from lesson 105.

The Standard Fix, and the Idea Behind Weak Events

The reliable, always-correct fix is exactly what lesson 106 already taught: explicit unsubscription. Every subscriber whose lifetime is shorter than the publisher's must call -= — typically inside Dispose() — at the moment its own lifetime ends. This requires no new machinery and works with every event you've ever written.

There's also a conceptual alternative worth knowing by name: a weak event pattern. The idea is to have the publisher's storage hold a weak reference to the subscriber instead of a strong one — a reference that doesn't, by itself, keep the object alive, and that the GC is allowed to null out once nothing else is holding the subscriber. Done correctly, a weak-event subscriber that's never explicitly unsubscribed still gets collected normally the moment the rest of the application is done with it; the publisher just quietly stops delivering to it. This is genuinely more complex to implement correctly than it sounds — you're managing WeakReferences, periodically pruning dead entries out of the storage, and being careful about how the delegate itself is captured — which is exactly why .NET's own WeakEventManager (used inside WPF) exists as a purpose-built helper rather than something application developers typically hand-roll. For most application code, explicit unsubscription remains the simpler, more predictable, and more commonly correct choice; reach for a weak-event pattern specifically when you cannot guarantee a deterministic unsubscription point (rare) rather than as a default habit.

Common Confusion

1. "Custom add/remove weakens encapsulation" — it's the opposite

It's easy to assume that writing your own accessors somehow opens a backdoor for external code. It doesn't — the C# compiler enforces exactly the same restriction (only +=/-= from outside the declaring class) regardless of whether the accessors are compiler-generated or hand-written. Custom accessors are strictly an internal implementation choice; the public contract from lesson 105 is untouched.

2. "A weak event pattern fixes the leak automatically, everywhere" — no, it's a deliberate opt-in technique

A weak-event pattern only exists where a class specifically implements it — EventHandler and a plain += subscription are strong by default, always, and nothing about C# or the CLR silently swaps in weak references for you. If you need weak-event behavior, you (or the framework you're using) must build it deliberately; it is not the default behavior of the event keyword.

Common Mistakes

Mistake 1 — Writing custom accessors without locking, in a class meant to be used from multiple threads

add => _handlers += value; with no synchronization, in a class where subscribers might come from multiple threads concurrently — this reintroduces the exact kind of race condition lesson 106 warned about, just at the storage level instead of the raise level.

Guard both add and remove (and the raise) with the same lock, exactly as shown in the "How It Works" section — this is the one piece of extra responsibility you take on the moment you write custom accessors instead of accepting the compiler-generated, already-thread-safe default.

Mistake 2 — Reaching for a shared-dictionary storage pattern for a class with two or three events

Adding the complexity of a keyed dictionary, locking, and custom accessors to an ordinary service class with one or two events "because it's the advanced way to do it."

Plain field-like events remain the right default for the overwhelming majority of classes. Reach for custom storage specifically when you have genuinely many possible events and know most instances will only use a handful — the UI-control scenario this lesson is built around, not a general-purpose habit.

Mistake 3 — Assuming a long-lived publisher's subscribers will "sort themselves out"

Subscribing a short-lived object to a long-lived (especially static or singleton-scoped) publisher and never calling -=, on the assumption that the GC will "figure it out eventually."

Treat every subscription to a longer-lived publisher as a resource that must be explicitly released, exactly like a file handle or a database connection — because structurally, it is one: a live reference that only your own code can release.

When Should I Use It?

Mental Model

A field-like event = compiler-written add/remove plus a dedicated field, chosen for you.
A custom-accessor event = you write add/remove, and you choose the storage — same external contract either way.
The lapsed listener leak = publisher → storage → delegate → subscriber, an ordinary strong-reference chain, regardless of storage choice.
The fix = explicit -= when the subscriber's lifetime ends, or a deliberately-built weak-reference storage as a conceptual alternative.

Key Takeaway


Check Your Understanding

You've seen what event compiles to by default, how to write your own accessors, and precisely why the lapsed listener leak happens. Let's confirm the details.

1. By default, what does public event Action? Something; compile to?

Show answer

Correct: B

Why B is correct: This is exactly what the compiler generates by default, and it's the mechanism that restricts external code to +=/-= — those operations call the generated add/remove methods, and nothing else is exposed.

Why A is incorrect: That describes a plain delegate field without event, which lesson 105 showed allows unrestricted assignment and invocation — exactly what event prevents.

Why C is incorrect: Nothing about a field-like event requires abstraction or subclass implementation — it's a fully concrete, self-contained mechanism.

Why D is incorrect: An event is explicitly a subscription point — that's its entire purpose — not something with no way to subscribe.

Reinforcement: The private field plus generated add/remove is exactly why +=/-= are the only operations external code can perform.

2. A class writes its own custom add and remove accessors for an event, storing subscribers in a private Dictionary<string, Delegate> instead of a dedicated field. From outside the class, what changes compared to a plain field-like event?

Show answer

Correct: B

Why B is correct: Custom accessors are purely an internal implementation detail. The compiler enforces the same external restriction — only +=/-= — regardless of whether the accessors are hand-written or compiler-generated, and regardless of what storage they use underneath.

Why A is incorrect: Direct assignment from outside the declaring class remains a compile error, exactly as with a field-like event — custom accessors don't loosen this.

Why C is incorrect: The event's public surface still exposes nothing beyond subscribe/unsubscribe — there's no built-in way to enumerate subscribers from outside, custom accessors or not.

Why D is incorrect: The declaring class can still raise the event through whatever storage it chose — custom accessors change storage and subscription logic, not the ability to raise.

Reinforcement: Custom accessors change internal implementation only — the external contract from lesson 105 is untouched.

3. Why does subscribing to a long-lived publisher's event and never unsubscribing prevent the subscriber from being garbage collected?

Show answer

Correct: B

Why B is correct: There's nothing special about the reference — it's an ordinary strong object reference, exactly like any field. It just happens to form a chain (publisher → storage → delegate → subscriber) that keeps the subscriber reachable as long as the publisher is.

Why A is incorrect: There's no special-casing in the CLR for event subscribers — the leak is a completely ordinary consequence of standard reachability-based garbage collection.

Why C is incorrect: Delegates and their backing storage are ordinary managed objects on the normal .NET heap, fully visible to the garbage collector — the GC sees the reference perfectly well; it just correctly refuses to collect a reachable object.

Why D is incorrect: This is a real, well-documented .NET memory-leak pattern (the lapsed listener problem), not a misconception — it's specifically why lesson 106 and this lesson both emphasize explicit unsubscription.

Reinforcement: The lapsed listener leak is ordinary strong-reference reachability, not a special language or runtime behavior.

4. What is the core idea behind a weak-event pattern, as a conceptual alternative to explicit unsubscription?

Show answer

Correct: A

Why A is correct: This is exactly the conceptual idea described — a weak reference doesn't, by itself, keep an object alive, so a weak-event subscriber can still be collected normally, breaking the strong-reference chain that causes the lapsed listener leak.

Why B is incorrect: There's no automatic timeout-based unsubscription in .NET's event model or in the weak-event idea — the mechanism is about reference strength, not timing.

Why C is incorrect: A weak-event pattern is typically built on top of the ordinary event keyword and standard C# syntax externally — the difference is entirely in how the publisher stores and manages subscriber references internally.

Why D is incorrect: Weak-event patterns address the subscriber's collectability, not the publisher's — the publisher's own lifetime is a separate, unrelated concern.

Reinforcement: A weak-event pattern trades a strong reference for a weak one specifically to let the subscriber be collected without requiring explicit unsubscription.

You now know exactly what event compiles to, how to take control of it with custom accessors, and precisely why the lapsed listener leak happens at the reference level. Next: pulling events out further still — a shared aggregator that lets publishers and subscribers communicate with zero direct reference to each other at all.


dotnetmadeeasy.com — Learn C# and .NET, the right way.