A multicast delegate with the doors locked — subscribers can only add and remove themselves, never wipe out everyone else or ring the bell from outside.
Back in lesson 097, you learned that += on a multicast delegate adds a subscriber, and that anyone holding the delegate can invoke it. Now imagine that delegate is a public field on a class you don't own:
public class Button
{
public Action OnClick; // just a plain public delegate field
}
Anyone with a reference to a Button can do myButton.OnClick = null;, silently deleting every other handler that was subscribed. They can also do myButton.OnClick();, faking a click from completely outside the button — nothing stops them. A plain delegate field gives away far more control than "notify me when something happens" should ever require. The event keyword exists to close that gap.
An event is a controlled way for a class to announce "something happened" to whoever is interested — without letting those interested parties interfere with each other, or trigger the announcement themselves. It's built directly on top of the multicast delegates you already know, with one crucial restriction added.
The event keyword, placed before a delegate-typed field declaration, restricts what code outside the declaring class is allowed to do with it: external code may only use += (subscribe) and -= (unsubscribe). It may not assign with a bare =, and it may not invoke the delegate directly. Only code inside the declaring class retains full access — including the ability to raise (invoke) it.
public class Button
{
public event Action? OnClick; // now protected — only Button itself can invoke or reset this
}
obj.Field = null; — wipes all subscribersobj.Field(); — invokes from outsideevent-Qualified Field+= / -= allowed= or invoke itA plain public Action OnSomethingHappened; field looks like a subscription point, but it's actually a fully open door. Every one of these is legal, and every one of them is a bug waiting to happen in a large codebase with many subscribers:
obj.OnSomethingHappened = SomeHandler; — replaces (deletes) every existing subscriber instead of adding one, because = is a plain assignment, not +=.obj.OnSomethingHappened = null; — silently unsubscribes everyone at once.obj.OnSomethingHappened(); — external code fakes the notification, even though nothing actually happened.The event keyword narrows what's exposed outside the class down to exactly two operations: subscribing and unsubscribing. Assignment with = and direct invocation both become compile errors from outside the declaring class — the compiler enforces this, it's not just a naming convention or a guideline developers have to remember to follow.
event doesn't give you a new communication mechanism — the underlying delegate and its invocation list work exactly as covered in lesson 097. What event adds is encapsulation: the declaring class stays the only one allowed to decide when the announcement actually happens.
public event EventHandler<int>? ThresholdReached;
counter.ThresholdReached += (sender, value) => Console.WriteLine($"Reached {value}!");
ThresholdReached?.Invoke(this, currentValue); // "this" — inside the class itself
?. avoids a NullReferenceException if nobody has subscribed yet.using System;
public class Counter
{
public event Action<int>? ThresholdReached; // event, not a plain delegate field
private int _count;
public void Increment()
{
_count++;
if (_count == 5)
ThresholdReached?.Invoke(_count); // only Counter itself can do this
}
}
class Program
{
static void Main()
{
var counter = new Counter();
counter.ThresholdReached += value => Console.WriteLine($"Handler A: reached {value}");
counter.ThresholdReached += value => Console.WriteLine($"Handler B: reached {value}");
for (int i = 0; i < 5; i++)
counter.Increment();
// counter.ThresholdReached = null; // compile error — not allowed from outside
// counter.ThresholdReached(5); // compile error — not allowed from outside
// Handler A: reached 5
// Handler B: reached 5
}
}
Code → Meaning → Result: Both handlers subscribe with += and both run when Counter itself decides to raise the event, in the order they subscribed. The two commented-out lines are exactly what a plain public Action<int> field would have allowed — event turns both into compile-time errors.
The standard .NET signature for events is EventHandler (no data beyond "it happened") and its generic counterpart EventHandler<TEventArgs> (carries event-specific data). Both follow a fixed shape: (object? sender, TEventArgs e) — the sender lets a handler subscribed to multiple objects tell which one raised the event.
using System;
public class StockTicker
{
public event EventHandler<decimal>? PriceChanged; // sender + a decimal payload
private decimal _price;
public void UpdatePrice(decimal newPrice)
{
_price = newPrice;
PriceChanged?.Invoke(this, newPrice); // "this" tells subscribers which ticker fired
}
}
class Program
{
static void Main()
{
var ticker = new StockTicker();
ticker.PriceChanged += (sender, price) =>
{
var source = (StockTicker)sender!;
Console.WriteLine($"Price updated to {price:C}");
};
ticker.UpdatePrice(142.50m); // Price updated to $142.50
ticker.UpdatePrice(145.10m); // Price updated to $145.10
}
}
EventHandler<decimal> here is really just a delegate type shaped like Action<object?, decimal> — nothing exotic, just a standard, recognizable signature every .NET developer knows on sight. The next lesson formalizes this further with a dedicated EventArgs subclass instead of a bare decimal, which is the pattern you'll actually see throughout the .NET ecosystem.
A plain delegate field is like a doorbell wired so that anyone passing by can not only press the button, but also rewire it or rip the whole thing off the wall. An event is the same doorbell with a locked cover: visitors can press it (subscribe/unsubscribe their own reaction to hearing it), but only the homeowner — the class that installed it — can actually make it ring, and nobody outside can disconnect the wiring for everyone else.
A field-like event (the plain public event Action? Something; style shown so far) compiles into three things: a private backing delegate field (holding the same kind of invocation list from lesson 097), plus a compiler-generated public add accessor and remove accessor. The += and -= syntax you write is translated into calls to those accessors, which internally call Delegate.Combine and Delegate.Remove — the exact operations from lesson 097 — usually guarded by a lock to make concurrent subscribe/unsubscribe calls from multiple threads safe. Everything about the invocation list itself — order, multicast behavior, target-plus-method-pointer entries — is unchanged from ordinary multicast delegates; event only changes what's publicly accessible, not how invocation works.
event is a modifier applied to an existing delegate-typed field declaration (Action, Func, EventHandler, or a custom delegate) — it doesn't introduce a new kind of delegate. public event Action? OnClick; still uses the ordinary Action delegate type from lesson 098; event only restricts what external code can do with that field.
A field declared as public Action<T> SomeField; and one declared as public event Action<T> SomeField; can look nearly identical at the declaration site, but external code's capabilities are completely different — full read/write/invoke access versus subscribe/unsubscribe only. Always reach for event when the field's purpose is genuinely "notify me," not "let external code fully control this delegate."
ThresholdReached(_count); — if nobody has subscribed, the event field is null, and this throws a NullReferenceException.
Always raise with ThresholdReached?.Invoke(_count);. It's the idiomatic, safe way to raise any event that might currently have zero subscribers.
Writing someOtherObject.SomeEvent(); from outside someOtherObject's class, expecting it to behave like calling any other delegate — this simply doesn't compile.
If external code genuinely needs to trigger behavior, the declaring class should expose a proper public method (like Increment() in the example above) that does its own work and then raises the event internally — external code never invokes the event field itself.
EventHandler / EventHandler<TEventArgs> over a bare Action-based event for anything beyond quick, internal, single-project use — it's the shape every other .NET developer already recognizes.Action passed once, directly, to one specific caller) is all you need — as in the retry helper from lesson 098 — you don't need event at all; it's specifically for the "many potential subscribers, publisher controls raising" scenario.event = a multicast delegate field + "outsiders may only += / -=."= or invoke it directly.event keyword restricts external access to += and -= only; assignment and invocation stay private to the declaring class, enforced by the compiler.SomeEvent?.Invoke(...) — to avoid a NullReferenceException when there are no subscribers.EventHandler and EventHandler<TEventArgs> are the standard shapes for events across .NET — (object? sender, TEventArgs e).event changes accessibility, not invocation mechanics.You've seen why raw delegate fields are dangerous, and how event fixes it. Let's confirm the details.
1. What can external code do to a field declared as public event Action? Something;, from outside the declaring class?
Correct: A
Why A is correct: This is exactly what the event keyword restricts external code to — subscribing and unsubscribing. Everything else (assignment, invocation) remains available only inside the declaring class.
Why B is incorrect: Direct assignment with = from outside the declaring class is a compile-time error — that's precisely the capability event removes.
Why C is incorrect: This describes a plain public delegate field, not an event-qualified one — the whole point of event is to prevent this.
Why D is incorrect: Subscribing from outside is exactly what events are designed to allow — that's the entire purpose of exposing one publicly.
Reinforcement: +=/-= only, from the outside — that's the whole restriction event adds.
2. Why should you raise an event using SomeEvent?.Invoke(...) instead of SomeEvent(...)?
Correct: B
Why B is correct: An event field with no subscribers is null by default. Invoking a null delegate directly throws; the null-conditional operator checks for null first and skips the call safely if there are no subscribers.
Why A is incorrect: There's no meaningful performance difference — the reason to use it is correctness, specifically avoiding a crash, not speed.
Why C is incorrect: SomeEvent(...) is valid syntax from inside the declaring class — it's just unsafe when there might be zero subscribers.
Why D is incorrect: ?.Invoke only guards against a null delegate — it does nothing about exceptions thrown from inside a subscriber's handler.
Reinforcement: Always assume an event might have zero subscribers when you raise it, and guard accordingly.
3. What is the standard delegate shape used by EventHandler<TEventArgs>?
Correct: B
Why B is correct: The standard .NET event pattern always includes the sender (so a handler subscribed to multiple sources can tell which one raised it) alongside the event-specific data.
Why A is incorrect: This omits the sender parameter, which is part of the standard convention — missing it makes the handler unable to identify the source when subscribed to multiple publishers.
Why C is incorrect: Event handlers are void-returning — like Action, not Func — since raising an event isn't asking subscribers to compute and return a value.
Why D is incorrect: This describes a plain Action, not EventHandler<TEventArgs>, which always carries at least the sender and the event args.
Reinforcement: Recognize the (sender, args) shape on sight — it's everywhere across .NET event-based APIs.
You now understand why events exist and how they lock down a multicast delegate safely. Next: the full standard event pattern — custom EventArgs, the protected virtual OnXxx raiser method, and two real gotchas: memory leaks and thread safety.
dotnetmadeeasy.com — Learn C# and .NET, the right way.