Designing C# event contracts: payloads, notifications and decisions
A C# event only fixes a delegate signature; it says nothing about what happened, when subscribers run, or what an empty subscriber list means. Nick Cosentino separates notification events (a past-tense fact) from decision events (a future-tense question) and argues for stable payloads, a single-owner callback and honest failure boundaries.
Key points
- Event names describe intent but cannot enforce it: Saved alone does not reveal whether state is committed or still proposed.
- Keep payloads to stable facts such as id and version instead of handing out the whole entity behind get-only properties.
- A field-like event with no subscribers has a null delegate, so the raise is a silent no-op that the contract should state.
- Decision events need one owner: the sample requires a Func<int,bool> and rejects a combined invocation list with several entries.
- A synchronous handler exception stops later handlers and occurs after the state change; the publisher does not roll back.
- An async void handler cannot answer a decision, since no returned task lets the raiser observe completion or exceptions.
An event's meaning comes from the method that raises it, not from its name. Cosentino separates past-tense notifications like Stored from future-tense decisions that gate an operation, and argues a payload should carry stable facts only: the sample's DocumentStoredArgs exposes a document id and version behind get-only properties instead of handing out the whole mutable document.
Where handlers can influence the outcome, one owner must answer. The second sample requires a Func<int,bool> callback in the constructor and rejects a delegate combining several entries. With no subscribers the null delegate is skipped and the operation still succeeds. A synchronous handler exception stops the remaining handlers and arrives after the state change, and an async void handler cannot decide anything.
Real-world use
- A file-upload service that raises a Stored notification after a blob is committed, letting audit and telemetry handlers react without touching the write path.
- A pricing engine that runs one approval callback (Func<decimal,bool>) before applying a plan change and writes nothing when approval fails.
- A desktop app listening to document changes where one failing UI handler must not silently cancel unrelated reactions such as audit writes.
- A multi-tenant API where observers are optional subscribers while authorization stays a required injected dependency.
Why it matters
Deciding up front whether an event notifies or decides makes the empty-subscriber behaviour and the effect of a throwing handler predictable. Otherwise correctness quietly depends on wiring that the public contract never mentions.