Events
Let objects notify any number of listeners when something happens, without knowing who they are.
11 min read
You will learn
- Declare an event and raise it safely
- Subscribe and unsubscribe handlers with += and -=
- Use EventHandler<T> and custom event arguments
- Recognize the publisher/subscriber pattern
"Let me know when something happens"
A download finishes. A temperature crosses a limit. A user clicks a button. Often several parts of a program care about such moments — update the UI, write a log, send an alert — and the object where it happens shouldn't need to know about all of them.
Events solve this with the publisher/subscriber pattern:
- The publisher declares an event and raises it when something happens.
- Any number of subscribers attach handlers, and get called each time.
Declaring and raising an event
class Downloader
{
public event EventHandler<string>? Completed; // the event
public void Download(string url)
{
// ... download the file ...
Completed?.Invoke(this, url); // raise it
}
}event+ a delegate type declares the event.EventHandler<T>is the standard shape:(object? sender, T args).?.Invoke(...)raises it — the?.matters, because an event with no subscribers isnull.- By convention,
senderis the object raising the event:this.
Subscribing
var downloader = new Downloader();
downloader.Completed += (sender, url) => Console.WriteLine($"Done: {url}");
downloader.Completed += LogCompletion; // a method works too
downloader.Download("https://example.com/file.zip");
// Done: https://example.com/file.zip
// [log] https://example.com/file.zip
void LogCompletion(object? sender, string url) => Console.WriteLine($"[log] {url}");Handlers run in the order they were added, synchronously, when the event is raised.
Unsubscribing
downloader.Completed -= LogCompletion;To unsubscribe a lambda, you must keep a reference to the same delegate:
EventHandler<string> handler = (s, url) => Console.WriteLine(url);
downloader.Completed += handler;
downloader.Completed -= handler; // worksEvent leaks
The publisher holds references to every handler — and through them, to the subscribers. If a short-lived object subscribes to a long-lived one and never unsubscribes, it can never be garbage collected. Unsubscribe when the subscriber is done.
Why the event keyword?
You could expose a public delegate field instead, but then anyone could raise it or replace all its handlers with =. The event keyword restricts outside code to += and -=; only the declaring class can raise it.
Custom event arguments
When you need to pass several values, define an arguments type — a record works nicely:
record OrderPlacedEventArgs(int OrderId, decimal Total);
class Shop
{
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
public void PlaceOrder(int id, decimal total)
{
// ... save the order ...
OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(id, total));
}
}
shop.OrderPlaced += (_, e) => Console.WriteLine($"Order {e.OrderId}: {e.Total:F2}");Where you'll meet events
- UI frameworks (Blazor, WPF, MAUI):
Click,TextChanged… INotifyPropertyChanged: thePropertyChangedevent powers data binding.- .NET APIs:
FileSystemWatcher.Changed,Process.Exited,AppDomain.UnhandledException.
In backend systems, the same idea scales up into domain events and message queues — publishers and subscribers in different services. You'll see those in Backend Fundamentals and Architecture.
Try it
C# runtime not loaded
Key takeaways
- Events implement publisher/subscriber: publishers raise, subscribers handle.
- Declare with
public event EventHandler<T>? Name;and raise withName?.Invoke(this, args). - Subscribe with
+=, unsubscribe with-=— keep a reference to lambdas you'll remove. - Unsubscribe to avoid keeping subscribers alive longer than intended.
Exercise
Thermostat raises TooHot with the temperature whenever SetTemperature is called with a value above Limit. Stock raises PriceChanged (symbol, new price) when Price is set to a DIFFERENT value — setting the same price again raises nothing. Both must work with no subscribers.
C# runtime not loaded
Hint 1
Raise an event safely with the null-conditional operator: TooHot?.Invoke(this, temperature);
Hint 2
Stock.Price needs a backing field: in the setter, return early if value == price; otherwise store it and raise PriceChanged?.Invoke(Symbol, value).
Show solution
var thermostat = new Thermostat(30);
thermostat.TooHot += (sender, temp) => Console.WriteLine($"Too hot: {temp}°C");
thermostat.SetTemperature(35);
class Thermostat
{
public double Limit { get; }
public event EventHandler<double>? TooHot;
public Thermostat(double limit) => Limit = limit;
public void SetTemperature(double temperature)
{
if (temperature > Limit)
{
TooHot?.Invoke(this, temperature);
}
}
}
class Stock
{
private decimal price;
public string Symbol { get; }
public event Action<string, decimal>? PriceChanged;
public decimal Price
{
get => price;
set
{
if (value == price) return;
price = value;
PriceChanged?.Invoke(Symbol, value);
}
}
public Stock(string symbol, decimal price)
{
Symbol = symbol;
this.price = price;
}
}Quiz
4 questions · 3 correct to pass1.What does `button.Click += OnClick;` do?
2.Why write `Changed?.Invoke(this, args)` instead of `Changed(this, args)`?
3.What does the event keyword add compared to a public delegate field?
4.What's a common cause of memory leaks with events?
To complete this lesson
- ○ Solve the exercise
- ○ Pass the quiz (3/4 correct)