Last Updated: May 17, 2026
Delegates and events look almost identical at the declaration site. Both hold a chain of methods, both are invoked the same way, both use += and -= to add and remove handlers. The difference is who is allowed to do what to them from outside the class, and that difference is the whole reason event exists as a separate keyword. This lesson is the synthesis lesson for the section: a side-by-side comparison, a decision rule, and a tour of the bugs that show up when someone picks the wrong shape.
By now the pieces are familiar: a delegate type holds a reference to a method, a multicast delegate holds a chain of methods invoked in order, and an event is a delegate exposed through a pair of restricted accessors. The natural follow-up: when you want a class to publish notifications, should the field be a plain delegate or an event?
Both shapes compile. Both work. The two snippets below define the same conceptual hook on a Cart: a way for outsiders to subscribe to "an item was added" notifications.
From the inside, both classes raise the notification the same way: a null-conditional ?.Invoke(name). From the outside, a subscriber writes cart.ItemAdded += OnItemAdded; either way. The two snippets look interchangeable, and that is exactly the problem. The event version gives up almost nothing in convenience and gains a wall of protection that the plain delegate version cannot offer.
The rest of this lesson is about what that wall protects against.
The clearest way to see the gap is to write a small program that does the suspicious things on both. We will define two carts, hand each one to a subscriber, and then watch what an outsider can do to break the subscription.
Identical output, identical wiring, identical look. So far the two shapes are indistinguishable. The split shows up when a second outsider, not the original subscriber, tries to interact with the cart.
Imagine some other module getting a reference to cart1 (the delegate version). That module can do four things the original subscriber never expected:
Each of those lines compiles. The original subscribers (EmailNotifier, SmsNotifier) have no way to defend themselves; the cart handed out a writable field, and once you have a writable field, anyone holding the reference can do anything with it.
Now try the same four operations on cart2 (the event version):
Four lines, four compile errors. The event keyword tells the compiler "this thing exists to be subscribed to and unsubscribed from, and that is the only contract outsiders are allowed to use." The class that declared the event is still free to invoke, assign, or null it from inside its own type. Everyone else gets += and -= and nothing more.
That single restriction is most of what the event keyword does. The rest of the lesson is filling in the implications.
event Restricts That a Delegate Field Doesn'tThe previous section showed the four operations the event keyword blocks. Now look at them one at a time and understand what each one would actually break if it were allowed.
When a publisher declares an event, the contract is roughly "I'll raise this when something interesting happens." Subscribers count on that contract. They don't expect the event to fire because some unrelated module decided to call Invoke from the outside.
With a plain delegate field, anything that holds a reference to the publisher can raise the notification:
The subscriber has no way to tell the two notifications apart. The fake one came in through the same channel as the real one. If OrderTracker.StatusChanged were declared as an event instead, the external Invoke line would not compile, and the only path that can raise the notification is Ship, which is exactly what subscribers expected.
A subscriber that writes cart.ItemAdded += OnAdded thinks of it as adding itself to a list. The reality with a public delegate field is that anyone can replace the whole list with a single assignment:
EmailAlert is gone. The original += was overwritten the moment someone wrote = somewhere else. This is one of the most common production bugs in code that exposes raw delegate fields: a developer somewhere writes inv.StockLow = ... thinking they're initializing the field, and silently wipes everyone else's subscription.
Declaring StockLow as an event makes the = line a compile error from outside the class, which is the entire point. Inside the class, the declaring type is still allowed to write StockLow = null; to clear all subscribers, but that's an explicit decision made by the author of the type, not a mistake made by a caller.
The special case of replacement is = null, which sets the field to no subscribers at all:
This is the most catastrophic version of the previous bug. Every subscriber, including ones from completely unrelated modules, just vanishes. With an event, the line is illegal outside the declaring type. With a delegate field, the line silently destroys every subscription in the application.
The fourth operation, reading the multicast chain into a variable, is less destructive but still leaks an implementation detail. With a plain delegate field, an outsider can grab the Delegate and inspect, copy, or save it:
That code can introspect every subscriber, which usually isn't supposed to be public information. With an event, the read is a compile error outside the declaring type, so the subscriber list stays private. The declaring type can still read it internally, which is how the event raises notifications in the first place.
Cost: The compiler enforces the event restrictions at compile time, not at runtime. There is no runtime check happening when you call += or -=. The generated code adds and removes from the same underlying multicast delegate field; the difference is just which operations the rest of the program is allowed to use. Switching a delegate field to an event has zero runtime cost.
A capability-by-capability view of what each shape allows. "Public delegate field" means a field declared as public Action<...> Foo;. "Public event" means public event Action<...> Foo;. Each row asks: when external code holds a reference to the publisher, can it do this?
| Capability | Public delegate field | Public event |
|---|---|---|
Subscribe with += | Yes | Yes |
Unsubscribe with -= | Yes | Yes |
| Read the multicast chain | Yes | No (compile error CS0070) |
Replace the chain with = | Yes | No |
Clear with = null | Yes | No |
Invoke with (...) or .Invoke(...) | Yes | No |
Call .GetInvocationList() on it | Yes | No |
| Inspect subscribers via reflection | Yes (it's just a field) | Possible but harder (event has a backing field with a generated name) |
| Override or shadow in a derived class | Replace the field, but you're now hiding it | Override only if declared virtual; otherwise hide |
| Be raised from a derived class | Just access the field | No, even derived classes can't raise the parent's event directly. Pattern: parent exposes a protected virtual OnFoo() method that derived classes call. |
| Synchronize subscribe/unsubscribe | You write it yourself if needed | The compiler generates thread-safe add and remove accessors by default |
| Default initial value | null | null |
| How an internal "raise" looks | Foo?.Invoke(args) | Foo?.Invoke(args) (only legal inside the declaring type) |
| Memory layout | One field of delegate type | The same field, plus generated add/remove methods, plus access checks |
| Runtime cost per invocation | Identical | Identical |
| Most common bug | An outsider writes Foo = ... and wipes everyone | None of the comparable bugs apply, because the operations are illegal |
| Right choice when | The "subscribe-only" contract isn't needed (single callback, internal use, passed as a parameter) | The class exposes a hook for outside listeners and the publisher is the only one allowed to raise it |
The bottom rows are the most important. The runtime cost is identical, so the only thing you're trading is convenience versus encapsulation. Almost every time, encapsulation wins for public hooks. The exception is when you genuinely want a single overridable callback (a strategy parameter, a single hook on an object only you own), and in that case a delegate field, an Action, or a method parameter is the cleaner shape.
A picture of the two shapes side by side:
The diagram captures the asymmetry: with a delegate field, the publisher and the outsider have the same set of capabilities. With an event, the publisher keeps the full set, and the outsider is locked down to just subscribe and unsubscribe.
Knowing the differences in capability is half the picture. The other half is knowing which bugs follow from picking the wrong shape, and how the right shape eliminates them at the source.
The most frequent bug with a public delegate field: someone uses = instead of += and silently destroys every subscriber that came before. Easy to write, hard to spot in review, fatal in production.
The email and SMS subscribers are silently gone. Convert OrderShipped from a public delegate field into a public event, and the offending line hub.OrderShipped = msg => ... no longer compiles. The bug becomes impossible to introduce at all.
The other classic bug: outside code calling Invoke on a publisher's delegate field and faking a notification. Subscribers can't tell whether they received a real event or a forgery.
Both messages look the same to the subscriber. Switch the field to an event, and the second line stops compiling, because outside code is no longer allowed to invoke the event. Subscribers regain the guarantee that the only thing that can raise StockLow is Inventory itself.
This one is shape-agnostic; both delegate fields and events have it. When a subscriber registers and never unregisters, the publisher holds a reference to it, which keeps it alive in the GC's eyes for as long as the publisher is alive.
The fix is symmetric and applies to both shapes: pair every += with a -= when the subscriber is done. A common pattern is to do this in a Dispose method or a try/finally block so the unsubscription happens even if something throws.
This bug doesn't go away by switching to events. The event keyword protects you from outside misuse, not from your own forgetful subscriber. Long-lived publishers with short-lived subscribers are still a leak source, and weak event patterns or explicit unsubscribe is the answer either way.
Cost: Each subscription is a small object on the heap (a closure or a delegate, depending on what's subscribed). A million forgotten subscriptions is a million extra references the GC has to walk, plus a million objects it can't reclaim. The leak is rarely the size of the closures themselves, but the objects those closures capture (entire view-models, services, large data structures), which is what actually pins memory.
Both shapes have a subtle threading bug if you ever raise from a thread different from the one doing the subscription. Consider this naive raise:
Between the null check and the call, a different thread could unsubscribe the last handler and the delegate field could become null. The result is a NullReferenceException in the call itself. The fix is the field-copy or null-conditional pattern:
?.Invoke reads the field exactly once into a hidden local, checks it for null, and then invokes the local. Other threads can change the field afterward, but the local copy is stable. This is one of the small reasons modern C# code prefers ?.Invoke(...) over the older if (Foo != null) Foo(...) style.
The event keyword does not save you from this bug. It's a publisher-side concern that applies whether you used event or a plain delegate. Use ?.Invoke consistently and the race goes away.
Multicast delegates invoke their handlers one at a time, in the order they were added. If one handler throws, the rest of the chain never runs. This isn't a "delegate vs event" difference (both shapes have it, because both ride on the same multicast machinery), but it shows up in event-driven designs and is worth knowing about.
The SMS subscriber never received the notification. The middle subscriber threw, and the exception propagated up through the multicast machinery, skipping every handler that hadn't run yet.
The fix when you actually need every subscriber to fire is to enumerate the invocation list manually and catch around each call:
This pattern isn't free (it iterates the list and catches one by one), but it's the standard recipe for "all subscribers must run regardless of what any one of them does." Most application code can ignore the subtlety; framework code and infrastructure publishers usually need to handle it.
After all the syntax and rules, the decision in practice comes down to two questions:
event. The whole point of the keyword is to let outsiders subscribe without letting them raise, replace, or wipe the chain.Action, Func, Predicate, or a method parameter) is the cleaner shape.The decision flowchart:
A few concrete cases to make the rule less abstract:
| Scenario | Right shape | Why |
|---|---|---|
A Cart publishes "item added" notifications to any subscriber (logger, analytics, UI). | event | Multiple outside listeners; only the cart should raise. |
An Order.Process(Action<string> onProgress) method takes a single progress callback. | Delegate parameter | One caller, one callback, passed in for the duration of the call. |
A RetryPolicy object holds a ShouldRetry predicate that callers set once. | Predicate<T> field | Single strategy, owned by the same module that constructs the policy. |
An Inventory raises "stock low" events that an outside Notifier listens to. | event | Outside subscriber, only the inventory should raise. |
A LINQ-style helper takes a Func<T, bool> filter. | Func<T, bool> parameter | The function is data passed to the method, not a notification. |
A custom UI control exposes a Clicked notification. | event | The canonical event use case: outside listeners, publisher-only raise. |
The pattern in the table is consistent: if the question is "who can subscribe?" and the answer is "anyone who wants to listen," use event. If the question is "what callback should this method use?" or "what strategy does this object hold?", use a plain delegate.
One more piece of advice worth repeating: when in doubt, use event. The runtime cost is identical, and the protection is real. Converting an event to a plain delegate field later is a one-line change; converting a delegate field to an event after it's been misused for months is a refactor.
event keyword is a compile-time access check that restricts outside callers to += and -= only.event blocks four operations from outside the declaring type: invocation, assignment, clearing with = null, and reading the multicast chain. The most common bug a public delegate field allows is the accidental wipe (pub.Foo = handler instead of pub.Foo += handler), which an event makes impossible.field?.Invoke(args) rather than the older if (field != null) field(args) pattern. The ?.Invoke form reads the field once into a hidden local and is race-safe; the two-line form has a null-deref race when another thread can unsubscribe between the check and the call.handler.GetInvocationList() and wrap each call in try/catch. This is shape-agnostic; both delegate fields and events have it.+= with a -= (commonly in Dispose) when the subscriber's lifetime is shorter than the publisher's.event when a class exposes a hook for outside listeners and only the class itself should raise it. Use a plain delegate (parameter, Action, Func, Predicate, or private field) for one-shot callbacks, single-strategy fields, and any case where the encapsulation an event provides isn't needed.event. The cost is zero, the protection is real, and converting an event back to a plain delegate field later is a one-line change. Converting a misused delegate field into an event after months of bugs is a refactor.The Exception Handling section switches focus from event-driven communication to error-driven control flow. Many of the threading and "what runs when" subtleties from this section reappear in different forms, especially around exceptions that propagate out of subscribers and exception filters that decide which catch block wins. The encapsulation instincts you built here ("only the declaring type should be allowed to do this") will guide the design of custom exception types just as they guided the choice between delegate and event in this section.