The delegates post said events are built on delegates and left it there. This post finishes the job. An event is not a new kind of thing; it is a delegate field with two of its four abilities taken away. Understanding which two, and why, is the whole topic, and it is the difference between a candidate who has used += and one who knows what they were doing.
The problem events solve
Keep the running example. A BookStore that lets people buy books, and other parts of the system that need to react when a sale happens: send a receipt, update stock, log it.
The naive version:
public class BookStore
{
public void Buy(Book book)
{
Console.WriteLine($"Sold: {book.Title}");
new EmailService().SendReceipt(book);
new StockService().Decrement(book);
new AuditLog().Record(book);
}
}
BookStore now knows about email, stock and auditing. Add SMS notifications and you edit BookStore. Write a unit test for Buy and you send an email. The class that owns the fact (“a book was sold”) is doing everything that follows from the fact, and it cannot be changed or tested independently.
We already know the fix in principle from the delegates post: pass the behaviour in. What we want is for BookStore to announce the sale and know nothing about who is listening.
The delegate version, and its flaw
Delegates get us most of the way:
public class BookStore
{
public Action<Book> BookSold; // public delegate field
public void Buy(Book book)
{
Console.WriteLine($"Sold: {book.Title}");
BookSold?.Invoke(book); // announce, don't act
}
}
var store = new BookStore();
store.BookSold += b => Console.WriteLine($"Receipt for {b.Title}");
store.BookSold += b => Console.WriteLine($"Stock down for {b.Title}");
store.Buy(book);
BookStore no longer references email or stock. Both subscribers run, because delegates are multicast: += chains them, as the delegates post covered.
Now the flaw. BookSold is a public field, so any code anywhere can do this:
store.BookSold = b => Console.WriteLine("hi"); // wiped out every other subscriber
store.BookSold(book); // raised the event from outside
store.BookSold = null; // silenced the store entirely
Three separate disasters. A subscriber can obliterate other subscribers with = instead of +=, a one-character typo that removes functionality from a class you have never heard of, with no compiler error. And anyone can raise the event, telling the whole system a book was sold when nothing was.
The event keyword
Add one word:
public event Action<Book> BookSold;
Now those three lines do not compile. The error is blunt: “The event ‘BookStore.BookSold’ can only appear on the left hand side of += or -=”.
That is the entire feature. An event is a delegate field where, from outside the declaring class, only += and -= are legal. Assignment, invocation and reading are private to the class that declared it. Inside BookStore, BookSold is still an ordinary delegate you can invoke and null-check.
Think of it as the same encapsulation you already apply to fields. You do not expose public List<Book> Books and hope nobody calls Clear(); you expose Add and Remove. An event does exactly that for a delegate: subscribers get Add and Remove, the owner keeps Set and Invoke.
That mapping is literal, not a metaphor. The compiler turns the event into a private delegate field plus two accessor methods, add_BookSold and remove_BookSold, in the same way an auto-property becomes a backing field plus get_ and set_. += compiles into a call to add_BookSold. An event is to a delegate what a property is to a field.
The standard .NET event pattern
You can declare an event with any delegate type, but .NET has a convention that every library and every UI framework follows, and interviewers expect it.
public class BookSoldEventArgs : EventArgs
{
public Book Book { get; init; }
public DateTime SoldAt { get; init; }
}
public class BookStore
{
public event EventHandler<BookSoldEventArgs>? BookSold;
public void Buy(Book book)
{
Console.WriteLine($"Sold: {book.Title}");
OnBookSold(new BookSoldEventArgs { Book = book, SoldAt = DateTime.UtcNow });
}
protected virtual void OnBookSold(BookSoldEventArgs e)
{
BookSold?.Invoke(this, e);
}
}
Four conventions in there, each with a reason:
EventHandler<TArgs>is a built-in delegate:void (object? sender, TArgs e). Using it means every event in .NET has the same signature shape, so tooling, designers and readers can rely on it.senderis the object that raised the event, which lets one handler serve many sources:((BookStore)sender).Name.- A class deriving from
EventArgscarries the data. The payoff is versioning: adding a property toBookSoldEventArgsnext year does not break a single existing subscriber, whereas adding a parameter toAction<Book, DateTime>breaks all of them. - A
protected virtual On<Name>method raises it. Protected so derived classes can raise it, virtual so they can intercept it, the standard wayButtonsubclasses handleOnClickbefore it reaches subscribers.
Subscribing:
store.BookSold += HandleSale;
void HandleSale(object? sender, BookSoldEventArgs e)
{
Console.WriteLine($"Receipt for {e.Book.Title} at {e.SoldAt:t}");
}
The null check, and why it is not optional
An event with no subscribers is null, not an empty list. Raising it directly throws NullReferenceException:
BookSold.Invoke(this, e); // NRE when nobody has subscribed
BookSold?.Invoke(this, e); // correct
The ?. is not just brevity. In multithreaded code, this is a real race:
if (BookSold != null) // subscriber unsubscribes right here, on another thread
BookSold(this, e); // NRE
?. compiles to a single read of the delegate into a temporary, then a null check on that temporary, so the value cannot change between the check and the call. Delegates are immutable, -= builds a new delegate rather than editing the existing one, so the captured copy stays valid. The handler that just unsubscribed may still run one last time, which is the correct and unavoidable trade.
Unsubscribing, and the memory leak nobody expects
This is the highest-value thing in the post, because it causes production bugs that are miserable to diagnose.
public class ReceiptPrinter
{
public ReceiptPrinter(BookStore store)
{
store.BookSold += OnSold; // subscribe
}
private void OnSold(object? sender, BookSoldEventArgs e) { /* ... */ }
}
Which way does the reference point? Most people assume the printer holds the store. It is the other way round. store.BookSold is a delegate list, and each entry holds a reference to the target object of its handler method. So the store holds a strong reference to the printer.
As long as store is alive, every ReceiptPrinter that ever subscribed stays alive, un-collectable, still receiving events. Create one per request against a long-lived singleton store and you have a slow, steady leak: memory climbing, handlers running for objects that were logically discarded hours ago, work being done many times over.
The fix is to unsubscribe, and the reliable way to guarantee it is IDisposable:
public class ReceiptPrinter : IDisposable
{
private readonly BookStore _store;
public ReceiptPrinter(BookStore store)
{
_store = store;
_store.BookSold += OnSold;
}
public void Dispose() => _store.BookSold -= OnSold;
private void OnSold(object? sender, BookSoldEventArgs e) { /* ... */ }
}
One trap inside the trap: -= removes a handler by comparing the target object and method. That works for a method group like OnSold, and fails for a lambda:
store.BookSold += (s, e) => Log(e); // subscribed
store.BookSold -= (s, e) => Log(e); // removes NOTHING, different delegate instance
The two lambdas are distinct objects, so the second -= silently does nothing. If you need to unsubscribe a lambda, store it in a variable first and pass that same variable to both.
The general rule: the shorter-lived object must unsubscribe from the longer-lived one. If both live the same length, you can be relaxed; a static or singleton publisher demands discipline.
Two more behaviours worth knowing
One handler throwing stops the rest. Multicast invocation walks the list in subscription order and does not catch anything. If handler two throws, handlers three and four never run, and the exception surfaces at the Invoke inside BookStore, a class that knows nothing about the handler. If subscribers are untrusted or independent, invoke them individually:
foreach (EventHandler<BookSoldEventArgs> handler in BookSold?.GetInvocationList() ?? [])
{
try { handler(this, e); }
catch (Exception ex) { _logger.LogError(ex, "Handler failed"); }
}
Events are synchronous. BookSold?.Invoke(...) blocks until every handler finishes. Nothing about events is asynchronous or queued; the calling thread runs all the handler code. This surprises people who read the word “event” as “message”. If a handler does slow I/O, it slows down Buy. And an async void handler cannot be awaited by the publisher, so its exceptions escape to the top of the process: the one place async void is tolerated is precisely event handlers, which is why they are so often the source of crashes.
The same idea inside Microsoft, Unity and the web
Microsoft (WinForms, WPF, Blazor). The event pattern was designed for UI, and every control is a bag of them:
saveButton.Click += SaveButton_Click;
textBox.TextChanged += TextBox_TextChanged;
The sender/EventArgs shape exists so one handler can serve twenty buttons. WPF’s routed events and Blazor’s EventCallback are variations on the same contract.
Unity (games). UnityEvent and C# events drive nearly all gameplay wiring, and the leak above is a rite of passage: subscribe in OnEnable, unsubscribe in OnDisable, or destroyed objects keep receiving callbacks and the game crashes on a null transform.
void OnEnable() => GameManager.PlayerDied += HandleDeath;
void OnDisable() => GameManager.PlayerDied -= HandleDeath;
The browser (Meta, every frontend). DOM events are the same publish/subscribe contract with different spelling:
button.addEventListener('click', handleClick);
button.removeEventListener('click', handleClick);
addEventListener is +=, removeEventListener is -=, and the same leak exists for the same reason: listeners keep their handler objects alive. Note that JavaScript also gives you an anonymous-function version of the lambda trap: you cannot remove a listener you did not keep a reference to.
Node.js. EventEmitter with emitter.on('sold', handler) even warns you at eleven subscribers, because the leak is that common.
Kotlin/Java (Android, Netflix). No event keyword; the equivalent is a listener interface plus addListener/removeListener, and RxJava’s Observable is the reactive generalisation of the same publish/subscribe idea. C#‘s IObservable<T> and Rx.NET occupy exactly that space, and their Subscribe returns an IDisposable specifically to make unsubscription impossible to forget.
Summary
- An event is a delegate field that only allows
+=and-=from outside the declaring class. Invoking, assigning and reading stay private. - An event is to a delegate what a property is to a field: the compiler generates
add_andremove_accessors around a hidden delegate. - Without
event, a caller can wipe every other subscriber with=or raise the event on the owner’s behalf. - The .NET convention is
EventHandler<TArgs>with(object? sender, TArgs e), a class deriving fromEventArgs, and aprotected virtual OnXmethod to raise it. It versions safely: new data means a new property, not a broken signature. - An event with no subscribers is
null. Always raise with?.Invoke(...), which also closes a thread race. - The publisher holds a strong reference to every subscriber, so a long-lived publisher leaks short-lived subscribers. Unsubscribe, usually via
IDisposable. -=cannot remove a lambda you did not keep a reference to.- Handlers run synchronously, in order, on the caller’s thread, and one throwing prevents the rest from running.
Interview questions and answers
1. What is an event in C#?
A member that wraps a delegate and restricts access to it. Outside the declaring class, only += and -= are permitted; the class itself retains the ability to assign, read and invoke it. It is the language’s built-in publish/subscribe mechanism.
2. What is the difference between a delegate and an event?
A delegate is a type that holds methods. An event is a restricted member built on a delegate. With a public delegate field, any caller can overwrite the subscriber list with =, set it to null, or invoke it. The event keyword makes all three compile errors from outside, leaving only subscribe and unsubscribe.
3. What does the compiler generate for an event?
A private delegate field plus two accessor methods, add_EventName and remove_EventName, which by default combine and remove delegates in a thread-safe way. += and -= compile into calls to those accessors, exactly as get/set accessors back a property.
4. What is EventHandler and why use it instead of Action?
EventHandler<TEventArgs> is the built-in delegate void (object? sender, TEventArgs e). Using it gives every event in the framework a consistent shape, provides the sender so one handler can serve several publishers, and allows the payload to grow over time by adding properties to the EventArgs class rather than changing the delegate signature and breaking subscribers.
5. Why do you raise events with ?.Invoke?
Because an event with no subscribers is null, so a direct invoke throws. ?. also avoids a race: a plain if (X != null) X(...) can have its last subscriber removed between the check and the call. The null-conditional operator reads the delegate once into a temporary, and since delegates are immutable that captured instance remains safe to invoke.
6. How can events cause memory leaks?
The publisher’s delegate list holds a strong reference to each subscriber’s target object. If the publisher outlives the subscriber (a singleton or static publisher, for instance), every subscriber that never unsubscribed stays reachable, so it is never garbage collected and keeps handling events. The fix is to unsubscribe with -=, commonly in Dispose.
7. Why can’t you always unsubscribe a lambda?
-= matches on the target object and method of the delegate. Two lambdas with identical bodies are two distinct delegate instances, so subscribing with one lambda and unsubscribing with a lookalike removes nothing, silently. Keep the lambda in a variable, or use a named method, if it must be removed later.
8. What happens if one event handler throws an exception?
Invocation stops. Handlers run sequentially in subscription order, and an unhandled exception propagates out of the Invoke call in the publisher, so handlers later in the list never run. To isolate subscribers, walk GetInvocationList() and wrap each call in its own try/catch.
9. Are events asynchronous?
No. Raising an event is a synchronous method call that runs every handler on the calling thread and returns only when they have all completed. Handlers declared async void are the exception in a bad way: the publisher cannot await them and their exceptions cannot be caught by it, which is why async void is acceptable only for event handlers, and even then needs a top-level try/catch inside the handler.
10. When would you use an event versus an interface or a callback?
Use an event when several independent listeners may care about something that happened, and the publisher should not know or care whether any of them exist. Use an interface or an injected callback when there is exactly one collaborator, when you need a return value from it (events return void), or when you want the dependency to be explicit and mockable in tests.