Last Updated: May 17, 2026
EventArgs is the small base type every C# event uses to carry data from the publisher to its subscribers. By itself it carries nothing, which is the point: it gives every event a uniform shape so subscribers can be written, registered, and unregistered the same way regardless of what the event is about. The interesting work happens when you subclass EventArgs to add the fields your specific event needs, and that is what this lesson covers.
EventArgs Is EmptyOpen the definition of System.EventArgs and there is almost nothing inside it. The class declares a single static Empty field and a parameterless constructor. No properties, no methods, no virtual members. It looks pointless at first glance, and yet every event in the .NET libraries inherits from it.
The reason for the emptiness is that EventArgs is a marker, not a container. Its job is to give every event handler the same general shape so callers can be written generically. The EventHandler delegate signature is (object? sender, EventArgs e), and EventHandler<TEventArgs> is (object? sender, TEventArgs e) where TEventArgs is some type derived from EventArgs. Both shapes guarantee that the second argument is "the event's payload, in a type that subscribers can recognize."
If EventArgs had its own data, every event in .NET would have to carry that data even when it wasn't relevant. By keeping the base type empty, the framework gives you a clean slate. Your custom event carries exactly the fields it needs, no more.
There is also a small efficiency angle. When an event has no data to carry (a "fired" notification with no context), publishers can reuse the singleton EventArgs.Empty instead of allocating a fresh instance on each raise. The base type doubles as the "no payload" payload.
The Cleared event uses the non-generic EventHandler, which takes a plain EventArgs. There is nothing to say about a clear operation beyond "it happened," so the publisher passes EventArgs.Empty and avoids creating a new object on every clear. For events that do carry data (which is almost all of them), the publisher allocates a custom subclass instead.
EventArgs to Carry DataMost real events have something to say. When an item is added to a cart, subscribers want to know which item was added and how many. When an order status changes, they want to know both the old status and the new one. The standard way to deliver that information is to subclass EventArgs.
The pattern is simple. Declare a new class that derives from EventArgs, add the fields the event needs as properties, and provide a constructor that fills them in. Then pair it with EventHandler<TEventArgs> so the delegate type matches.
ItemAddedEventArgs is a small data class with three properties, all set through its constructor. The Cart.AddItem method creates a fresh args instance every time it raises the event, and each subscriber reads whatever properties it cares about. A logger might read all three; an analytics subscriber might read only Price; a UI subscriber might read ProductName and Quantity for a toast notification. The args object decouples them.
The class lives next to the publisher. By convention, you put the args class in the same file or namespace as the event that raises it. The name follows a strict pattern: {EventName}EventArgs, with no underscores or "Args" suffix dropped. An event named ItemAdded gets an args class named ItemAddedEventArgs. An event named OrderStatusChanged gets OrderStatusChangedEventArgs. Readers learn to expect the pattern, and tooling like IntelliSense picks it up cleanly.
The flow looks like this when the cart raises the event:
One args instance flows through every subscriber in turn. They all see the same object: if any of them changes a field, the rest will see the changed value.
You do not derive from EventArgs because the runtime forces you to. Nothing in EventHandler<TEventArgs> requires it in modern .NET; the constraint was relaxed years ago. You derive from EventArgs because it is the conventional contract, and following the convention is how your event fits into a codebase that already uses events the standard way. Subscribers, tooling, and analyzers all assume the pattern.
Look again at ItemAddedEventArgs and notice what is missing: setters. The properties are declared with { get; } only, and the constructor is the only place they get assigned. That is deliberate. The same args instance is delivered to every subscriber, in order, and any mutation by one subscriber would surprise the rest.
Here is the bug, in a form that compiles and runs:
The logger never saw the real comment. The first subscriber mutated Comment on the shared args object, and the second subscriber inherited the change. This is a real bug in real codebases. It is also the kind of bug that hides for months because the subscribers are usually registered in separate files by separate teams, and the order in which they run isn't obvious.
The fix is to make the payload immutable. Drop the setters, accept everything through the constructor, and the bug becomes a compile error.
Both subscribers now see the same payload, which is what every subscriber should expect when a publisher hands them an args object. If a subscriber needs to track derived state (a tally of redactions, a local copy of the comment), it stores that state on itself, not on the shared args.
Immutable args also work nicely with init-only properties (C# 9+), which let you keep the object initializer syntax while still preventing mutation after construction.
init setters are assignable only during object initialization, so the publisher can use the readable property-name syntax, and subscribers still can't write to the properties. Either style (constructor or init) is fine; the rule is just that subscribers must not be able to mutate the payload after the args leaves the publisher.
Cost: A custom args object is one small heap allocation per raise. For a chatty event in a hot loop (say, raised once per item in a long-running import), the allocations add up. For a typical UI or domain event raised a handful of times per second, the cost is invisible. If you ever do find yourself raising a custom-args event millions of times per second, the answer is usually to redesign the event, not to mutate a shared args.
Change events are everywhere in software (order status changed, stock count changed, customer address changed) and they almost always need both the old and new values. Subscribers often care about the delta, not just the final state. A subscriber that emails the customer about a status change wants to say "your order moved from Shipped to Delivered," which requires knowing both endpoints.
The standard pattern is a {X}ChangedEventArgs class with two properties: OldValue (or Old{X}) and NewValue (or New{X}). The naming reads better when the property is named after the field, so prefer OldStatus/NewStatus over the generic OldValue/NewValue.
A few details are worth pulling out. The publisher captures oldStatus before assigning the new one, because by the time the args is constructed the field has already changed. The publisher passes a timestamp into the args, which lets subscribers correlate the event with logs or other timelines without each subscriber calling DateTime.UtcNow independently. And the args carries the order ID, so a subscriber that handles status changes for many orders can route based on e.OrderId without keeping a separate mapping.
The "no change" guard at the top is worth a moment too. If the status hasn't actually changed, the publisher returns without raising. That keeps subscribers from seeing redundant events, which would otherwise look like real transitions and trigger duplicate emails or duplicate log entries. This is a small detail, but in practice it removes a surprising number of bugs.
Subscribers can be precise about which transitions they care about, because they have both endpoints:
The first subscriber only fires its email on the specific Shipped -> Delivered transition. The second subscriber fires on any cancellation but still reports which state the order was in when it was cancelled, which is useful for the refund logic. Neither subscriber needs to maintain its own copy of the previous status; the args carries it.
This same pattern works for any change event:
| Event | Old field | New field | Extra context |
|---|---|---|---|
OrderStatusChanged | OldStatus | NewStatus | OrderId, ChangedAt |
PriceChanged | OldPrice | NewPrice | ProductId, Currency |
StockChanged | OldQuantity | NewQuantity | ProductId, ChangedAt |
AddressChanged | OldAddress | NewAddress | CustomerId |
Pair each "before" with its "after," and add whatever identifying context the subscriber would otherwise have to look up. The args becomes a self-contained record of what happened.
Cancel FlagSome events are not "this happened" notifications but "this is about to happen, any objections?" questions. A CartChanging event (note the -ing, not -ed) lets subscribers veto the change before it commits, which is useful for validation, business rules, or policy enforcement. The standard way to model this is a Cancel flag on the args class.
Cancel is the one acceptable mutable property on an args object. Subscribers are meant to set it. The publisher reads it after invoking the event and decides what to do.
A few rules make this pattern work cleanly. The publisher always raises the -ing event first, checks the flag, and only proceeds if no subscriber objected. The args defaults to Cancel = false, so silence is consent: if nobody sets the flag, the change goes through. Subscribers do not "clear" each other's cancellations; once any subscriber sets Cancel = true, the change is off, regardless of what later subscribers think.
The flow looks like this:
The -ing event is the pre-event veto step in the -ing/-ed pairing, made concrete with a Cancel flag. The -ed event is the post-event notification that the change committed. If Cancel is true, the -ed event never fires.
A CancelReason companion property is a small but useful extension. Without it, the publisher only knows that someone said "no," not why. With it, the publisher can log the reason, surface it to the user, or include it in an exception. The reason is also useful for subscribers themselves: a later subscriber can read it and decide whether to add its own veto for an unrelated reason.
The .NET BCL has its own version of this base type called System.ComponentModel.CancelEventArgs. It carries just the Cancel flag and nothing else. You can inherit your custom args from CancelEventArgs instead of EventArgs when you want the cancellation flag without redefining it.
CancelEventArgs already has the Cancel property, so you inherit it for free. This is a small win in readability: readers who recognize the base class instantly know the event supports cancellation. For a one-off internal event, deriving from EventArgs and adding Cancel yourself is also fine; both choices are common in the wild.
Cancellable events are not the right tool for every "before" situation. If the only thing a subscriber would do with the veto is throw an exception, just throw the exception from a regular method call and skip the event machinery. Events are for many subscribers, where any of them might independently object. If only one subscriber ever exists and you control it, a direct method call is simpler.
Some events need more than two or three properties to make sense. A log event might need a level, a message, a category, a correlation ID, and a timestamp. A wishlist back-in-stock event might need the customer, the product, the new stock count, the price, and whether a notification has already been queued. Stuffing all of these into one args class is correct; trying to keep them as separate parameters on the event signature is not, because the event signature is fixed at (object? sender, TEventArgs e).
The trick is to design the args class with the same care you'd give any small domain class. Group related fields together. Use meaningful property names. Pass in derived values when the subscriber would otherwise have to compute them.
The args is doing real work. It carries enough context that every subscriber can render, route, or filter without reaching back into the logger. The publisher fills in TimestampUtc from DateTime.UtcNow, which means every subscriber sees the same timestamp, not a fresh one each time the subscriber calls DateTime.UtcNow after the event. Optional fields like CorrelationId and Exception are nullable so callers can omit them when they don't apply.
The same principles apply to a richer back-in-stock event:
Each subscriber reads only the fields it needs. The email subscriber wants the friendly name, the count, and the price. The analytics subscriber wants the IDs. Neither has to call back into the wishlist or look anything up; the args carries everything that mattered at the moment of the event.
A useful rule of thumb when designing args is: if a subscriber would have to ask the publisher a follow-up question to do its job, the args is missing a field. Add it. The whole point of the args is to be self-contained.
There is a flip side, of course. If you add fields that no subscriber ever reads, you waste an allocation and clutter the API. The right size for an args class is "everything a reasonable subscriber would want, nothing more." That sounds vague, but in practice it lands around three to seven properties for most events. If you are reaching twelve, the event itself is probably trying to do too much.
The conventions around event args are stricter than around most C# classes, partly because the BCL set the pattern decades ago and partly because tooling assumes the pattern. Following it makes your event types blend in. Breaking it stands out for the wrong reasons.
The naming rules are:
| Concern | Convention |
|---|---|
| Class name | {EventName}EventArgs (e.g., ItemAddedEventArgs, OrderStatusChangedEventArgs) |
| Base class | EventArgs, or CancelEventArgs when the event is cancellable |
| Namespace | Same namespace as the publisher, or a nearby Events namespace |
| Property style | Public get-only (or init-only) auto-properties |
| Constructor | One constructor that fills every property |
| Cancel flag | Named exactly Cancel, type bool, mutable |
| Old/new pairs | Old{X} and New{X}, not Previous{X}/Updated{X} |
| Timestamps | UTC, named {Action}AtUtc or TimestampUtc |
The design rules are softer, but the spirit is consistent:
public decimal LineTotal => Price * Quantity;).IDisposable, does not hold scarce resources. If you find yourself adding any of those, you're using the args as a service, which is not its job.List<string> of items is still mutable in the ways that matter, because a subscriber can call Add or Clear on the list.The last point is worth pausing on. Making the args properties read-only stops a subscriber from reassigning a field, but if the field's type is a mutable reference, the contents can still change. If your args needs to carry a collection, pass a snapshot or a read-only wrapper.
The args constructor copies the incoming items into a fresh list and exposes it through IReadOnlyList<string>. Subscribers can iterate it, count it, and read elements out of it, but they cannot add to it or clear it. If the publisher later mutates its own internal list, the args is unaffected, because the args has its own copy.
The cost of that defensive copy is real. For a small list (a handful of items), it is negligible. For a large list (hundreds or thousands of entries), it can dominate the cost of raising the event. A reasonable compromise is to pass an IReadOnlyList<T> of the publisher's own internal list when you trust the publisher not to mutate after raising. This is faster but more fragile.
Cost: Defensive copies inside an args constructor allocate a second collection per raise. For small collections this is fine. For large ones, prefer to expose the publisher's own collection as IReadOnlyList<T> and document that the publisher must not mutate it during event dispatch.
Designing args is mostly about deciding what counts as "the event." For an item-added event, the items being added are the event; the entire cart is not. The args should carry the new item, not the whole cart. A subscriber that wants the cart total can still get it through sender, which is the cart itself.
That last detail also tells you when to use sender versus the args. The sender is the publisher object, the entity that raised the event. The args is the data about what happened. Subscribers reach into sender for state that isn't specific to this raise (the cart's current total, the order's full history) and into the args for state that is specific to this raise (the item that was just added, the status that just changed). Keeping that boundary clean makes your events easy to read.
A final convention worth following: derive your args from EventArgs (or a sub-base like CancelEventArgs) even when the constraint on EventHandler<T> doesn't require it. The convention is what makes your code recognizable. Subscribers, code reviewers, analyzers, and future-you all assume that anything ending in EventArgs derives from EventArgs. Honor the assumption.
EventArgs is a near-empty base class whose only job is to give every event a uniform (sender, args) shape. Subclasses carry the actual data.EventArgs for each event that has a payload. Name the class {EventName}EventArgs and pair it with EventHandler<TEventArgs>.init-only properties set through the constructor, because the same args instance is delivered to every subscriber and any mutation by one subscriber would corrupt the view of the rest.OldStatus/NewStatus, OldPrice/NewPrice) plus enough identifying context (OrderId, ProductId) that subscribers don't have to look anything up.-ing verb pair, add a mutable bool Cancel property (or derive from System.ComponentModel.CancelEventArgs), and have the publisher check the flag after raising and skip the change when any subscriber sets Cancel = true.IReadOnlyList<T> or ReadOnlyCollection<T> when this matters.The _Delegates vs Events_ lesson steps back and compares these two related but distinct features head-to-head, looking at when to expose a delegate property directly versus when to wrap it in an event and why the language separates them at all.