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
nullwhen there are no subscribers, so?.Invokeis 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/protectedto 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
eventwhen multiple subscribers may come and go, and you want to prevent external invocation. - Use a plain delegate field or
Func/Actionproperty 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
CancellationTokenfor 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 eventlets derived types override add/remove, but the pattern is rare—prefer raising viaprotected virtual OnX. - Auto-property-like syntax (
public event Action Clicked;) is field-like; if you need to constrain who can subscribe, make the eventinternal/protected internaland 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.


