BrandGhost
Designing C# Event Contracts: Payloads, Notifications, and Decisions
All articles

Designing C# Event Contracts: Payloads, Notifications, and Decisions

Understand C# event contracts with practical payload and decision examples, explicit notification timing, no-subscriber defaults, and failure boundaries.

C# event contracts describe more than which method signature a subscriber must provide. They explain what happened, which data belongs to that fact, when the notification occurs, and what the caller can conclude afterward. An event named Completed is not useful if readers cannot tell whether it announces committed state, a proposed transition, or merely the start of somebody else's asynchronous reaction.

The distinction becomes especially important when a handler can influence an operation. Announcing a completed change and asking permission for a future change are different reader jobs. Both can involve delegates, but the meaning of a payload, the default with no subscriber, and the ownership of decisions need separate treatment.

Documentation was checked online on October 11, 2026, against the C# specification and .NET 10 reference. The two complete examples use only the standard library in separate console programs. They were documented and inspected as source, not compiled or executed.

Begin with the meaning of the event

The language specification defines an event as a member enabling an object or class to provide notifications. Its type must be a delegate type. The event is not itself a delegate type, and the event keyword does not prescribe the application's business meaning.

A notification commonly answers a past-tense question: what changed? A decision contract asks a future-tense question: may this change proceed? A progress update describes an intermediate observation. These categories help readers interpret the same conventional sender-and-arguments signature without assuming identical behavior from every event.

Event names can communicate intent, but cannot enforce it. Saved sounds post-operation; Saving sounds pre-operation. The raising method determines whether state has changed before subscribers run and whether their responses influence the result. If documentation and implementation disagree, the name cannot resolve the contradiction.

The existing decoupling explanation shows how a lower-level component can expose a contract without knowing the concrete presentation layer. That separation remains useful only when both sides agree on semantics. Removing a concrete dependency does not remove the need for a shared meaning.

Contract question Notification example Decision example
What is being communicated? A version was stored A replacement is proposed
Has the transition happened? Yes, before notification No, decision still pending
Is a result required? Not from observers Yes, from a defined authority
What if nobody subscribes? Operation can still complete Explicit default or missing dependency
Can handlers change the outcome? Not by this payload API Only through a documented decision
What does failure mean? Notification failure after change Decision unavailable or operation denied

The table is a design vocabulary, not a language restriction. An API can intentionally use a different arrangement, but it should explain that arrangement. A reader should not need to inspect every subscriber to understand whether the publisher's primary operation has already occurred.

Put stable facts in notification payloads

A payload should carry the facts consumers need to interpret a notification. For a versioned document, an identifier and version number may be enough. Passing the entire mutable document object can expose more authority than a consumer needs, and creates uncertainty about whether the payload describes the original notification or the object's later state.

Get-only properties are useful for simple facts. They prevent consumers from assigning new values through those properties. They do not establish deep immutability for arbitrary referenced objects. An event argument containing a mutable list still exposes that list's behavior unless the contract deliberately restricts or copies it.

#nullable enable
using System;

var store = new DocumentStore();
store.Stored += ReportStored;
store.Store("notes");
store.Stored -= ReportStored;

static void ReportStored(object? sender, DocumentStoredArgs args)
{
    Console.WriteLine($"{args.DocumentId} stored at version {args.Version}");
}

public sealed class DocumentStoredArgs : EventArgs
{
    public DocumentStoredArgs(string documentId, int version)
    {
        DocumentId = documentId;
        Version = version;
    }

    public string DocumentId { get; }
    public int Version { get; }
}

public sealed class DocumentStore
{
    private int _version;

    public event EventHandler<DocumentStoredArgs>? Stored;

    public void Store(string documentId)
    {
        if (string.IsNullOrWhiteSpace(documentId))
        {
            throw new ArgumentException("A document identifier is required.", nameof(documentId));
        }

        _version++;
        var args = new DocumentStoredArgs(documentId, _version);
        var handlers = Stored;
        handlers?.Invoke(this, args);
    }
}

The sample uses an in-memory version counter, not persistence. Its name does not claim a database transaction or filesystem write. The educational contract is that the version advances before Stored is invoked, and the argument describes that newly assigned version.

The publisher supplies itself as sender. This is a familiar convention, not a requirement for every possible delegate-typed event. A subscriber can use the typed payload without casting the sender. If an API needs a different identity, describing that identity in the payload may be clearer than relying on consumers to infer it from an object reference.

The sample validates its identifier before changing state. It invokes nothing when validation fails. A synchronous exception from ReportStored, however, happens after the version increment. That difference is observable in the code and deserves documentation. The event does not roll the increment back or magically make state changes across handlers atomic.

No-subscriber behavior is part of C# event contracts

A field-like event without added handlers has null delegate storage. Null-conditional invocation makes no-subscriber notification a no-op in these examples. The business operation continues because it does not require a consumer to answer a question. That is an explicit sample decision.

An operation requiring authorization cannot infer approval merely from having no registered callbacks unless that is its documented rule. Sometimes the appropriate default is deny; sometimes the operation requires an injected decision dependency that cannot be missing. The important point is to make the default visible, rather than inheriting accidental behavior from a null check.

The subscription documentation describes adding and removing handlers. Those registration operations say who is listening, not whether the publisher's operation is valid with zero listeners. The meaning of empty membership remains an application-level concern.

A fact notification can support independent reactions without requiring every possible reaction to be present. That is a useful form of optional extensibility. If an essential action is hidden in an optional subscriber, however, the operation's correctness may depend on wiring that its public contract never acknowledges.

This is worth explaining to future readers in ordinary language. "Observers are optional" and "one authorization provider is required" are clearer than a broad statement that events decouple everything. They identify which dependencies are optional and which responsibilities still belong to the primary operation.

Decisions need one answer or an aggregation rule

The existing mutable event-argument example lets a presentation component influence processing. Its distinct teaching value is preserved here rather than recreating its capitalizer. The broader lesson is that a decision-writing event needs a default and a rule for conflicting writers.

When one collaborator owns the answer, a single callback returning a value can express that responsibility directly. The following example demonstrates the contrast with a notification. The permission callback decides whether a proposed size is accepted. The event announces a successful replacement afterward.

#nullable enable
using System;

var document = new SizedDocument(newSize => newSize <= 100);
document.Replaced += ReportReplacement;

Console.WriteLine(document.TryReplace(80));
Console.WriteLine(document.TryReplace(140));
document.Replaced -= ReportReplacement;

static void ReportReplacement(object? sender, SizeChangedArgs args)
{
    Console.WriteLine($"Size changed from {args.Previous} to {args.Current}");
}

public sealed class SizeChangedArgs : EventArgs
{
    public SizeChangedArgs(int previous, int current)
    {
        Previous = previous;
        Current = current;
    }

    public int Previous { get; }
    public int Current { get; }
}

public sealed class SizedDocument
{
    private readonly Func<int, bool> _mayReplace;
    private int _size;

    public SizedDocument(Func<int, bool> mayReplace)
    {
        _mayReplace = mayReplace
            ?? throw new ArgumentNullException(nameof(mayReplace));

        if (_mayReplace.GetInvocationList().Length != 1)
        {
            throw new ArgumentException("One decision callback is required.", nameof(mayReplace));
        }
    }

    public event EventHandler<SizeChangedArgs>? Replaced;

    public bool TryReplace(int proposedSize)
    {
        if (proposedSize < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(proposedSize));
        }

        if (!_mayReplace(proposedSize))
        {
            return false;
        }

        int previous = _size;
        _size = proposedSize;
        var handlers = Replaced;
        handlers?.Invoke(this, new SizeChangedArgs(previous, _size));
        return true;
    }
}

This callback is required at construction, and the sample rejects an ordinary combined invocation list with more than one entry. That check explains a structural contract; it cannot prove that a single callback's implementation consults only one source or is free of side effects. A supplied callback remains application code.

The refusal branch returns false without changing size or sending the notification. The acceptance branch changes state before notifying. A decision callback exception propagates before the change; a synchronous notification exception propagates after it. The Boolean result therefore does not mean every observer succeeded under all circumstances.

A dedicated interface could express the decision responsibility too. That adds a named abstraction and can be useful when the collaborator has several related operations. A delegate keeps a single call concise. A mutable decision event keeps subscription conventions, but requires additional rules for defaults, timing, and disagreements.

These are different ways to explain ownership, not a competitive ranking of tools. The sample uses a callback to make one-answer semantics visible while keeping optional observation separate. It does not suggest that a callback automatically solves validation, concurrency, or lifetime management.

Document failure and ordering honestly

Ordinary multicast delegate invocation calls entries synchronously in list order. The delegate specification also states that an uncaught exception prevents later entries from being invoked. A notification with three subscribers does not inherently guarantee delivery to all three.

That behavior may be acceptable for a small synchronous contract. It may be surprising when every subscriber is observational and one display failure prevents an independent audit reaction. Choosing to continue requires an explicit policy for reporting failures and deciding whether partially completed reactions affect the operation.

Order can also accidentally become a hidden business dependency. If handler B assumes handler A has changed shared state, reordering subscription can change behavior. A fact payload with stable values reduces some ambiguity, but does not stop handlers from touching shared application objects. Contract design should distinguish intended ordering from incidental registration order.

There is no universal reason to promise ordering beyond what consumers need. Promising less leaves implementation flexibility; promising more enables callers to rely on a specific sequence. The educational point is to identify the dependency before it becomes an undocumented condition of correctness.

Custom accessors add another distinction. A field-like event has compiler-provided storage and registration accessors. An explicitly implemented event runs the supplied add/remove statements and can store registrations elsewhere. Its author must explain any nonstandard duplicate, storage, or removal behavior rather than presenting it as an automatic language guarantee.

Async reactions cannot supply synchronous decisions reliably

A conventional EventHandler-based event has a void-returning call contract. An async void handler cannot be awaited by the raiser's caller. If a decision is written only after an incomplete await, the publisher can read the original argument value before that later write happens.

The async return-type documentation describes why void-returning async calls do not give the caller completion or exception observation through a returned task. A synchronous decision contract should not quietly accept asynchronous answer ownership that its signature cannot represent.

Language events can use Task-returning delegate types, but direct multicast invocation returns only the final task when it reaches the final entry. Changing the type to Func<Task> does not aggregate completion. The existing task-returning event exploration owns that separate implementation topic.

For consumers of fixed framework events, the async-handler risk guide explains the conventional boundary. Here the contract lesson is narrower: do not tell callers that notification return means all asynchronous reactions completed when there is no returned completion object establishing that fact.

Cancellation is likewise not the same as rejecting a proposed business change. A Boolean veto is a decision result. A cancellation request asks cooperative work to stop. A fault indicates failure. Keeping these meanings separate prevents a broad error handler from disguising cancellation as success or turning an ordinary denied request into an unexplained exception.

Keep the payload boundary smaller than the publisher

A useful review question is whether a subscriber needs the entire publisher object to understand the notification. In the document example, the identifier and version describe the fact directly. A consumer can record those values without reaching back into the publisher to discover what changed. That makes the notification easier to explain in isolation.

Another question is whether the argument describes one transition or whatever state happens to exist when it is inspected. A captured reference to a mutable document can later describe a different version. Passing selected values makes the intended observation clearer, although it does not provide durable storage or replay. Those would be additional responsibilities.

Keep documentation equally focused. Explain whether the event is raised for every accepted request or only for changes that actually alter state. Explain whether repeated values are meaningful. These are application choices, not rules that a subscriber can derive from EventHandler. A precise sentence about repeated operations can prevent more confusion than a large generic event helper.

When revisiting a contract, preserve distinctions that callers already use. Changing a post-change notification into a pre-change decision would alter both timing and authority. Changing a payload from a stable value to a live mutable object would alter what observations mean. Even when the delegate signature still compiles, those semantic changes deserve explicit treatment.

Frequently asked questions

What should a C# event contract document?

It should explain the notification's meaning, payload values, raising point, no-subscriber behavior, and failure policy. If consumers can influence the outcome, it also needs defaults and disagreement rules. A delegate signature alone does not answer those questions.

Must every payload derive from EventArgs?

That is a common convention for EventHandler-based APIs, and these examples follow it. The language rule is that an event has a delegate type. Other custom delegate signatures are possible, so the convention should not be described as a universal event-language restriction.

Are get-only payload properties deeply immutable?

Not automatically. They prevent reassignment through those properties, but a referenced collection or object can still expose mutation. The examples use simple integer and string facts; a richer payload needs a deliberate policy for referenced mutable state.

Is a pre-operation event automatically cancelable?

No. Naming an event Saving or Closing does not implement veto behavior. The publisher must read a decision and act on it. Its contract must explain the default with no subscriber and the result of conflicting decision writers.

Should a notification return a business decision?

A pure notification usually announces something rather than requesting an answer. If one decision is required, a callback or interface can make ownership clearer. A mutable decision event is possible, but it needs explicitly synchronous response, default, and precedence semantics.

Does notification failure undo the publisher's state change?

Not by virtue of being an event. Both examples change state before their post-change notification. A later synchronous handler exception can escape without undoing that state. Rollback or transactional behavior would need a separately implemented contract.

Make C# event contracts readable before adding machinery

A useful contract gives consumers the right conclusion at the right boundary. Payloads describe facts, decisions identify an authority, and failure policies explain what an operation returning or throwing actually means. Those details matter before custom accessors or asynchronous dispatch enter the design.

Keeping notification separate from decision ownership avoids accidental promises. The result is not a universal event framework, but a clearer relationship between the publisher's operation and the reactions that surround it.

Get the Dev Leader Newsletter

C#, .NET, and software engineering content every Saturday.

Subscribe to the newsletter
An error has occurred. This application may no longer respond until reloaded. Reload