The pattern behind almost every Click, Changed, and Completed event you've ever subscribed to in .NET.
In lesson 105, StockTicker raised its event with a bare decimal as the payload: PriceChanged?.Invoke(this, newPrice);. That works — until the day you need to also send the old price along with the new one. Now you have to change the event's type from EventHandler<decimal> to something else entirely, which breaks every existing subscriber's handler signature. Every one of them has to be rewritten.
This is exactly the problem the .NET Framework designers hit constantly while building Button, FileSystemWatcher, Process, and every other event-raising type in the BCL. Their solution became a convention so consistent that once you recognize it, you can predict the shape of almost any event in .NET before you even open the documentation: a dedicated EventArgs class for the data, and a protected virtual OnXxx method that raises it. This lesson is that pattern, in full.
The standard .NET event pattern is a small set of conventions applied on top of the event keyword you already know: package whatever data the event carries into its own purpose-built class, and route every "raise this event" call through one dedicated method instead of scattering ?.Invoke(...) calls throughout the class. Follow it, and your events look and behave exactly like every other event a .NET developer has ever used.
The pattern has three pieces:
System.EventArgs, conventionally named <Something>EventArgs, holding whatever data the event needs to carry (or nothing at all, if the event is just a notification).public event EventHandler<TEventArgs>? field, declared using that EventArgs subclass as its type parameter.protected virtual void On<Something>(TEventArgs e) method — the raiser — that does the actual ?.Invoke(this, e). Every place inside the class that needs to raise the event calls this method instead of touching the event field directly.public class PriceChangedEventArgs : EventArgs
{
public decimal OldPrice { get; }
public decimal NewPrice { get; }
public PriceChangedEventArgs(decimal oldPrice, decimal newPrice)
{
OldPrice = oldPrice;
NewPrice = newPrice;
}
}
public class StockTicker
{
public event EventHandler<PriceChangedEventArgs>? PriceChanged;
protected virtual void OnPriceChanged(PriceChangedEventArgs e)
=> PriceChanged?.Invoke(this, e); // the ONE place that raises this event
}
EventHandler<decimal> — bare primitive as dataEventHandler<PriceChangedEventArgs> — a named classEventArgs class — no signature ever changesprotected virtual OnXxx gives subclasses a hookOnce an event ships as part of a public API, its signature is a promise to every consumer who has already written a handler for it. Three concrete problems show up almost immediately if you skip the standard pattern:
EventHandler<decimal> can never carry a second piece of data without changing its type parameter — and changing it breaks every subscriber's handler signature.PriceChanged?.Invoke(this, e); scattered inline wherever it's needed, a subclass has no clean way to run code immediately before or after the event fires, or to suppress it under certain conditions, without duplicating the raising logic itself.Wrapping the payload in a dedicated EventArgs subclass means the event's signature — EventHandler<PriceChangedEventArgs> — never has to change again; new data becomes a new property on the existing class. Routing every raise through a single protected virtual OnXxx method means there's exactly one place that decides when and how the event fires, and subclasses get a real, supported extension point: override the method, optionally call base.OnXxx(e), and you're participating in the exact same mechanism the base class uses.
This pattern doesn't add new capability beyond what you already know from lesson 105 — it's still a plain event-qualified multicast delegate, raised with ?.Invoke. What it buys you is stability (the signature survives new fields) and extensibility (subclasses get a real hook), both for free, just by following the convention.
UpdatePrice(newPrice) changes internal stateOnPriceChanged(new PriceChangedEventArgs(oldPrice, newPrice));
OnPriceChanged runs firstbase.OnPriceChanged(e) to continue?.Invoke(this, e) and invocation-list mechanics from lesson 105public class OrderShippedEventArgs : EventArgs
{
public string TrackingNumber { get; }
public OrderShippedEventArgs(string trackingNumber) => TrackingNumber = trackingNumber;
}
public event EventHandler<OrderShippedEventArgs>? OrderShipped;
protected virtual void OnOrderShipped(OrderShippedEventArgs e)
=> OrderShipped?.Invoke(this, e);
protected — subclasses can call and override it, external code cannot.virtual — subclasses can override it to hook into the raise.OnOrderShipped(new OrderShippedEventArgs(trackingNumber));
order.OrderShipped += HandleShipped; // subscribe
// ... later, when the handler is no longer needed:
order.OrderShipped -= HandleShipped; // unsubscribe
using System;
public class PriceChangedEventArgs : EventArgs
{
public decimal OldPrice { get; }
public decimal NewPrice { get; }
public PriceChangedEventArgs(decimal oldPrice, decimal newPrice)
{
OldPrice = oldPrice;
NewPrice = newPrice;
}
}
public class StockTicker
{
public event EventHandler<PriceChangedEventArgs>? PriceChanged;
private decimal _price;
protected virtual void OnPriceChanged(PriceChangedEventArgs e)
=> PriceChanged?.Invoke(this, e); // the single raise point
public void UpdatePrice(decimal newPrice)
{
if (newPrice == _price) return;
var oldPrice = _price;
_price = newPrice;
OnPriceChanged(new PriceChangedEventArgs(oldPrice, newPrice)); // never invoke the field directly
}
}
class Program
{
static void Main()
{
var ticker = new StockTicker();
ticker.PriceChanged += (sender, e) =>
Console.WriteLine($"Price moved from {e.OldPrice:C} to {e.NewPrice:C}");
ticker.UpdatePrice(142.50m); // Price moved from $0.00 to $142.50
ticker.UpdatePrice(145.10m); // Price moved from $142.50 to $145.10
}
}
Code → Meaning → Result: UpdatePrice never touches the PriceChanged field. It builds a PriceChangedEventArgs carrying both the old and new price, then hands it to OnPriceChanged — the one method responsible for actually raising the event. Subscribers get both values from a single, stable event signature that could grow a third property tomorrow without breaking any of them.
The protected virtual raiser earns its keep the moment a subclass needs to participate in the event. Consider a Document base class used across a suite of editor tools, and an AuditedDocument subclass that needs to log every save without touching Document's source at all:
using System;
public class SavedEventArgs : EventArgs
{
public DateTime SavedAtUtc { get; }
public SavedEventArgs(DateTime savedAtUtc) => SavedAtUtc = savedAtUtc;
}
public class Document
{
public event EventHandler<SavedEventArgs>? Saved;
protected virtual void OnSaved(SavedEventArgs e) => Saved?.Invoke(this, e);
public void Save()
{
// ... actual save logic here ...
OnSaved(new SavedEventArgs(DateTime.UtcNow));
}
}
// A subclass extends the raise WITHOUT touching Document's source
public class AuditedDocument : Document
{
protected override void OnSaved(SavedEventArgs e)
{
Console.WriteLine($"[AUDIT] Document saved at {e.SavedAtUtc:O}");
base.OnSaved(e); // still raises Saved for Document's own external subscribers
}
}
class Program
{
static void Main()
{
var doc = new AuditedDocument();
doc.Saved += (sender, e) => Console.WriteLine($"External subscriber notified: {e.SavedAtUtc:O}");
doc.Save();
// [AUDIT] Document saved at 2026-08-30T...
// External subscriber notified: 2026-08-30T...
}
}
Notice what AuditedDocument did not need: it never touched Document's source code, never re-declared the Saved event, and never duplicated the raising logic. It overrode one method, ran its own logic, and called base.OnSaved(e) to keep the original behavior intact. This is precisely why the pattern uses a method instead of raising the event inline everywhere it's needed — the method is the extension point.
Raising an event with a bare primitive scattered across the class is like a reporter shouting facts out a window whenever something happens — no format, no record, and if a second fact needs shouting later, everyone downstairs has to relearn what to listen for. The standard pattern is a proper newsroom: every story goes through the same editorial desk (the OnXxx method) in a standard format (the EventArgs class). A regional bureau (a subclass) can intercept the story before it goes out, add its own annotation, and forward it along — without reinventing the news wire.
A subtle race condition haunted older C# codebases that wrote the null check as a separate statement:
// The old, unsafe pattern — do NOT write this
if (PriceChanged != null)
{
// if another thread runs "PriceChanged -= lastHandler" right here,
// between the null check and the call below, this throws NullReferenceException
PriceChanged(this, e);
}
Between the null check and the invocation, another thread could unsubscribe the last remaining handler, and the field would be null by the time the second line runs. The ?.Invoke(...) pattern you've been using since lesson 105 avoids this entirely: the compiler copies the delegate field into a temporary local variable first, performs the null check against that local copy, and invokes the copy — not the field. Because delegates are immutable (recall lesson 097: += and -= always produce a new combined delegate instance rather than mutating one in place), that local copy can't change out from under you mid-call. ?.Invoke isn't just a null-guard convenience — it's the thread-safe way to raise an event.
Subscribing to an event creates a reference from the publisher to the subscriber — the publisher's invocation list holds the subscriber's method and, for an instance method, the subscriber object itself. If the publisher lives longer than the subscriber is supposed to (a long-lived service, a static event, a UI window that outlives a dialog it once observed) and the subscriber never calls -=, the garbage collector can never reclaim the subscriber: something is still holding a live reference to it, even though your code has otherwise "let go" of it. This is known as the lapsed listener problem, and it's one of the most common causes of unexplained memory growth in long-running .NET applications.
The rule is simple: any subscriber whose lifetime is shorter than the publisher's must unsubscribe. If the subscriber implements IDisposable, unsubscribing in Dispose() is the standard place to do it — it guarantees the publisher's reference is released exactly when the subscriber itself is supposed to be released, not sometime later at the GC's convenience.
public class PriceLogger : IDisposable
{
private readonly StockTicker _ticker;
public PriceLogger(StockTicker ticker)
{
_ticker = ticker;
_ticker.PriceChanged += OnPriceChanged; // publisher now references this PriceLogger
}
private void OnPriceChanged(object? sender, PriceChangedEventArgs e)
=> Console.WriteLine($"Logged: {e.NewPrice:C}");
public void Dispose()
=> _ticker.PriceChanged -= OnPriceChanged; // release the reference explicitly
}
If _ticker is a long-lived, application-scoped object and many short-lived PriceLogger instances subscribe to it without ever calling Dispose(), every one of them stays alive in memory for as long as _ticker does — even after the rest of the application has stopped using them.
OnXxx is the same as subscribing with +=" — it isn'tOverriding the protected OnXxx method is something only a subclass can do, at compile time, by inheriting from the class. Subscribing with += is something any external code holding a reference to the object can do, at any point at runtime. They're two independent extension mechanisms that happen to compose together: a subclass overrides OnXxx to add its own behavior, and then typically calls base.OnXxx(e) so that external subscribers using += still get notified too.
EventArgs.EmptyPlenty of events genuinely carry no data beyond "it happened" — a Closed event, for instance. In that case, don't invent an empty subclass; use the base EventArgs type directly and pass the shared EventArgs.Empty instance instead of allocating a new one every time:
public event EventHandler? Closed;
protected virtual void OnClosed(EventArgs e) => Closed?.Invoke(this, e);
// raised with: OnClosed(EventArgs.Empty);
Subscribing in a constructor or setup method and never calling -= anywhere, especially when the subscriber is meant to be temporary (a dialog, a request-scoped handler, an item in a list that gets removed).
Unsubscribe exactly when the subscriber's own lifetime ends — in Dispose() for an IDisposable subscriber, or explicitly at the point the subscriber is no longer needed.
public instead of protected public virtual void OnPriceChanged(...) — this lets any external code call the raiser directly and fake the event firing, exactly the encapsulation hole lesson 105's event keyword was designed to close.
Keep the raiser protected — only the declaring class and its subclasses can call it; external code is restricted to += and -=, same as any other event.
OnXxx and forgetting to call base.OnXxx(e)A subclass overrides the raiser to add its own logic, but never calls the base implementation — the event silently stops firing for every external subscriber, with no compiler warning.
Unless you have a deliberate reason to suppress the event under specific conditions, always call base.OnXxx(e) from an override, typically as the last line, so the original raising behavior still runs.
EventArgs subclass plus protected virtual OnXxx — for any event exposed on a public class, especially one that might be subclassed, or whose payload might reasonably grow over time.EventHandler<T> over a primitive, raised inline with ?.Invoke) is still perfectly reasonable for a small, sealed, internal-only class where the event is raised from exactly one place and no subclass will ever need to extend it.?.Invoke) apply equally to both.protected virtual OnXxx = the one front desk every raise passes through — subclasses can intercept there.+=) = borrowing a reference from the publisher — always return it with -= when you're done.?.Invoke = the thread-safe way to raise, always — it copies the delegate before checking and calling it.
EventArgs subclass and routes every raise through one protected virtual OnXxx method.override + base.OnXxx(e).?.Invoke(...) — it's the thread-safe pattern, since delegates are immutable and the null-conditional operator works against a local copy of the field.-= — typically in Dispose() — or it leaks memory (the lapsed listener problem).protected, never public — external code should only ever get += / -=, exactly as lesson 105 established.You've seen the full standard event pattern and two real gotchas that come with it. Let's confirm the details.
1. Why does the standard pattern wrap event data in a dedicated EventArgs subclass instead of passing a primitive value directly?
Correct: B
Why B is correct: A dedicated class can grow new properties freely; the event's type — EventHandler<TEventArgs> — never has to change, so existing subscriber handler signatures keep compiling untouched.
Why A is incorrect: There's no meaningful performance difference between invoking with a class versus a primitive — the reason is signature stability, not speed.
Why C is incorrect: Lesson 105's EventHandler<decimal> example proves the compiler allows primitives just fine — this is a convention, not a language requirement.
Why D is incorrect: Using a class for event data has no bearing on threading; thread safety comes from the ?.Invoke raising pattern instead.
Reinforcement: Stability under change is the whole reason for the EventArgs convention.
2. Why is the raiser method (OnXxx) declared protected virtual rather than public or non-virtual?
Correct: B
Why B is correct: protected means only the declaring class and its subclasses can call it, keeping external code limited to +=/-= as intended. virtual gives subclasses a genuine, supported extension point.
Why A is incorrect: There's no such compiler rule — OnXxx is purely a naming convention; the accessibility and virtuality are deliberate design choices, not language requirements.
Why C is incorrect: It has a very real functional effect, as shown in the "Mistake 2" section — making it public reopens the exact encapsulation hole event was meant to close.
Why D is incorrect: Accessibility modifiers have nothing to do with whether a method can invoke a delegate — any method can, regardless of its access level.
Reinforcement: protected virtual is doing two separate, deliberate jobs at once: encapsulation and extensibility.
3. A long-lived StockTicker object exists for the entire lifetime of an application. A short-lived PriceLogger subscribes to its PriceChanged event but is never unsubscribed, even after the code stops using the PriceLogger. What happens?
Correct: B
Why B is correct: Subscribing adds the PriceLogger instance to StockTicker's invocation list — a live reference. As long as StockTicker is reachable, so is everything in its invocation list, regardless of whether anything else in the application still references the PriceLogger. This is the lapsed listener memory leak.
Why A is incorrect: This ignores the reference held by the publisher's invocation list — the GC can't collect an object that's still reachable through any live reference, including this one.
Why C is incorrect: .NET event subscriptions never expire automatically — they last until explicitly removed with -= or the publisher itself becomes unreachable.
Why D is incorrect: The handler is still validly subscribed and will keep running successfully every time the event fires — nothing throws; the bug is a silent memory leak, not a crash.
Reinforcement: A subscriber whose lifetime is shorter than the publisher's must always unsubscribe.
4. Why is SomeEvent?.Invoke(this, e) considered thread-safe, while if (SomeEvent != null) SomeEvent(this, e); is not?
Correct: B
Why B is correct: ?.Invoke evaluates the event field once, into a compiler-generated temporary, then checks and calls against that copy. Because +=/-= always produce a brand-new delegate instance rather than mutating one in place, the copy is safe from concurrent modification during the call.
Why A is incorrect: ?.Invoke does not take a lock — its safety comes from working against an immutable local copy, not from synchronization.
Why C is incorrect: if statements themselves are fine; the problem is specifically the gap in time between a separate null-check statement and a separate invocation statement, which the compiler-generated local copy in ?.Invoke eliminates.
Why D is incorrect: The two-statement version has a genuine, exploitable race condition that can throw a NullReferenceException under concurrent unsubscription — this is a real, documented difference.
Reinforcement: Always raise events with ?.Invoke — it's not just shorter, it's the safe pattern.
You now know the full standard .NET event pattern — the same shape behind nearly every event in the framework. Next: pulling events out to the design level, using them to decouple independent parts of an application from each other.
dotnetmadeeasy.com — Learn C# and .NET, the right way.