AlgoMaster Logo

Custom EventArgs

Last Updated: May 17, 2026

14 min read

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.

Why EventArgs Is Empty

Open 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.

Subclassing EventArgs to Carry Data

Most 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.

Making the Payload Immutable

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.

Old and New Values for Change Events

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:

EventOld fieldNew fieldExtra context
OrderStatusChangedOldStatusNewStatusOrderId, ChangedAt
PriceChangedOldPriceNewPriceProductId, Currency
StockChangedOldQuantityNewQuantityProductId, ChangedAt
AddressChangedOldAddressNewAddressCustomerId

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.

Cancellable Events: One Cancel Flag

Some 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.

Carrying Multiple Values Cleanly

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.

Naming and Designing Args Classes

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:

ConcernConvention
Class name{EventName}EventArgs (e.g., ItemAddedEventArgs, OrderStatusChangedEventArgs)
Base classEventArgs, or CancelEventArgs when the event is cancellable
NamespaceSame namespace as the publisher, or a nearby Events namespace
Property stylePublic get-only (or init-only) auto-properties
ConstructorOne constructor that fills every property
Cancel flagNamed exactly Cancel, type bool, mutable
Old/new pairsOld{X} and New{X}, not Previous{X}/Updated{X}
TimestampsUTC, named {Action}AtUtc or TimestampUtc

The design rules are softer, but the spirit is consistent:

  • The args carries data, not behavior. Don't put methods on it that mutate global state or call back into the publisher. Properties are fine, including computed read-only properties (public decimal LineTotal => Price * Quantity;).
  • The args is a leaf. It does not raise its own events, does not implement 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.
  • The args is throwaway. Each raise allocates a fresh instance, and the subscribers should not keep references to it after their handler returns. If a subscriber wants to remember the data, it copies what it needs onto its own state.
  • Reference-type fields inside the args should themselves be immutable (or treated as such) where possible. An args that carries a mutable 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.

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.

Summary

  • 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.
  • Subclass EventArgs for each event that has a payload. Name the class {EventName}EventArgs and pair it with EventHandler<TEventArgs>.
  • Make the payload immutable. Use get-only or 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.
  • For change events, carry both the old and new value (OldStatus/NewStatus, OldPrice/NewPrice) plus enough identifying context (OrderId, ProductId) that subscribers don't have to look anything up.
  • For cancellable events, use the -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.
  • The args is data, not behavior. It should not carry methods that mutate global state, raise other events, or hold scarce resources. It is allocated per raise and thrown away after the handlers return.
  • Defensive copies of reference-typed payload fields (lists, dictionaries) keep an args truly immutable, at the cost of one extra allocation per raise. Use 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.