Events in C
A deep-dive walkthrough of events in C# — covering how events are built on top of delegates, the standard .NET event pattern (sender, EventArgs), publishing and subscribing mechanics, why events restrict what a delegate would otherwise allow, custom accessors and what actually happens under the hood, thread safety and the memory-leak risk of forgotten subscriptions, and how modern C# (weak event patterns, IObservable<T>) responds to those event-specific pitfalls.
Table of Contents
- Introduction
- What an Event Actually Is
- Declaring and Raising an Event
- Subscribing and Unsubscribing
- The Standard .NET Event Pattern: Sender and EventArgs
- Why Events Restrict What a Plain Delegate Allows
- What the Compiler Actually Generates
- Custom Accessors: add and remove
- The Null-Check-Before-Invoke Pattern, and Why It Matters
- Thread Safety When Raising Events
- The Memory Leak Risk: Forgotten Subscriptions
- Events vs. Delegates vs. Interfaces: Choosing the Right Tool
- Events in Modern C#: IObservable<T> and Reactive Extensions
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
An event in C# is a notification mechanism — a way for an object to announce "something happened" to any number of other objects that have expressed interest, without needing to know in advance who those subscribers are or what they'll do in response. Mechanically, an event is a delegate (this series' Delegates guide covers that foundation in depth), but with a restricted public surface specifically designed to make the publish/subscribe relationship safe: subscribers can only attach or detach their own handlers, never invoke the event directly or wipe out every other subscriber's handler by accident. This guide assumes familiarity with delegates and builds directly on top of that foundation, covering the standard .NET event pattern, what actually happens under the hood, and the thread-safety and memory-leak pitfalls that are specific to events rather than delegates in general.
public event EventHandler<OrderPlacedEventArgs> OrderPlaced;
Subscriber A: orderService.OrderPlaced += HandleOrderPlacedForShipping;
Subscriber B: orderService.OrderPlaced += HandleOrderPlacedForEmail;
Subscriber C: orderService.OrderPlaced += HandleOrderPlacedForAnalytics;
OrderService (internally): OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(orderId));
→ all three subscribers' handlers run, in the order they subscribed
1. What an Event Actually Is
An event is a delegate field, with event restricting its public API
public class Button
{
public event Action Clicked; // this IS a delegate — specifically, an Action-typed one
}
Underneath, Clicked is exactly the same kind of thing this series' Delegates guide covers in depth — a reference (or references, since events support multicast) to one or more methods matching a given signature. The event keyword doesn't create a fundamentally different runtime mechanism; it changes what operations are legally available to code outside the declaring class, which Section 5 covers as the entire reason event exists as a distinct feature rather than developers just using plain public delegate fields everywhere.
The core relationship, stated directly
Delegate: a type-safe reference to one or more methods — general-purpose,
can be assigned, invoked, and multicast from anywhere it's accessible.
Event: a delegate field with a restricted public surface (+= and -= only,
from outside the declaring class) — purpose-built for publish/subscribe
notification, where the publisher must retain exclusive control over
when the notification actually fires.
Every event is built on a delegate; not every delegate is (or should be) an event. Section 11 returns to this distinction with concrete guidance on which to reach for.
2. Declaring and Raising an Event
Declaring an event with a delegate type
public class Button
{
public event Action Clicked;
public void SimulateClick()
{
Clicked?.Invoke(); // "raising" the event — Section 8 covers the null-check in detail
}
}
Clicked is declared using the built-in Action delegate type (per this series' Delegates guide's Section 6) — any parameterless, void-returning method can subscribe. SimulateClick() is the method that actually raises (fires, triggers) the event — conventionally, this happens from inside the declaring class only, which is precisely what event enforces at the language level.
Raising an event with a custom delegate type
public delegate void PriceChangedHandler(decimal oldPrice, decimal newPrice);
public class Product
{
public event PriceChangedHandler PriceChanged;
private decimal _price;
public decimal Price
{
get => _price;
set
{
var oldPrice = _price;
_price = value;
PriceChanged?.Invoke(oldPrice, value); // raise the event with the relevant data
}
}
}
An event can be built on any delegate type, not just Action or EventHandler — here, PriceChangedHandler carries both the old and new price directly as parameters. This works, and it's valid C#, but Section 4 covers why the vast majority of real .NET code uses a specific, conventional shape instead of ad hoc parameter lists like this one.
3. Subscribing and Unsubscribing
+= to subscribe, -= to unsubscribe — the only two operations available from outside
var button = new Button();
void OnButtonClicked() => Console.WriteLine("Button was clicked!");
button.Clicked += OnButtonClicked; // subscribe
button.SimulateClick(); // "Button was clicked!"
button.Clicked -= OnButtonClicked; // unsubscribe
button.SimulateClick(); // nothing happens — the handler is no longer attached
This is the entire public contract an event exposes to outside code: attach a handler, detach a handler. As this series' Delegates guide's Section 5 covers for multicast delegates generally, += appends to an internal invocation list rather than replacing it, and -= removes a specific entry — both operations here behave identically to a plain multicast delegate; what's different is that = (outright replacement) and direct invocation are unavailable to this outside code, which Section 5 covers as the actual point of using event.
Multiple subscribers, and the order they're invoked in
button.Clicked += () => Console.WriteLine("Handler 1");
button.Clicked += () => Console.WriteLine("Handler 2");
button.Clicked += () => Console.WriteLine("Handler 3");
button.SimulateClick();
// Handler 1
// Handler 2
// Handler 3
Subscribers are invoked in the order they were added — this is a genuinely important detail to know, since it means the order of += calls in your code has real, observable runtime consequences, and a bug where subscriber ordering matters (one handler depends on side effects from another running first) is worth watching for rather than assuming subscribers are somehow independent or unordered.
4. The Standard .NET Event Pattern: Sender and EventArgs
The (object sender, TEventArgs e) shape, and why it became the convention
public class OrderPlacedEventArgs : EventArgs
{
public int OrderId { get; }
public decimal Total { get; }
public OrderPlacedEventArgs(int orderId, decimal total)
{
OrderId = orderId;
Total = total;
}
}
public class OrderService
{
public event EventHandler<OrderPlacedEventArgs> OrderPlaced;
public void PlaceOrder(int orderId, decimal total)
{
// ... place the order ...
OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(orderId, total));
}
}
EventHandler<TEventArgs> is a built-in generic delegate with the signature void EventHandler<TEventArgs>(object sender, TEventArgs e) — the first parameter is always the object that raised the event (this, from inside OrderService), and the second is a purpose-built class carrying whatever data is relevant to that specific event. This shape isn't a language requirement (as Section 2's PriceChangedHandler example shows, you can build an event on any delegate), but it's the overwhelming convention across .NET's own libraries and the vast majority of real-world C# code, and following it is what makes an event's API feel immediately familiar to any C# developer.
Why sender is useful: one handler, many publishers
void HandleOrderPlaced(object sender, OrderPlacedEventArgs e)
{
var service = (OrderService)sender; // recover which SPECIFIC instance raised this
Console.WriteLine($"Order {e.OrderId} placed via {service.GetType().Name}");
}
orderServiceA.OrderPlaced += HandleOrderPlaced;
orderServiceB.OrderPlaced += HandleOrderPlaced; // the SAME handler, subscribed to a different instance
Passing sender matters specifically when a single handler method is subscribed to the same event across multiple instances — without it, the handler would have no way to know which OrderService actually raised the event it's responding to; sender recovers that information without needing a separate closure or field per subscription.
Why a dedicated EventArgs subclass, rather than passing raw parameters
Per Section 2's PriceChangedHandler alternative: passing (decimal oldPrice,
decimal newPrice) directly works, but it means the delegate's SIGNATURE
itself has to change every time the event needs to carry one more piece
of data — a dedicated EventArgs class can gain new properties over time
without changing the event's delegate signature at all, which matters
for the same API-evolution reasons this series' Interfaces guide's
default-method discussion cares about not breaking existing subscribers.
Wrapping event data in a dedicated class rather than passing it as loose parameters is what lets an event's payload evolve — adding a new property to OrderPlacedEventArgs later doesn't require touching the event declaration or any existing subscriber's method signature, whereas adding a new parameter to a raw delegate signature (like PriceChangedHandler) would be a breaking change to every existing handler.
The non-generic EventHandler, for events that carry no meaningful data
public event EventHandler Clicked; // no generic parameter — just (object sender, EventArgs e)
Clicked?.Invoke(this, EventArgs.Empty); // EventArgs.Empty avoids allocating a new, meaningless instance
For an event that genuinely has no data to carry beyond "this happened" (a button click, with nothing else relevant), the non-generic EventHandler delegate and EventArgs.Empty (a cached, shared instance) are the conventional choice — still following the sender/args shape for consistency, even when the args themselves carry nothing.
5. Why Events Restrict What a Plain Delegate Allows
The danger of a plain public delegate field, revisited from first principles
public class Button
{
public Action Clicked; // a plain field — NOT an event
}
var button = new Button();
button.Clicked += () => Console.WriteLine("Handler A");
// Anywhere else in the codebase, with a reference to the same button:
button.Clicked = () => Console.WriteLine("Handler B"); // accidental (or careless) `=` instead of `+=`
// wipes out Handler A entirely — no warning, no error
button.Clicked?.Invoke(); // ANY code with a reference to `button` can trigger this directly,
// bypassing Button's own logic about WHEN this should actually fire
Both of these are real, common failure modes with a plain public delegate field — an accidental = silently discards every other subscriber's registration, and external code can invoke the "event" whenever it wants, which defeats the entire purpose of the publisher deciding when the underlying condition genuinely occurred.
event closes both gaps, by construction
public class Button
{
public event Action Clicked; // now an event
}
var button = new Button();
button.Clicked += () => Console.WriteLine("Handler A");
// button.Clicked = () => Console.WriteLine("Handler B"); // ❌ compile error from OUTSIDE the class
// button.Clicked?.Invoke(); // ❌ compile error from OUTSIDE the class
From any code outside Button itself, only += and -= are legal — = and direct invocation are compile errors. From inside Button, all of these operations remain fully available (which is exactly what SimulateClick() in Section 2 relies on) — event narrows the API for external consumers specifically, while leaving the declaring class's own internal flexibility completely intact.
6. What the Compiler Actually Generates
event is syntactic sugar over a private backing delegate field plus add/remove accessor methods
// What you write:
public class Button
{
public event Action Clicked;
}
// Roughly what the compiler generates (simplified):
public class Button
{
private Action _clicked; // PRIVATE backing field — this is the actual delegate storage
public event Action Clicked
{
add { _clicked += value; } // called for every external `+=`
remove { _clicked -= value; } // called for every external `-=`
}
}
This is the mechanical explanation for everything in Section 5: the compiler automatically generates a private backing field (never directly accessible from outside the class) plus two special accessor methods, add and remove, which are the only entry points external code can reach — = and direct invocation aren't disallowed by some special runtime check, they simply have no accessor that would make them possible from outside, the same way a property with only a get accessor makes external assignment impossible.
This is the same accessor pattern properties use, just for delegates instead of values
public class Account
{
private decimal _balance;
public decimal Balance { get => _balance; private set => _balance = value; } // get/set accessors
public event Action BalanceChanged
{
add { /* ... */ } // add/remove accessors — the event equivalent
remove { /* ... */ }
}
}
Worth recognizing the parallel explicitly: a property restricts what external code can do with a value (perhaps read-only, via get with no public set); an event restricts what external code can do with a delegate (+=/-= only, via add/remove, never direct assignment or invocation) — both are the compiler generating controlled access points around private state, just applied to two different kinds of state.
7. Custom Accessors: add and remove
Writing your own add/remove logic, instead of the compiler-generated default
public class TemperatureSensor
{
private EventHandler<double> _temperatureChanged;
private readonly object _lock = new();
public event EventHandler<double> TemperatureChanged
{
add
{
lock (_lock)
{
_temperatureChanged += value;
Console.WriteLine($"Subscriber added. Total: {_temperatureChanged?.GetInvocationList().Length ?? 0}");
}
}
remove
{
lock (_lock)
{
_temperatureChanged -= value;
}
}
}
}
The default, compiler-generated add/remove (Section 6) is sufficient for the overwhelming majority of events, but writing your own explicitly is a real, supported option — here used to add logging and explicit locking (Section 9 covers why locking matters) around every subscribe/unsubscribe operation, something the compiler-generated default doesn't provide automatically.
A genuine real-world reason to customize: storing handlers in a different structure entirely
public class EventAggregator
{
private readonly Dictionary<string, Action> _handlersByKey = new();
public event Action SomeEvent
{
add => _handlersByKey["SomeEvent"] = (_handlersByKey.GetValueOrDefault("SomeEvent") ?? (() => { })) + value;
remove => _handlersByKey["SomeEvent"] -= value;
}
}
Custom accessors are also how you'd implement an event whose subscriber storage genuinely isn't just "a private delegate field" — a dictionary-backed event aggregator (a common pattern in larger, decoupled UI or plugin architectures) is a real example where the default generated accessor wouldn't fit, and writing add/remove explicitly lets the event's public API (+=/-=) stay exactly the same for consumers, even though the storage underneath is entirely different.
8. The Null-Check-Before-Invoke Pattern, and Why It Matters
An event with zero subscribers is null, not an empty, harmless delegate
public class Button
{
public event Action Clicked;
public void SimulateClick()
{
Clicked(); // ❌ throws NullReferenceException if NOBODY has subscribed yet!
}
}
This is a genuinely common bug for developers new to events: an event field that has never had anything added to it via += is null, not some kind of empty, safely-invokable delegate — calling it directly, without checking, throws at runtime the moment there happen to be zero subscribers, which might work fine in testing (where a subscriber was always attached) and then fail in production the first time it isn't.
The idiomatic fix: the null-conditional operator
public void SimulateClick()
{
Clicked?.Invoke(); // if Clicked is null, this expression short-circuits and does nothing
}
?.Invoke() is the standard, idiomatic way to raise an event safely — if Clicked is null (no subscribers), the whole expression evaluates to null and nothing happens; if it's non-null, Invoke() is called normally. This single pattern is worth treating as close to mandatory for every event you raise, given how easy the alternative bug is to introduce and how deceptively fine it looks during development.
A subtler race condition the null-check alone doesn't fully close
// A theoretical race: Clicked could become null BETWEEN the null check and the Invoke call,
// if another thread unsubscribes the LAST handler at exactly the wrong moment.
// `?.Invoke()` is actually safe against this specific race because the compiler captures
// a LOCAL copy of the delegate reference before checking it — but it's worth understanding WHY
// this pattern is safe, not just memorizing it as a syntax requirement.
Worth knowing as a deeper detail: ?.Invoke() is specifically safe against the classic "check for null, then it becomes null before you use it" race condition, because the compiler evaluates Clicked once into a temporary local variable and performs both the null check and the invocation against that captured copy — this is a real, if subtle, reason ?.Invoke() is preferred over manually writing if (Clicked != null) Clicked();, which does not have this same safety guarantee under concurrent access.
9. Thread Safety When Raising Events
Why concurrent subscribe/unsubscribe calls are a genuine, real concern
Per Section 8's race condition note: `+=` and `-=` on a delegate are NOT
guaranteed atomic operations by themselves in every context — in code
with genuinely concurrent subscription (multiple threads calling
someEvent += handler around the same time), a lost update is a real,
if narrow, possibility without additional synchronization.
For the overwhelming majority of C# code — single-threaded UI event handling, or straightforward application logic — this is not something you need to actively think about; +=/-= compiled by the standard compiler-generated accessors are effectively safe for the common case. It becomes a genuine concern specifically in multi-threaded services where many threads might subscribe or unsubscribe concurrently, which is exactly the scenario Section 7's custom lock-based accessor example was written to guard against.
Interlocked.CompareExchange as a lock-free alternative for high-contention scenarios
public class HighThroughputPublisher
{
private Action _handler;
public event Action Handler
{
add
{
Action current, updated;
do
{
current = _handler;
updated = current + value;
} while (Interlocked.CompareExchange(ref _handler, updated, current) != current);
}
remove { /* symmetric pattern */ }
}
}
For scenarios where a lock's overhead genuinely matters (very high-frequency subscribe/unsubscribe activity), .NET's Interlocked.CompareExchange provides a lock-free way to safely update the backing delegate field — this is meaningfully more advanced than most event code ever needs, worth knowing exists rather than reaching for by default, since a simple lock (Section 7) is perfectly adequate for the vast majority of real thread-safety needs around events.
10. The Memory Leak Risk: Forgotten Subscriptions
Why subscribing creates a reference that keeps the subscriber alive
public class Publisher
{
public event Action SomethingHappened;
}
public class Subscriber
{
public Subscriber(Publisher publisher)
{
publisher.SomethingHappened += HandleEvent; // Publisher now holds a reference to THIS Subscriber
}
private void HandleEvent() => Console.WriteLine("Handled!");
}
When Subscriber does publisher.SomethingHappened += HandleEvent, the publisher's invocation list now holds a reference back to the subscriber (since HandleEvent is an instance method, the delegate implicitly captures this) — if Publisher outlives Subscriber in the application's intended lifecycle, but Subscriber never explicitly unsubscribes, the garbage collector can never reclaim Subscriber's memory, because Publisher is still holding a live reference to it through the event.
This is a genuinely common, real-world .NET memory leak pattern
Especially common in UI applications: a short-lived view/control subscribes
to a long-lived, application-scoped event source (a shared service, a
static event, an application-level singleton) and is later discarded by
the UI framework — but because it never unsubscribed, the "discarded"
view is still reachable through the event, and never actually gets
garbage collected, silently accumulating over the application's lifetime.
This exact pattern — a short-lived object subscribing to a long-lived publisher and never cleaning up — is one of the most commonly cited real-world causes of memory leaks in event-heavy .NET applications (particularly WPF, WinForms, and similar UI frameworks), precisely because the symptom (memory usage slowly growing) doesn't manifest immediately or obviously, making it easy to overlook until a profiler investigation surfaces it.
The direct fix: unsubscribe when the subscriber's own lifetime ends
public class Subscriber : IDisposable
{
private readonly Publisher _publisher;
public Subscriber(Publisher publisher)
{
_publisher = publisher;
_publisher.SomethingHappened += HandleEvent;
}
private void HandleEvent() => Console.WriteLine("Handled!");
public void Dispose() => _publisher.SomethingHappened -= HandleEvent; // explicit cleanup
}
Implementing IDisposable and unsubscribing in Dispose() is the standard, explicit fix — it makes cleanup a deliberate, visible part of the subscriber's lifecycle rather than something left to chance, and it's worth treating "does this subscriber ever unsubscribe" as a genuine design question any time you're subscribing to an event on something with a longer lifetime than the subscriber itself.
The weak event pattern: an alternative for cases where explicit unsubscription is impractical
.NET provides WeakEventManager (used extensively in WPF) and similar weak-
reference-based patterns specifically for this problem — the publisher
holds a WEAK reference to the subscriber, which does NOT prevent garbage
collection, so a subscriber that's discarded without unsubscribing is
still correctly reclaimed, at the cost of real additional complexity in
how the event is wired up.
Worth knowing this pattern exists for cases where explicit, disciplined unsubscription genuinely isn't practical (a framework wiring together many transient objects to shared, long-lived event sources) — but it's meaningfully more complex to set up correctly than ordinary +=/-=, so it's generally reached for specifically to solve this leak problem in frameworks that need it, rather than used as a default replacement for normal event subscription.
11. Events vs. Delegates vs. Interfaces: Choosing the Right Tool
When a plain delegate (no event) is the right choice
Per this series' Delegates guide's Section 4 (passing methods as parameters):
when the "delegate" is really just a single callback parameter passed
into a method call — a comparison function, a transformation, a one-off
completion callback — there's no ongoing publish/subscribe relationship
to protect, so a plain delegate parameter is simpler and entirely appropriate.
list.Sort((a, b) => a.Name.CompareTo(b.Name)) doesn't need event — there's no multi-subscriber list to protect, no external code that could maliciously overwrite anything, just a single method reference passed in and used once. Reaching for event here would be unnecessary ceremony for a problem that doesn't exist in this context.
When event is the right choice
Whenever an object needs to notify potentially MULTIPLE, independent,
externally-defined subscribers about something that happened over its
OWN lifetime, and needs to guarantee it retains exclusive control over
when that notification actually fires — the publish/subscribe shape
Section 5 exists specifically to protect.
OrderService.OrderPlaced, Button.Clicked, TemperatureSensor.TemperatureChanged — anything modeling "this object announces things happening to whoever's interested" fits the event shape precisely, and the restrictions event adds over a plain delegate are exactly the guarantees that relationship needs.
When an interface is the better choice over an event
// An interface-based alternative to a single event, when MULTIPLE related
// notifications need to be coordinated together as one coherent contract
public interface IOrderObserver
{
void OnOrderPlaced(Order order);
void OnOrderCancelled(Order order);
void OnOrderShipped(Order order);
}
Per this series' Interfaces guide's Observer pattern discussion, when a subscriber genuinely needs to implement several coordinated notification methods as one unit (rather than subscribing to several independent events separately), an interface-based Observer pattern can be a more cohesive alternative — the deciding factor mirrors this series' Delegates guide's Section 11 comparison between delegates and interfaces generally: one signature versus several coordinated ones.
12. Events in Modern C#: IObservable<T> and Reactive Extensions
The limitation plain events have: no built-in composition
A .NET event gives you subscribe/unsubscribe and nothing more — filtering
("only notify me when the price change exceeds $10"), combining multiple
events, or throttling ("at most once per second") all have to be
hand-written around a plain event, with no standard vocabulary for expressing them.
Plain events are a solid, simple foundation, but they don't compose the way LINQ-style operators do — there's no built-in way to say "give me a stream derived from this event, filtered and transformed," which is exactly the gap IObservable<T> and Reactive Extensions (Rx.NET) were built to fill.
IObservable<T> as a composable alternative built on the same underlying idea
using System.Reactive.Linq;
IObservable<double> temperatureStream = Observable.FromEventPattern<EventHandler<double>, double>(
handler => sensor.TemperatureChanged += handler,
handler => sensor.TemperatureChanged -= handler)
.Select(pattern => pattern.EventArgs);
var significantChanges = temperatureStream.Where(temp => temp > 100);
significantChanges.Subscribe(temp => Console.WriteLine($"High temp: {temp}"));
Observable.FromEventPattern bridges a conventional .NET event into an IObservable<T> stream, after which the full vocabulary of LINQ-style operators (Where, Select, Throttle, Buffer, and many more) becomes available — this is a meaningfully more powerful tool for genuinely complex event-composition scenarios, at the cost of a real additional dependency (Rx.NET) and a steeper learning curve than plain events require; for straightforward publish/subscribe needs, ordinary event remains the simpler, entirely sufficient default.
13. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| Invoking an event directly without a null check | Throws NullReferenceException the moment there are zero subscribers, which can pass unnoticed in testing |
Always raise events with the null-conditional operator: SomeEvent?.Invoke(...)
|
Using a plain public delegate field instead of event
|
External code can overwrite the whole subscriber list with =, or invoke it directly, bypassing the publisher's control |
Use event for any genuine publish/subscribe relationship (Section 5) |
| Never unsubscribing a short-lived subscriber from a long-lived publisher | The publisher's live reference through the event prevents the subscriber from ever being garbage collected — a real, common memory leak | Unsubscribe explicitly (often via IDisposable) when the subscriber's own lifetime ends (Section 10) |
Passing raw, loose parameters instead of a dedicated EventArgs subclass |
The delegate signature has to change (a breaking change for every subscriber) any time the event needs to carry more data | Wrap event data in a dedicated EventArgs subclass so new properties can be added without breaking existing handlers |
Assuming +=/-= on an event are always thread-safe without further thought |
In genuinely concurrent subscribe/unsubscribe scenarios, a lost update is a real, if narrow, possibility | For high-concurrency publishers, use explicit locking or Interlocked.CompareExchange in custom add/remove accessors (Section 9) |
| Relying on subscriber invocation ORDER for correctness | Subscribers run in the order they were added, which is easy to assume is irrelevant until a bug depends on it | Design handlers to be independent of one another's side effects and ordering wherever possible |
Reaching for event for a simple, single-use callback parameter |
Adds unnecessary restriction and ceremony where a plain delegate parameter would be simpler and entirely sufficient | Use a plain delegate parameter (Section 11) when there's no ongoing, multi-subscriber relationship to protect |
Building custom add/remove accessors without understanding what the compiler-generated default already does |
Risks reimplementing (often incorrectly) behavior the default accessor already handles correctly, like proper multicast semantics | Only write custom accessors for a genuine, specific reason (custom storage, added synchronization, logging) — the default is correct and sufficient otherwise |
Quick Reference Table
| Concept | C# Syntax | Purpose |
|---|---|---|
| Declaring an event | public event Action Clicked; |
A delegate field restricted to +=/-= from outside the declaring class |
| Raising an event | Clicked?.Invoke(); |
Safely invokes all current subscribers, doing nothing if there are none |
| Subscribing | button.Clicked += Handler; |
Adds a method to the event's invocation list |
| Unsubscribing | button.Clicked -= Handler; |
Removes a method from the event's invocation list |
| Standard event pattern | event EventHandler<TEventArgs> SomethingHappened; |
Conventional (sender, args) shape most .NET events follow |
| Custom accessors | add { ... } remove { ... } |
Overrides the compiler-generated default subscribe/unsubscribe logic |
IDisposable cleanup |
public void Dispose() => publisher.Event -= Handler; |
Explicit unsubscription preventing a subscriber memory leak |
IObservable<T> bridge |
Observable.FromEventPattern(...) |
Converts a plain event into a composable, LINQ-style reactive stream |
Conclusion
An event's entire design rests on a simple, deliberate restriction: it's a delegate, with all the same invocation-list mechanics this series' Delegates guide covers, but with = and direct invocation walled off from anyone outside the declaring class. That restriction is what makes events safe as a public API for the publish/subscribe relationship they're built for — many independent subscribers can come and go over an object's lifetime, while the object itself always retains exclusive control over when a notification actually fires.
The pitfalls that matter most specifically to events, rather than delegates in general, cluster around lifecycle and safety: raising an event without a null check, forgetting to unsubscribe and leaking memory through a reference the event itself is quietly holding, and (in genuinely concurrent code) needing real thought about thread safety around subscription. None of these are exotic — they're the ordinary, well-known edge cases of a mechanism used constantly throughout real C# code, from UI frameworks to domain event patterns, and knowing them is what separates using events correctly from using them just well enough that the bugs haven't shown up yet.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the memory-profiler-session-that-found-a-thousand-undisposed-subscribers story that made "always unsubscribe" click far better than any warning in a guide ever could.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.