Events: Advanced Patterns
Reviewed & published by Brayan K
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.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
💡 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.
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;
}
}
// The PUBLISHER announces events but knows nothing about who listens.
class OrderService
{
// EventHandler<T> is the standard generic event delegate.
// The '?' makes it nullable: it is null until someone subscribes.
public event EventHandler<OrderEventArgs>? OrderPlaced;
public void PlaceOrder(string id, decimal amount)
{
Console.WriteLine($"Processing order {id}...");
// Raise the event. '?.' skips the call if there are ZERO
// subscribers, so this can never throw NullReferenceException.
OrderPlaced?.Invoke(this, new OrderEventArgs(id, amount));
}
}
class Program
{
static void Main()
{
var service = new OrderService();
// SUBSCRIBERS react however they like — each reads the data
// off the EventArgs object 'e' that the publisher handed over.
service.OrderPlaced += (sender, e) =>
Console.WriteLine($" Email: order {e.OrderId} confirmed ({e.Amount:C})");
service.OrderPlaced += (sender, e) =>
Console.WriteLine($" Analytics: {e.Amount:C} at {e.Placed:HH:mm}");
// ONE call fans out to BOTH subscribers automatically.
service.PlaceOrder("ORD-001", 49.99m);
// Processing order ORD-001...
// Email: order ORD-001 confirmed (£49.99)
// Analytics: £49.99 at 14:30
}
}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.
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(string message)
{
Console.WriteLine("Alarm firing...");
// 3) Raise the event SAFELY (skip if there are no subscribers).
Triggered?.___(this, new AlertEventArgs(message)); // 👉 Invoke
}
}
class Program
{
static void Main()
{
var alarm = new Alarm();
alarm.Triggered += (s, e) =>
Console.WriteLine($" Handler: {e.Message}");
alarm.Fire("Intruder detected!");
// ✅ Expected output:
// Alarm firing...
// Handler: Intruder detected!
}
}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.
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 operator that ADDS a subscriber.
game.ScoreChanged ___ (sender, e) => // 👉 the operator is +=
{
// 2) Print the points carried on the EventArgs object 'e'.
Console.WriteLine($" Scored {e.___} points!"); // 👉 Points
};
game.AddPoints(10);
game.AddPoints(25);
// ✅ Expected output:
// Scored 10 points!
// Scored 25 points!
}
}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.
using System.Globalization;
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; // nothing changed — skip
var old = _price;
_price = value;
OnPriceChanged(new PriceChangedEventArgs(old, value)); // raise here
}
}
// The standard .NET pattern: a 'protected virtual OnXxx' method
// does the actual raising. 'protected' lets subclasses raise it,
// 'virtual' lets them OVERRIDE how it is raised.
protected virtual void OnPriceChanged(PriceChangedEventArgs e)
{
// Copy to a local first for thread safety (explained below),
// then raise with ?.Invoke so zero subscribers is safe.
PriceChanged?.Invoke(this, e);
}
}
class Program
{
static void Main()
{
// Currency formatting follows the thread's culture: {value:C} prints
// £ in London, $ in Boston and € in Paris. Pin it when the output has
// to be the same everywhere — as it does on a page that shows you the
// result.
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-GB");
var stock = new Stock();
stock.PriceChanged += (s, e) =>
Console.WriteLine($" Price moved {e.OldPrice:C} -> {e.NewPrice:C}");
stock.Price = 100m; // Price moved £0.00 -> £100.00
stock.Price = 100m; // (no output — value did not change)
stock.Price = 125m; // Price moved £100.00 -> £125.00
}
}
// ✅ Expected output:
// Price moved £0.00 -> £100.00
// Price moved £100.00 -> £125.004. 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).
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 you. (?.Invoke does this copy for you.)
var handler = Ticked; // snapshot the subscriber list
handler?.Invoke(this, e); // safe even if Ticked becomes null
}
public void Tick(int n) => OnTicked(new TickEventArgs(n));
}
class Program
{
static void Main()
{
var clock = new Clock();
// A NAMED method (not a lambda) so we can unsubscribe it later.
void OnTick(object? s, TickEventArgs e)
=> Console.WriteLine($" tick {e.Tick}");
clock.Ticked += OnTick; // subscribe
clock.Tick(1); // tick 1
clock.Tick(2); // tick 2
clock.Ticked -= OnTick; // UNSUBSCRIBE (same reference!)
Console.WriteLine("Unsubscribed.");
clock.Tick(3); // (no output — nobody is listening)
}
}
// ✅ Expected output:
// tick 1
// tick 2
// Unsubscribed.🔎 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 OnXxx pattern so subclasses can extend the behaviour and there's exactly one place that raises the event.
- 💡 Keep EventArgs properties read-only: set them in the constructor so one subscriber can't mutate the data the next subscriber sees.
- 💡 Use a record for 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, so OrderPlaced(this, e) crashes. Always raise it as OrderPlaced?.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 in Dispose()), passing the same handler reference you subscribed with.
- Wrong handler signature: for EventHandler<T> the handler must be (object sender, T e). A method like void Handle(T e) won't compile — it's missing the sender. 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/protected method and call that instead.
- EventArgs not inheriting from EventArgs: EventHandler<T> requires T to derive from EventArgs. 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.
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 > Threshold, RAISES ThresholdExceeded safely with ?.Invoke,
// passing new ThresholdEventArgs(value).
// 4. In Main: create the sensor, set Threshold = 30, subscribe a handler
// that prints "ALERT: {Value}°C is too hot!", then call Report twice.
//
// ✅ Expected output (Threshold = 30):
// Reading: 22°C
// Reading: 41°C
// ALERT: 41°C is too hot!
class ThresholdEventArgs // : EventArgs ...
{
// your Value property and constructor here
}
class TemperatureSensor
{
// your event, Threshold property, and Report(...) method here
}
class Program
{
static void Main()
{
// create the sensor, set the threshold, subscribe a handler,
// then call Report(22) and Report(41)
}
}🎉 Lesson Complete
- ✅ A custom EventArgs subclass carries data; keep its properties read-only
- ✅ EventHandler (no data) and EventHandler<T> (with data) are the standard event delegates
- ✅ Every handler matches the (object sender, TEventArgs e) signature
- ✅ Raise through a protected virtual OnXxx method — 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
Practice quiz
What does a custom EventArgs subclass do?
- Replaces the event keyword
- Unsubscribes handlers automatically
- Carries data to every subscriber of an event
- Makes the event thread-safe by itself
Answer: Carries data to every subscriber of an event. A subclass of EventArgs carries the details (which order, how much, etc.) to every subscriber when the event is raised.
What is the standard generic event delegate that carries data?
- EventHandler<T>
- Action<T>
- Func<T>
- Delegate<T>
Answer: EventHandler<T>. EventHandler<T> is the standard generic event delegate; T must derive from EventArgs.
What signature must every handler for EventHandler<T> match?
- (T e)
- (T e, object sender)
- () with no parameters
- (object sender, T e)
Answer: (object sender, T e). Every handler matches (object sender, TEventArgs e) — sender is who raised it, e is the data.
Why raise an event with `OrderPlaced?.Invoke(this, e)` instead of calling it directly?
- It runs faster
- The ?. skips the call when there are zero subscribers (no NullReferenceException)
- It sorts the subscribers
- It unsubscribes afterwards
Answer: The ?. skips the call when there are zero subscribers (no NullReferenceException). An event with no subscribers is null; the null-conditional ?.Invoke does nothing when nobody is listening, avoiding a NullReferenceException.
Why are an EventArgs class's properties kept read-only?
- So one subscriber can't mutate the data the next subscriber sees
- For performance
- It's required by the compiler
- To allow lazy loading
Answer: So one subscriber can't mutate the data the next subscriber sees. Read-only properties (set once in the constructor) stop one subscriber from tampering with the data other subscribers will receive.
What is the purpose of the `protected virtual OnXxx` raise method pattern?
- To make the event public
- To avoid using EventArgs
- To give one place that raises the event, which subclasses can override
- To subscribe automatically
Answer: To give one place that raises the event, which subclasses can override. protected lets subclasses raise it, virtual lets them override how it's raised, and it funnels every raise through a single place.
Why copy an event into a local variable before raising it across threads?
- To make it faster
- Another thread could unsubscribe the last handler between the null-check and the call
- To duplicate the handlers
- To reset the event
Answer: Another thread could unsubscribe the last handler between the null-check and the call. A local copy can't be changed out from under you, so you won't end up calling null if another thread unsubscribes mid-raise. ?.Invoke does this copy for you.
Why can't you unsubscribe a handler you subscribed with as an inline lambda?
- Lambdas can't be used as handlers
- The += operator deletes the reference
- Lambdas are always static
- Each lambda is a distinct delegate object, so -= with a new lambda matches nothing
Answer: Each lambda is a distinct delegate object, so -= with a new lambda matches nothing. Each lambda you write is a new delegate object; -= with a fresh lambda doesn't match the one you added. Use a named method or stored delegate.
What is the most common managed memory leak involving events?
- Raising an event too often
- A long-lived publisher keeping a subscriber alive because you never called -=
- Using EventHandler<T>
- Declaring too many EventArgs classes
Answer: A long-lived publisher keeping a subscriber alive because you never called -=. publisher.Event += subscriber.Handler makes the publisher reference the subscriber; if the publisher outlives it and you never -=, the subscriber can't be collected.
What constraint does EventHandler<T> place on T?
- T must be a struct
- T must implement IDisposable
- T must derive from EventArgs
- T must be sealed
Answer: T must derive from EventArgs. EventHandler<T> requires T : EventArgs, which is why your data class must inherit from EventArgs.
Continue this course
- Previous: Deep Dive Into C# Delegates & Multicast Delegates
- Next: Expression Trees: Building Dynamic Logic at Runtime — Build and compile expression trees for dynamic query and code generation
- Quick reference: C# cheat sheet