Last Updated: May 22, 2026
An event is a controlled way for one object to broadcast that something happened, and for any number of other objects to listen for it. The mechanism sits on top of delegates, but the event keyword adds a wall around the field so that outside code can only subscribe and unsubscribe, never overwrite the list or fire the notification themselves. This lesson covers how to declare an event on a class, how to subscribe and unsubscribe, how to raise the event safely, and what event actually buys you that a plain delegate field doesn't.
A Cart class needs to tell other parts of the program when an item is added. The pricing engine wants to recompute the subtotal. The UI wants to refresh the cart icon badge. An analytics module wants to log the event. A "you might also like" recommender wants to update its suggestions.
The naive way is to bake the list of interested parties into the cart:
That AddItem method now knows about pricing, the UI, analytics, and recommendations. Every time another team wants to react to an "item added" event, they have to edit CartNaive and add another call. The cart, which should care only about its own list of items, has become a switchboard for the rest of the application.
The cleaner shape is to invert the relationship. The cart shouldn't know who cares. It should publish a notification, and whoever wants to react should subscribe. The cart can then run the same code regardless of whether zero, one, or twenty things are listening.
The publisher holds a list of callbacks. When something happens, it walks the list and invokes each one. Subscribers come and go without the publisher caring or noticing.
C# already has a tool for "a list of callbacks": a multicast delegate, where a delegate variable can hold many target methods and invocation calls every one. An event is a delegate field with a guardrail bolted on top, so that subscribers can add and remove themselves but cannot replace the list or fire the notification on the publisher's behalf.
An event needs a delegate type to describe the shape of the callbacks. We'll use a custom-declared delegate so the type is concrete. The EventHandler family in the BCL is the more common convention in real-world code.
Three things make the event work. The delegate CartItemAddedHandler declares the signature any listener must match: takes a string and a decimal, returns void. The Cart class then declares public event CartItemAddedHandler? ItemAdded, which is a field-like event: the compiler synthesizes a private delegate field plus an add accessor and a remove accessor. Outside callers can write cart.ItemAdded += handler and cart.ItemAdded -= handler, and nothing else.
The ? on CartItemAddedHandler? is important. The event field starts as null (no subscribers), and you have to acknowledge that in nullable reference type contexts. Calling Invoke on a null delegate would throw NullReferenceException, which is why we use the null-conditional ?.Invoke(...) in the raise line.
A few rules that follow from the declaration:
ItemAdded, Saved, Closing, LowStockDetected.public event is the usual choice. protected event lets only the class and its subclasses listen.Each subscriber on a multicast delegate adds one entry to the invocation list. Invoking the event walks the list in order, calling each subscriber on the same thread that raised the event. A subscriber that throws or runs slowly will stall every subscriber after it. Keep handlers small and well-behaved.
Subscribing means attaching a handler so it runs when the event fires. Unsubscribing means detaching that same handler. Both happen through += and -= on the event member.
A handler can be a method, a lambda, or any delegate of the right type. Each registration adds another callback to the invocation list. The order subscribers run in matches the order they subscribed.
Both subscribers ran, in the order they subscribed. The publisher didn't know how many were listening or what they would do; it just invoked the event and let the runtime walk the list.
Unsubscribing uses -= and the same handler reference. The runtime compares delegates by target and method, so the handler you pass to -= must point to the same method on the same instance as the one passed to +=.
The first Restock call ran the logger because it was on the invocation list. After -=, the list was empty, and the second Restock raised the event onto a null delegate, which the ?.Invoke(...) pattern in the publisher handled gracefully (it simply did nothing).
An important detail: -= only works when the same delegate can be recreated. With a method group like pricing.OnStockChanged, that's easy: the runtime can compare the target object and method by identity. With a lambda, the lambda reference must be kept and passed back. Writing the lambda inline at the -= site creates a fresh delegate, which is not equal to the original and therefore doesn't remove anything.
The second lambda is a new delegate. The compiler doesn't see "same source code" and treat them as equal. The fix is to assign the lambda to a variable and use that variable for both += and -=.
Each += and -= on an event is cheap individually (allocate a new invocation list, swap it in via Interlocked.CompareExchange). In a tight loop they add up, and the allocations are visible to the GC. Subscribing and unsubscribing thousands of times per second suggests a design problem; consider a custom callback list or a single long-lived handler that filters.
The publisher's job during AddItem is to fire the event. That sounds simple, but there are two specific traps to handle: the event might have no subscribers (the field is null), and the list of subscribers might change between when you check for null and when you invoke.
The simple, modern raise pattern is the null-conditional invoke:
?.Invoke(...) reads the field once, checks the result for null, and invokes the captured value if it's non-null. The "read once" part is what makes this safe under concurrency: if another thread unsubscribes the last handler in between, the local copy you already captured is still a valid (non-null) delegate. The unsubscribe affects the field for future reads, but not the snapshot you're holding.
That pattern is shorthand for what older C# code wrote explicitly:
You'll still see the longhand form in older codebases or when there's logic between the check and the invoke. The ?.Invoke(...) shorthand was introduced in C# 6 and has been the default style ever since. Both versions compile to roughly the same thing.
The pattern to avoid is reading the field twice:
In a multi-threaded program, another thread can call cart.ItemAdded -= handler between the two reads. If that -= removes the last subscriber, the field becomes null, and the second read inside ItemAdded(...) throws NullReferenceException. Single-threaded programs don't hit this, but the pattern is still wrong because it reads the field twice for no reason.
A complete example with two subscribers, an unsubscribe, and a raise:
ProductRestocked always runs the same code regardless of how many notifiers are registered. The first call fires the event to two subscribers; the second call fires it to one; if all subscribers unsubscribed, the same BackInStock?.Invoke(...) line would do nothing and not throw.
The flow from a publish to all subscribers, with the raise pattern in the middle, looks like this:
The "snapshot then null-check" step is exactly what ?.Invoke(...) compresses into one operator. The "runtime walks invocation list" step is multicast delegate behavior; events are multicast underneath, so adding several subscribers fires all of them in subscription order.
One detail worth flagging is the convention that only the declaring class can raise an event. From outside the class, the event member exposes += and -=, but not invocation. Even a subclass cannot raise an event declared on the base class directly. If a subclass needs to fire its parent's event, the parent should provide a protected helper:
The OnItemAdded protected method (named with the On + event name convention) is the standard escape hatch. It centralizes the raise logic so subclasses don't need direct access to the underlying field.
event Restricts (vs a Public Delegate Field)You might wonder why the language needs the event keyword at all. A public delegate field has roughly the same shape: it can hold many subscribers (because delegates are multicast), and outside code can write obj.Field += handler to add and obj.Field -= handler to remove. So what does event actually prevent?
It prevents three specific things outside code from doing. To see them, compare the two declarations side by side:
From the outside, the two look similar. Both support += and -=. The difference shows up when outside code tries to do anything else:
| Operation from outside the class | Public delegate field | event |
|---|---|---|
obj.Subscribers += handler | Allowed | Allowed |
obj.Subscribers -= handler | Allowed | Allowed |
obj.Subscribers = handler (overwrite list) | Allowed | Compile error |
obj.Subscribers = null (clear all subscribers) | Allowed | Compile error |
obj.Subscribers.Invoke(msg) (raise the event) | Allowed | Compile error |
obj.Subscribers(msg) (raise via call syntax) | Allowed | Compile error |
obj.Subscribers.GetInvocationList() (enumerate subscribers) | Allowed | Compile error |
The compile errors are the interesting column. Here's what each of them would look like with a plain delegate field, and why each one is dangerous:
Each one is a real hazard in a shared codebase. The "overwrite" case is the classic plain-delegate trap: a careless = instead of += silently removes every other subscriber in the program, with no compiler warning. The "raise from outside" case lets unrelated code spoof events that should only be fired by the publisher, which breaks the trust model that subscribers rely on.
The event keyword makes all of those operations compile errors:
Both errors say the same thing in different words: outside the type, the only thing you can do with an event is subscribe or unsubscribe. Inside the type, the event behaves like a plain delegate field: the publisher can read it, null-check it, invoke it, even reassign it. The asymmetry is the entire point.
Mechanically, this works because event is not a field at all from the outside. The compiler generates a hidden field plus two accessor methods, add_Subscribers and remove_Subscribers. Writing += from outside calls add_Subscribers; writing -= calls remove_Subscribers. There is no get_Subscribers to read the field, so every other operation has nowhere to bind.
Outside code goes through accessors that can only modify the list. The publisher class talks to the field directly and can do anything with it, including invoke. That separation is what events buy you over a plain delegate field, and it's why the convention in real C# code is to expose multi-subscriber callbacks as events rather than as public delegates.
For now, the rule of thumb is: if you're modeling "one publisher tells many listeners that something happened," use event. If you're modeling "give me a single callback to invoke as a strategy," use a delegate parameter or property.
Events have one error-prone behavior that surprises many developers on first encounter. When subscriber A registers a handler with publisher B, B starts holding a reference to A through the delegate's target field. As long as B is alive and the subscription is in place, the garbage collector cannot reclaim A, even after every other reference to A has gone away.
This is fine when A's lifetime matches B's lifetime, which is the common case (a service subscribes to another service for the lifetime of the program). It's a leak when A is meant to be short-lived and B is long-lived, because A stays pinned in memory until B drops the subscription or B itself becomes unreachable.
A concrete example: a long-lived WishlistService notifies short-lived CartView objects when a wishlisted product comes back in stock.
A page lifecycle would create a CartView, show it, then navigate away. The local variable holding the CartView goes out of scope. In a normal world the GC would collect it. With the event subscription in place, the WishlistService still has a delegate whose target is the CartView, so the cart view stays alive. Every navigation leaks one CartView. Over hours of use, memory grows without bound.
The relationship in memory looks like this:
The page-level reference is gone, but the long-lived service still reaches the cart view through its event's invocation list. The GC traces the live graph, sees the cart view as reachable, and keeps it alive. The cart view's fields, plus anything those fields reference, all stay pinned.
The fix is to unsubscribe explicitly when the subscriber is done. For a CartView with a clear "done" point, that's a single line:
The page calls Detach() (or the equivalent) when it's done with the view, which removes the handler from the service's invocation list, which removes the last reference to the view, which lets the GC collect it.
For classes that already implement IDisposable, the convention is to unsubscribe in Dispose. That gives callers a single, well-known method to call, and it integrates with using statements when the lifetime is scoped:
A demo of the leak and the fix side by side:
The clean view's Dispose removed its handler on the way out. The leaky view's handler is still in the invocation list, and the service still holds a reference to the leaky view, even though the local variable leaky will eventually go out of scope. In a real long-running program, those leaked subscribers accumulate.
A forgotten subscription keeps the subscriber object (and everything it references) alive for the lifetime of the publisher. For a long-lived publisher like a global service, "for the lifetime of the publisher" usually means "for the lifetime of the process." Always pair subscribe with unsubscribe, and prefer IDisposable or a clear Detach method on subscribers that have a bounded lifetime.
The complement to this rule is that unsubscription isn't always required. If the subscriber and publisher have the same lifetime (both live for the whole program, both are torn down at the same shutdown), the "leak" is permanent regardless, so unsubscribing is unnecessary cleanup. The rule that matters is whether the subscriber outlives its usefulness while the publisher keeps it pinned, not whether += was ever called.