Last Updated: May 17, 2026
The event keyword lets you declare an event with any delegate type, but the .NET runtime, the Base Class Library, and every framework on top of it (WinForms, WPF, ASP.NET Core, Entity Framework) all agree on a single signature for event handlers: void Handler(object? sender, TEventArgs e). This lesson is about that convention, the EventHandler and EventHandler<TEventArgs> delegate types that encode it, and the small set of naming and raising patterns that go with it.
A handler signature has to answer two questions every time a listener is invoked. Who is raising the event, and what data goes with it. The BCL's answer is the same for every event in the framework: pass the publisher as the first argument and the data as the second argument. Concretely, the signature is void(object? sender, TEventArgs e), where TEventArgs is either EventArgs (the empty payload type) or some subclass of EventArgs that carries the data.
An event is just a controlled wrapper around a delegate field. Any delegate type would technically work. So why does the framework insist on one shape?
The first reason is uniform tooling. Visual Studio, Rider, and every code-generation tool that talks to events (designer-generated WinForms code, XAML event hookups, source generators) all assume that an event handler takes (sender, e). The moment you write a custom signature, every one of those tools either breaks or starts emitting workarounds. The cost of being non-standard shows up immediately the first time a designer tries to wire up a handler for your event.
The second reason is uniform listener shape. A method that handles a Click event on a WinForms button has the same signature as a method that handles a PropertyChanged event on a view model and a method that handles a Closed event on an HttpListener. If you write a generic logger, a generic event bus adapter, or a generic test helper, you can write it once against EventHandler<TEventArgs> and use it for every event in your codebase. Custom signatures fragment that.
The third reason is framework interop. Data binding in WPF, the INotifyPropertyChanged pattern in MVVM, observer wiring in ASP.NET Core middleware, all of these read the standard signature directly. If your custom type raises an event with a non-standard delegate, you have to write adapter code to plug it into any of these systems. The convention is cheap to follow and expensive to break.
The shape itself is also pragmatic. The sender argument lets a single handler subscribe to events on multiple publishers and tell them apart at runtime, which is common in UI code where one method handles clicks from twelve buttons. The TEventArgs argument is a single object reference, which means the runtime can pass arbitrary payload data without changing the delegate's signature when the payload changes. Both arguments are reference-typed, which avoids boxing for the most common shapes.
The publisher is the first argument to every handler. The payload is the second. Every listener sees the same shape, regardless of who is raising the event or what data is attached.
The rest of this lesson treats this convention as a given and shows you the small set of types and patterns that make following it easy.
EventHandler and EventHandler<TEventArgs>The BCL ships two delegate types that encode the convention so you don't have to declare your own. Both live in the System namespace.
The non-generic EventHandler looks like this in its declaration form:
It uses EventArgs directly as the payload type, which is the framework's "no payload" marker. EventArgs.Empty is a static, reusable instance of that type, and you pass it when there's no data to carry beyond "the event happened."
The handler takes (object? sender, EventArgs e) and does whatever it wants with them. In this case it ignores both, because the event itself is enough information. When you don't have anything to pass, EventHandler plus EventArgs.Empty is the minimal, idiomatic form.
The generic EventHandler<TEventArgs> is the version you reach for whenever there's data to pass. Its declaration is:
TEventArgs is any type. The framework convention is that it derives from EventArgs, but the generic parameter itself is unconstrained, so the compiler won't stop you from using any class or struct as the payload. Stick with the convention and your type will plug into framework code that expects EventArgs subclasses; ignore it and you'll trip yourself up when you try.
EventHandler<ItemAddedEventArgs> plugs the payload type into the standard (sender, e) shape, gives you compile-time access to e.ProductName and e.Price, and removes the need to declare your own delegate type. Almost every event you write in modern C# uses EventHandler<TEventArgs> with a custom args class. The non-generic EventHandler is reserved for the small number of events that genuinely carry no data.
Cost: Each event raise allocates one EventArgs instance unless you reuse EventArgs.Empty or cache a singleton args object. For high-frequency events (per-frame UI updates, per-message bus events), consider pooling the args or designing the event to carry the data on the publisher itself.
Note that this lesson uses a simple ItemAddedEventArgs to demonstrate the generic form. Here we'll keep payloads minimal and focus on the rest of the pattern.
The names you pick for the event, the args type, and the raise method form a small grammar that any C# developer can read at a glance. The BCL follows this grammar in its own types, and the easiest path is to follow it in yours too.
The first rule is about the event name itself. An event represents something that has happened (or is about to happen) on the publisher, so the name is a verb. For events that fire after the change has already taken place, use the past tense: ItemAdded, OrderShipped, StockDepleted, ReviewPosted, OrderCancelled. The past tense signals that subscribers are reacting to a fait accompli, not influencing what's about to happen.
For events that fire before the change is committed, use the present participle (the -ing form): OrderShipping, ItemAdding, OrderCancelling. The participle signals that the action is in flight and that subscribers can still influence the outcome (typically by setting a Cancel flag on the args).
| Event timing | Tense | Example |
|---|---|---|
| After the change | Past tense verb | ItemAdded, OrderShipped, ReviewPosted |
| Before the change | Present participle | ItemAdding, OrderShipping, ReviewPosting |
The second rule is about the args type name. The args type is named after the event, with the suffix EventArgs. An ItemAdded event uses ItemAddedEventArgs. An OrderShipping event uses OrderShippingEventArgs. This pairing is so consistent in the BCL that you can usually guess the args type from the event name without checking the documentation.
The third rule is about the raise method. By convention, the method that raises the event is named OnEventName: OnItemAdded, OnOrderShipped, OnOrderShipping. The On prefix signals "this is the method that raises the event," and the rest of the name matches the event. The raise method is usually protected virtual, which is the topic of the next section.
Three names appear and they all line up: Shipped (the event, past tense), OrderShippedEventArgs (the payload), OnShipped (the raise method). Any reader walking into this code knows immediately which method raises which event, and which args type goes with it, without reading a single comment.
The three names are linked by the verb. Pick the verb once, derive the args type name from it, and derive the raise method name from it. The rest follows.
A few practical refinements come up in real code. The args type doesn't have to repeat the publisher's name (it's OrderShippedEventArgs, not OrderOrderShippedEventArgs), and the event itself doesn't repeat the publisher's name either (it's Shipped on an Order, not OrderShipped on an Order). When two events on the same publisher have similar names, the convention bends: a Cart might expose ItemAdded and ItemRemoved rather than Added and Removed, because Added alone is ambiguous when read in isolation.
protected virtual OnX Raise MethodThe raise method is what actually calls the event delegate. The convention is to put that call inside a protected virtual method named OnX, and to invoke the event only through that method, never directly.
Three things make this convention pay off:
OnX. A subclass can suppress the event, transform the args before raising, or run additional logic around the raise.protected access level keeps the method invisible to outside callers. Listeners can subscribe to the event, but only the publisher and its subclasses can raise it.The standard form looks like this:
Post doesn't raise the event directly. It calls OnReviewPosted(e), and that method does the actual Invoke. The separation looks like extra ceremony for one publisher, but it pays off immediately the first time you subclass.
The subclass overrode OnReviewPosted and changed the raising behavior without touching the event itself or the listeners. Listeners still subscribed to ReviewPosted on the base type, and they only fired when the subclass chose to call base.OnReviewPosted(e). This is the main reason the raise method is virtual: it gives subclasses a clean hook.
The flow has three layers. The first layer is whatever caller decides to raise the event (an internal method, a setter, a public API). The second layer is the OnX method, which is the single point of dispatch and the hook for subclasses. The third layer is the ?.Invoke call that actually delivers the event to subscribers.
There's one detail in the ?.Invoke(this, e) line that's worth being explicit about. The null-conditional operator (?.) checks whether the delegate field is null (which means no one has subscribed) and skips the call if it is. Without it, raising an event with no subscribers would throw NullReferenceException. The pattern also reads the delegate field atomically (the ?. captures the reference into a temporary), which sidesteps a race where another thread unsubscribes the last handler between the null check and the invocation.
The older, pre-?. form looked like this:
That snippet does the same thing the modern ?.Invoke does. You'll still see it in older codebases, but ?.Invoke(this, e) is the C# 6+ default and what new code should use.
A common variation is to expose a parameterless or overloaded OnX that constructs the args itself:
This works fine for events with no payload. For events with a payload, prefer passing the args in as a parameter so the subclass can inspect or modify them.
Changing + Changed)Some operations benefit from notifying listeners both before the action runs and after. The BCL handles this by exposing two events on the same publisher, one for each phase, with names that differ only by tense.
The naming convention is:
Changing, Shipping, ItemAdding.Changed, Shipped, ItemAdded.Listeners that want to react to the change after the fact subscribe to the past-tense event. Listeners that want to validate or cancel the change before it commits subscribe to the present-participle event.
The pre-event's args type often carries a Cancel property (typically by inheriting from System.ComponentModel.CancelEventArgs), which lets a listener veto the operation. The publisher checks that flag after raising the pre-event, and aborts the operation if any listener set it to true. Here we'll keep it minimal.
Two events, two phases. The Shipping event fires first. Any listener that sets e.Cancel = true vetoes the shipment. If no one cancels, the publisher proceeds with the actual work and then fires Shipped. The post-event never fires when the pre-event was cancelled, which is the contract callers expect.
The flow has two decision points worth naming. The first is the pre-event, where listeners get a chance to inspect and potentially cancel. The second is the cancel check, which gates whether the actual work runs and whether the post-event fires. A clean implementation always checks the cancel flag right after raising the pre-event, and always raises the post-event in the same branch that did the work.
The pre-event isn't the only way to use the pair. Even when cancellation isn't supported, a Changing event can give listeners a chance to read the previous value before it's overwritten, while the Changed event delivers the new value. The args types are usually different for the two phases, because the data of interest differs (the pre-event carries the proposed change; the post-event carries the result).
The BCL uses this pair in many places. PropertyChanging and PropertyChanged on INotifyPropertyChanging and INotifyPropertyChanged are the canonical example in WPF data binding. Closing and Closed on Form follow the same shape. CollectionChanging and CollectionChanged on observable collections do too.
Cost: Pre-event pairs double the number of args allocations per operation, and listeners on the pre-event run synchronously inside the publisher's call stack. For frequent operations (per-keystroke validation, per-item collection updates), the cost can add up. Use the pair only when listeners genuinely need a chance to react before the change.
A Cart is the natural place to see every piece of the pattern come together. The cart can raise events when an item is added, when an item is about to be removed (with a chance to cancel), and when the cart is cleared. Each event uses the conventions covered above: a verb name (past tense for after, participle for before), an args type that ends in EventArgs, a protected virtual OnX raise method, and EventHandler<TEventArgs> as the delegate type.
Walk through what the program demonstrates, one piece at a time. The Cart exposes four events. Three of them use EventHandler<TEventArgs> with a custom args type. The fourth (Cleared) uses the plain EventHandler with EventArgs.Empty, because no data needs to travel. Every event has a matching protected virtual OnX method, and every raising path goes through that method.
The AddItem flow is the simple case. The cart mutates its internal list, then calls OnItemAdded with the new item's data. The listener fires once per call, in subscription order.
The RemoveItem flow shows the pre/post-event pair. The cart raises ItemRemoving first. A listener inspects the price, decides whether to allow the removal, and sets e.Cancel accordingly. The cart checks the cancel flag and either commits the removal (and raises ItemRemoved) or returns false without changing the list. In the run above, the cheap item is removed normally, but the expensive item is held back by the listener and the removal is reported as False.
The Clear flow uses the no-payload pattern. The cart clears its list and calls OnCleared, which raises Cleared with EventArgs.Empty. No allocation per raise beyond what the framework already caches.
A subclass could override any of the OnX methods to add logging, persist an audit trail, or suppress specific events. For example, a LoggedCart : Cart that overrides OnItemAdded to write each addition to a log file would slot in without changing a line of the existing subscribers.
That mix is the typical shape of the EventHandler pattern in production C# code. Past-tense events for things that have already happened, participle events paired with cancellation args for things that are about to happen, EventHandler<T> everywhere as the delegate type, a small EventArgs subclass per event, and a protected virtual OnX method as the single dispatch point. Each piece is small. The payoff is that any other C# developer can read your event surface and know exactly how it works without checking the documentation.
void Handler(object? sender, TEventArgs e). Following it gives you free interop with framework tooling, designers, and generic helpers; ignoring it costs you that ecosystem.EventHandler is the non-generic form for events with no payload (EventArgs.Empty as the second argument). EventHandler<TEventArgs> is the generic form for every event that carries data, with TEventArgs typically derived from EventArgs.ItemAdded, OrderShipped), present participle for pre-events (ItemAdding, OrderShipping). The args type ends in EventArgs and matches the event name. The raise method is On plus the event name.protected virtual so subclasses can override it to add logging, suppress the raise, or transform args, without forcing listeners to change. Outside callers can only subscribe; only the publisher and its subclasses can raise.?.Invoke(this, args). The ?. skips the call when no listener is subscribed and reads the delegate field atomically, avoiding a race with a concurrent unsubscribe.Changing + Changed) let listeners react before and after a change. The pre-event's args often inherit from CancelEventArgs so listeners can veto the operation; the publisher checks e.Cancel and skips the work and the post-event when set.