Lesson 17 • Advanced Track
Events: Advanced Patterns & Custom EventArgs
By the end of this lesson you'll be able to design professional, type-safe events: carry rich data with a custom EventArgs, raise events the standard .NET way with a protected virtual OnXxx method, do it safely on multiple threads, and avoid the memory leaks that catch out even experienced developers.
What You'll Learn
- Create a custom EventArgs subclass that carries data with an event
- Declare type-safe events with the EventHandler and EventHandler<T> pattern
- Raise events the standard way with a protected virtual OnXxx method
- Raise events safely (null-conditional ?.Invoke and a local copy for threads)
- Subscribe and unsubscribe handlers — and read the event data in a handler
- Prevent memory leaks and understand weak-event basics
event, subscribing with +=, and raising it with ?.Invoke(this, EventArgs.Empty). This lesson takes that base and makes it production-grade.💡 Real-World Analogy
An event is a newsletter. The publisher sends out an issue without knowing who reads it; subscribers sign up (+=) and cancel (-=) at will. A plain EventArgs.Empty is a blank postcard that just says "something happened". A custom EventArgs is the actual article stapled to the postcard — it carries the details: which order, how much, what the new temperature was. And the OnXxx method is the printing press in the basement: the only machine allowed to put an issue in the post, kept private so nobody outside can fake a mailing.
Running C# Locally: Install the .NET SDK or use dotnetfiddle.net to run every console example below.
📊 The Event Toolkit at a Glance
| Piece | What it is | Example | When to use |
|---|---|---|---|
| EventHandler | Standard delegate, no extra data | event EventHandler Clicked; | Event carries nothing |
| EventHandler<T> | Standard delegate carrying data T | event EventHandler<OrderEventArgs> OrderPlaced; | Event carries details |
| : EventArgs | Your data class for an event | class OrderEventArgs : EventArgs | Define what to send |
| OnXxx(...) | protected virtual raise method | protected virtual void OnOrderPlaced(...) | The one place that raises |
| ?.Invoke | Null-safe raise | OrderPlaced?.Invoke(this, e); | Always, when raising |
| += / -= | Subscribe / unsubscribe | svc.OrderPlaced += OnPlaced; | From outside the class |
The signature every handler matches is always (object sender, TEventArgs e) — sender is who raised it, e is the data. Memorising that one shape unlocks every event in .NET.
1. Custom EventArgs — Carrying Data With an Event
A bare event can shout "something happened!", but real apps need to know what happened. You attach that detail by creating a class that inherits from EventArgs and declaring the event as EventHandler<YourEventArgs>. By convention the data class ends in EventArgs and its properties are read-only (set once in the constructor) so a subscriber can't tamper with the data other subscribers will receive. Read this worked example and run it, then you'll write your own.
Worked example: a custom OrderEventArgs
One PlaceOrder call hands every subscriber an OrderEventArgs full of detail.
using System;
// A custom EventArgs subclass CARRIES DATA to every subscriber.
// Convention: the class name ends in "EventArgs" and the
// properties are read-only (set once in the constructor).
class OrderEventArgs : EventArgs
{
public string OrderId { get; }
public decimal Amount { get; }
public DateTime Placed { get; }
public OrderEventArgs(string orderId, decimal amount)
{
OrderId = orderId;
Amount = amount;
Placed = DateTime.Now;
}
}
// Th
...Your turn. Define an EventArgs subclass that carries a message, then raise an event with it. Fill in the three blanks marked ___ using the // 👉 hints, then run it.
🎯 Your turn: define EventArgs & raise an event
Inherit from EventArgs and raise the event safely with ?.Invoke.
using System;
// 🎯 YOUR TURN #1 — define a custom EventArgs and raise an event.
// 1) Finish this EventArgs subclass so it CARRIES a message string.
class AlertEventArgs : ___ // 👉 inherit from EventArgs
{
public string Message { get; }
public AlertEventArgs(string message) => Message = message;
}
class Alarm
{
// 2) Declare an event using the generic standard delegate.
public event EventHandler<AlertEventArgs>? Triggered; // (already done)
public void Fire(s
...2. Subscribing — Reading the Event Data
Defining the data is only half the job; a subscriber has to read it. Every handler receives two arguments: sender (the object that raised the event) and e (your EventArgs). You reach the details through e — e.OrderId, e.Amount, e.Points, whatever your class exposed. Fill in the two blanks below: choose the operator that adds a subscriber, and pull the value off e.
🎯 Your turn: subscribe and read e.Points
Subscribe with += and print the points carried on the EventArgs.
using System;
class ScoreEventArgs : EventArgs
{
public int Points { get; }
public ScoreEventArgs(int points) => Points = points;
}
class Game
{
public event EventHandler<ScoreEventArgs>? ScoreChanged;
public void AddPoints(int points)
=> ScoreChanged?.Invoke(this, new ScoreEventArgs(points));
}
class Program
{
static void Main()
{
var game = new Game();
// 🎯 YOUR TURN #2 — subscribe a handler that READS the event data.
// 1) Use the
...3. The protected virtual OnXxx Raise Method
In professional code you almost never raise an event inline. Instead you funnel every raise through a single protected virtual method named On + the event name — OnPriceChanged, OnOrderPlaced. There are two reasons. protected lets a subclass raise the event too (handy for base classes). virtual lets a subclass override the method to add behaviour — log it, suppress it, run code before or after — without touching the property that triggered it. It also gives you exactly one place to make the raise thread-safe.
Worked example: the OnPriceChanged pattern
A property setter calls a protected virtual OnXxx method to raise the event.
using System;
class PriceChangedEventArgs : EventArgs
{
public decimal OldPrice { get; }
public decimal NewPrice { get; }
public PriceChangedEventArgs(decimal oldPrice, decimal newPrice)
{
OldPrice = oldPrice;
NewPrice = newPrice;
}
}
class Stock
{
public event EventHandler<PriceChangedEventArgs>? PriceChanged;
private decimal _price;
public decimal Price
{
get => _price;
set
{
if (_price == value) return;
...4. Thread-Safe Raising & Unsubscribing
There's a subtle race condition when events are used across threads. Between checking that an event has subscribers and actually calling it, another thread could unsubscribe the last handler — leaving you calling null. The fix is to copy the event into a local variable first, then call the copy. Good news: the ?.Invoke operator you've been using already does this copy for you, which is the main reason it's the recommended way to raise. The example also shows the other half of subscription hygiene: a named handler so you can -= it later (you can't unsubscribe an anonymous lambda).
Worked example: local-copy raise & unsubscribe
Snapshot the handler list for thread safety, then unsubscribe a named handler.
using System;
class TickEventArgs : EventArgs
{
public int Tick { get; }
public TickEventArgs(int tick) => Tick = tick;
}
class Clock
{
public event EventHandler<TickEventArgs>? Ticked;
protected virtual void OnTicked(TickEventArgs e)
{
// THREAD-SAFE RAISE: copy the event to a local variable first.
// Another thread could unsubscribe the last handler between the
// null-check and the call; the local copy can't be changed
// out from under y
...🔎 Deep Dive: memory leaks & weak events
When you write publisher.SomeEvent += subscriber.Handler, the publisher now holds a reference to the subscriber. If the publisher lives a long time (a singleton, a static service, a long-running window) and you never call -=, the garbage collector cannot reclaim the subscriber — it's kept alive purely by that subscription. Repeat this in a loop and memory climbs forever. This is the single most common "managed memory leak" in C#.
The everyday fix is discipline: unsubscribe with -= when the subscriber is done, often inside Dispose(). For cases where you can't guarantee that — UI frameworks especially — there are weak event patterns where the publisher holds only a weak reference, so the subscriber can still be collected. WPF ships WeakEventManager; many teams use a small WeakEventManager<T> helper for the same effect.
// LEAK: long-lived publisher keeps 'view' alive forever service.DataUpdated += view.Refresh; // FIX: unsubscribe when done (e.g. in Dispose) service.DataUpdated -= view.Refresh; // Pass the SAME reference you subscribed with — a fresh lambda // like (s,e) => view.Refresh(s,e) is a NEW object and won't detach.
Pro Tips
- 💡 Always raise with
?.Invoke(...): it both skips zero-subscriber events and takes the thread-safe local copy for you — two safety nets in one operator. - 💡 Use the
protected virtual OnXxxpattern so subclasses can extend the behaviour and there's exactly one place that raises the event. - 💡 Keep
EventArgsproperties read-only: set them in the constructor so one subscriber can't mutate the data the next subscriber sees. - 💡 Use a
recordfor terse EventArgs (C# 9+):record OrderEventArgs(string Id, decimal Amount) : EventArgs;generates the constructor and properties for you. - 💡 Name handlers so you can detach them: a named method or stored delegate can be passed to
-=; an inline lambda can't be unsubscribed.
Common Errors (and the fix)
- "System.NullReferenceException" when raising: an event with no subscribers is
null, soOrderPlaced(this, e)crashes. Always raise it asOrderPlaced?.Invoke(this, e);— the?.simply does nothing when nobody is listening. - Forgetting to unsubscribe → memory leak: a subscriber a long-lived publisher still references can never be garbage-collected. Call
-=when you're done (often inDispose()), passing the same handler reference you subscribed with. - Wrong handler signature: for
EventHandler<T>the handler must be(object sender, T e). A method likevoid Handle(T e)won't compile — it's missing thesender. Match the standard(object sender, TEventArgs e)shape exactly. - "CS0070: The event 'X' can only appear on the left hand side of += or -=": you tried to raise (or assign) an event from outside the declaring class. Only the class that declares the event may raise it — wrap the raise in a
public/protectedmethod and call that instead. - EventArgs not inheriting from EventArgs:
EventHandler<T>requiresTto derive fromEventArgs. If you forget: EventArgs, the event declaration won't compile.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Define event data | class OrderEventArgs : EventArgs | Read-only props |
| Declare a data event | event EventHandler<OrderEventArgs> OrderPlaced; | On the publisher |
| Raise method | protected virtual void OnOrderPlaced(...) | One place to raise |
| Raise safely | OrderPlaced?.Invoke(this, e); | Null-safe + thread-safe |
| Thread-safe local copy | var h = OrderPlaced; h?.Invoke(this, e); | What ?.Invoke does |
| Handler signature | void H(object sender, OrderEventArgs e) | Always (sender, e) |
| Subscribe / unsubscribe | svc.OrderPlaced += H; svc.OrderPlaced -= H; | Same reference to -= |
| Read the data | $"{e.OrderId}: {e.Amount:C}" | Inside the handler |
Frequently Asked Questions
Q: Do I have to inherit from EventArgs for my data class?
To use the built-in EventHandler<T> delegate, yes — its constraint requires T : EventArgs. It's a tiny base class with no members, so inheriting costs nothing and signals "this is event data" to every C# developer and tool.
Q: Why use a protected virtual OnXxx method instead of raising inline?
It gives you one single place that raises the event, which keeps thread-safety logic in one spot. protected lets subclasses raise it, and virtual lets them override to add behaviour (logging, suppression) without rewriting the trigger. It's the convention every .NET library follows.
Q: Is ?.Invoke really enough for thread safety?
For the classic "subscriber unsubscribed between the null-check and the call" race, yes — ?.Invoke evaluates the event once into a hidden local, so it can't become null mid-call. It does not make the handlers themselves thread-safe; if your handlers touch shared state, they still need their own locking.
Q: Why can't I unsubscribe a lambda I subscribed with?
Each lambda you write is a distinct delegate object. x -= (s,e) => ... creates a brand-new lambda that doesn't match the one you added, so nothing is removed. Store the handler in a variable or use a named method, then pass that same reference to both += and -=.
Mini-Challenge: a TemperatureSensor
No blanks this time — just a brief and an outline. Build a TemperatureSensor that raises a ThresholdExceeded event (with a custom EventArgs carrying the value) when a reading goes above its threshold, and a subscriber that reacts with an alert. Run it and check your output against the expected lines in the comments.
🎯 Mini-Challenge: build a TemperatureSensor event
Define ThresholdEventArgs, raise ThresholdExceeded with ?.Invoke, and react in a subscriber.
using System;
// 🎯 MINI-CHALLENGE: a TemperatureSensor that raises ThresholdExceeded
//
// 1. Create a custom EventArgs class "ThresholdEventArgs" that carries
// a double Value (set it in the constructor, read-only property).
// 2. Give TemperatureSensor an event:
// public event EventHandler<ThresholdEventArgs>? ThresholdExceeded;
// ...and a double Threshold property (e.g. 30.0).
// 3. Add a method Report(double value) that prints the reading, and if
// value > Thresho
...🎉 Lesson Complete
- ✅ A custom
EventArgssubclass carries data; keep its properties read-only - ✅
EventHandler(no data) andEventHandler<T>(with data) are the standard event delegates - ✅ Every handler matches the
(object sender, TEventArgs e)signature - ✅ Raise through a
protected virtual OnXxxmethod — one place, easy to override - ✅ Always raise with
?.Invoke: it's null-safe and takes the thread-safe local copy - ✅ Unsubscribe with
-=(same reference) to prevent memory leaks; weak events help when you can't - ✅ Next lesson: Expression Trees — building and inspecting code as data at runtime
Sign up for free to track which lessons you've completed and get learning reminders.