Fundamentals - Part 16

12/05/2025
4 minute read

Events are delegates with a guardrail

An event is a delegate field that only the declaring type can invoke. Subscribers can add or remove handlers, but they can’t overwrite the delegate or invoke it directly.

Field with a naked delegate (no guardrail)

public class Button
{
    public Action? Clicked; // anyone can assign or invoke
}

Same idea with an event

public class Button
{
    public event Action? Clicked; // only Button can invoke

    public void Raise() => Clicked?.Invoke();
}

var btn = new Button();
btn.Clicked += () => Console.WriteLine("Hi"); // allowed
btn.Clicked(); // compile error: cannot invoke
btn.Clicked = null; // compile error: cannot assign

Under the hood, the compiler still emits a delegate field, plus add_Clicked and remove_Clicked methods that wrap Delegate.Combine/Delegate.Remove. That is the default “field-like event” shape.


What is a field-like event?

“Field-like” means the compiler auto-generates the backing field and the simple add/remove logic. You write the short form; the compiler writes something close to this:

public class Button
{
    private Action? _clicked;

    public event Action? Clicked
    {
        add => _clicked = (Action?)Delegate.Combine(_clicked, value);
        remove => _clicked = (Action?)Delegate.Remove(_clicked, value);
    }

    protected void OnClicked() => _clicked?.Invoke();
}

Key implications:

  • Invocation stays inside the declaring type (or derived types if the event is protected).
  • Subscription is thread-safe for reference updates (the combine/remove uses atomic semantics), but your invoke usually is not; copy to a local before invoking to avoid race conditions.
  • The backing delegate is null when there are no subscribers, so ?.Invoke is the common idiom.

Custom event accessors (the “other way”)

Sometimes you need more control: weak references, logging, filtering duplicates, or forwarding to another source. You can write explicit add/remove accessors and skip the autogenerated backing field.

public class EventHub
{
    private readonly List<EventHandler> _handlers = new();

    public event EventHandler SomethingHappened
    {
        add
        {
            if (!_handlers.Contains(value)) _handlers.Add(value);
        }
        remove => _handlers.Remove(value);
    }

    public void Raise(object? sender, EventArgs args)
    {
        // snapshot to avoid mutation during invoke
        foreach (var handler in _handlers.ToArray())
        {
            handler(sender, args);
        }
    }
}

Notes when you go custom:

  • You must store handlers yourself (list, weak table, logger, whatever). There is no hidden field.
  • You are responsible for thread-safety around your storage and during invocation.
  • Accessors can be private/protected to narrow who can subscribe.
  • You can route add/remove to a different object, letting one type expose another type’s events.

Raising events correctly

The conventional pattern is On<EventName> plus EventHandler (or the generic form) so you get a consistent signature: void Handler(object? sender, EventArgs e).

public class Timer
{
    public event EventHandler? Tick;

    protected virtual void OnTick()
    {
        var handler = Tick; // copy to avoid race with -=
        handler?.Invoke(this, EventArgs.Empty);
    }

    public void RunOnce() => OnTick();
}

Why the local copy? Another thread could unsubscribe between the null check and the call, producing a NullReferenceException on _Tick.Invoke(). Copying to a local is cheap insurance.


Events vs delegates vs other patterns

  • Use an event when multiple subscribers may come and go, and you want to prevent external invocation.
  • Use a plain delegate field or Func/Action property when you need a single callback slot that the consumer fully controls.
  • Use IObservable<T>/IAsyncEnumerable<T> when you need composition (filter, throttle, retry) or async sequencing; you can often wrap an event into either pattern.
  • Use CancellationToken for cancellation rather than custom “Canceled” events; the BCL already has the right behavior.

Gotchas to remember

  • Instance events are per instance; static events live for the app domain and can cause memory leaks if you forget to unsubscribe.
  • Events participate in inheritance: virtual event lets derived types override add/remove, but the pattern is rare—prefer raising via protected virtual OnX.
  • Auto-property-like syntax (public event Action Clicked;) is field-like; if you need to constrain who can subscribe, make the event internal/protected internal and expose a narrower facade.
  • Do not expose events on mutable structs; copies lead to confusing subscription behavior.

Quick reference

Aspect Field-like event Custom event accessors
Storage Hidden delegate field Whatever you implement
Add/remove Compiler emits combine/remove You write logic (can log/filter/forward)
Who can invoke? Declaring type only Declaring type only (unless you call handlers elsewhere)
Thread safety Add/remove atomic; invoke is your job Entirely your job
Typical use UI controls, domain notifications Weak events, proxying, deduping

Final thoughts

  • Events are a thin, guarded layer over delegates.
  • “Field-like event” means the compiler gives you the backing field and simple add/remove; “the other way” is writing custom accessors.
  • Treat invocation as part of your API contract: copy handlers to a local, choose the right event args shape, and consider whether an observable/async stream fits better.
An error has occurred. This application may no longer respond until reloaded. Reload x