BrandGhost
Events and Delegates in C#: Contracts, Subscriptions, and Notification

Events and Delegates in C#: Contracts, Subscriptions, and Notification

Events and delegates in C# answer different questions. A delegate describes compatible calls and represents methods to invoke. An event exposes a subscription boundary through which another object can register those calls. Understanding that distinction makes notification code easier to read without assuming that every subscriber completes successfully, runs asynchronously, or stays registered forever.

Consider a component that records a document revision. Its caller needs to know whether recording succeeded. Other objects might also want to update a display or record an observation. Those are related responsibilities, but they are not interchangeable. The operation owns the state change; an event can announce the change. A delegate supplies the callable contract used for that announcement.

This guide connects declaration, subscription, invocation, failure, and lifetime in one model. Documentation was checked online on October 11, 2026, using the C# specification and .NET 10 API reference. The complete console examples use the standard library and nullable annotations; they are documented, not compiled or executed here.

Events and delegates in C# are not the same thing

The C# delegate specification distinguishes a delegate type from a delegate instance. Declaring a delegate creates a type. Creating an instance associates that type with compatible callable entities. For an instance method, the callable entity includes the target instance as well as the method.

An event is not another delegate type. It is a member whose declared type must be a delegate type. The event specification defines the subscription operations and separates field-like events from events with explicitly implemented accessors. This is a language boundary, not just a preferred naming convention.

Outside the declaring type, code can use an accessible event with += and -=. It cannot directly read the event's underlying delegate, replace the list, or invoke that event as though it owned the publisher. A publisher may expose a method that causes a notification, but callers are then using that method's contract, not bypassing the event boundary.

A public delegate property would expose different authority. If its setter is public, another object could replace previously registered callbacks. If its getter returns a delegate, callers can invoke that delegate. Sometimes callback ownership is appropriate, but exposing a property does not reproduce an event's restrictions. Similar syntax does not establish equivalent encapsulation.

Concept What it describes What it does not establish
Delegate type Parameter and return contract A particular registered method
Delegate instance A callable invocation list Delivery durability or retry
Event member Subscription accessors Successful completion of every reaction
Publisher method When and how notification occurs Automatic rollback after handler failure
Subscription owner When a registration is needed Cancellation of a captured invocation

These distinctions support decoupling between components. The publisher can know the event contract without knowing a subscriber's concrete class. However, the subscriber still needs access to the publisher or another subscription surface, and both sides depend on the meaning of the payload.

A complete notification example

Start with one synchronous operation and one fact: a revision was recorded. The payload contains the document identifier and revision number. The publisher does not ask subscribers whether it should proceed. It updates its state before invoking the notification, making the event's position in the operation explicit.

#nullable enable
using System;

var revisions = new RevisionLog();
EventHandler<RevisionRecordedArgs> handler = ShowRevision;

revisions.Recorded += handler;
revisions.Record("guide");
revisions.Recorded -= handler;
revisions.Record("guide");

static void ShowRevision(object? sender, RevisionRecordedArgs args)
{
    Console.WriteLine($"{args.DocumentId}: revision {args.Revision}");
}

public sealed class RevisionRecordedArgs : EventArgs
{
    public RevisionRecordedArgs(string documentId, int revision)
    {
        DocumentId = documentId;
        Revision = revision;
    }

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

public sealed class RevisionLog
{
    private int _revision;

    public event EventHandler<RevisionRecordedArgs>? Recorded;

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

        _revision++;
        var args = new RevisionRecordedArgs(documentId, _revision);
        var handlers = Recorded;
        handlers?.Invoke(this, args);
    }
}

The first call has a registered handler; the second does not. Both calls still advance the revision. That behavior is a sample contract, not a universal requirement of events. Some APIs choose a different no-subscriber policy, but it should be visible in the operation rather than inferred from the event name.

The get-only payload properties prevent a handler from replacing these two values through this API. That is sufficient for this example because the fields are an integer and a string. Get-only properties would not make an arbitrary collection or referenced domain object deeply immutable. Payload design needs to consider what consumers can mutate through any references it exposes.

The local handlers captures the delegate used for this raise. Combined or removed delegates do not mutate an already-created delegate instance's invocation list. This helps explain subscription changes during dispatch, but it does not freeze publisher state, subscriber state, or the objects referenced by arguments. It is not a synchronization policy for the whole component.

Notice the failure boundary too. A synchronous handler exception escapes Record, and the revision has already advanced. The code does not undo that change. A production API might choose to isolate observational failures, but merely adding an event does not create a transaction spanning the publisher and every subscriber.

Field-like events and custom accessors

The declaration in the example is field-like. The compiler supplies delegate storage and add/remove accessors. Inside the declaring type, that event can be used in the permitted field-like contexts, including taking a local copy for invocation. Outside, consumers still see only subscription operations.

Custom accessors are different. They contain the statements executed for adding and removing handlers, receiving the delegate in an implicit parameter named value. The implementation might forward registrations to another component or use another storage mechanism. An explicitly implemented event is not automatically a field that the declaring class can invoke by event name.

This distinction matters when inspecting unfamiliar APIs. A += expression shows that the add accessor is called. It does not, by itself, prove that the implementation accepts duplicates, stores the exact delegate, uses strong references, or never throws. Those details belong to the actual accessor implementation and its documented contract.

The specification requires thread-safe addition and removal for ordinary compiler-provided field-like accessors. That statement should not be stretched into a promise that arbitrary handler code, payload mutation, overlapping raises, or a custom-accessor implementation is thread-safe. Registration mechanics and operation-level coordination are separate concerns.

For straightforward notifications, field-like events keep the implementation small. Custom accessors offer control but make storage, identity, removal, and concurrency policies the author's responsibility. There is no need to add custom accessors simply because an event has multiple subscribers. Extra control is useful only when there is a concrete contract to implement.

Multicast calls have an order and a failure boundary

Delegate combination concatenates invocation lists. For ordinary multicast invocation, methods are invoked synchronously in list order with the same supplied arguments. If a handler throws an exception that it does not catch, later entries are not invoked. That is a specific language rule, not a statement about what every custom dispatcher must do.

using System;
using System.Collections.Generic;

var observations = new List<string>();
Action<string> notify = message => observations.Add($"first: {message}");
notify += _ => throw new InvalidOperationException("Second handler failed.");
notify += message => observations.Add($"third: {message}");

try
{
    notify("revision recorded");
}
catch (InvalidOperationException exception)
{
    Console.WriteLine(exception.Message);
}

Console.WriteLine(string.Join(", ", observations));

This example isolates the delegate behavior from event declarations. Its structure predicts that the first handler records an observation, the second throws, and the third is skipped. Those are documentation-backed expectations, not captured execution output. The catch belongs to the caller of this synchronous delegate.

For a delegate that returns a value, ordinary multicast invocation returns the final invoked entry's value when invocation reaches that entry. Earlier values are not automatically collected. GetInvocationList returns an array of delegates; it does not itself invoke them or return the final handler's result. That difference becomes important when results represent decisions or tasks.

An event with several subscribers can therefore provide flexible reaction points, while still coupling the operation to synchronous failures under direct invocation. If every reaction is required for correctness, describe that requirement explicitly. If reactions are observational, consider whether letting one observation prevent later ones matches the intended behavior.

Notification is not request and response

A notification usually describes a fact or transition. A decision contract asks what should happen next. Both can be expressed with delegates, but their ownership differs. A revision-recorded event can tolerate zero observers; a permission decision may require one authority and a definite answer.

Mutable event arguments can carry a synchronous decision back to the raiser. The existing example of getting data back from an event handler explores that style. Its essential questions are what happens with no subscriber and how disagreements between multiple writers are resolved.

If the decision belongs to one collaborator, a callback returning a value or an interface method may express the requirement more directly. An event retains the familiar subscription model but does not automatically identify one decision owner. This is a distinction in educational contract design, not a recommendation to replace every event with a callback.

A pre-operation name such as Closing also does not supply veto semantics by itself. The publisher must inspect a decision and choose an outcome. A post-operation name such as Closed should not imply that consumers can reverse a transition that already happened. Names help readers, but implementation and documentation establish the actual boundary.

Asynchronous work changes what completion means

The conventional EventHandler shape returns void. Adding async to a compatible handler does not add a task to the caller's return contract. An async void handler cannot be awaited by its caller, and the caller cannot catch its exceptions as though they were exceptions from a normal synchronous call.

The async return-type documentation describes that boundary. Framework events often require the conventional void signature. Language events are not universally restricted to void-returning delegates, however: an event's declared delegate type can specify a different return contract.

A custom Func<Task>-shaped contract does not solve multicast completion just by changing the return type. Direct combined invocation returns only the final task when the invocation reaches the last handler. Earlier returned tasks remain separate operations. Correctly observing every task requires explicit handling of individual registrations, not ordinary multicast return semantics.

The existing task-returning event exploration covers that separate reader job. When consuming an event that cannot change its signature, subscriber-side adaptation has different limitations. Neither approach makes removal cancel previously dispatched work.

Cancellation is another separate contract. A cancellation request is cooperative, not forced termination. Distinguish a canceled operation from a faulted one and decide who owns the request. This guide intentionally does not introduce another async dispatcher: understanding why one would need an explicit policy comes before copying its implementation.

Subscription lifetime belongs to an owner

An ordinary event-backed delegate can retain an instance handler's target while the publisher remains reachable. A subscription that is useful only while a view or session is active should have a matching end boundary. The language does not automatically infer that a consumer has stopped needing notifications.

Named instance handlers can be removed using the same target and method. Anonymous handlers should be retained in a delegate variable when removal is required. Repeating visually identical lambda source is not a reliable registration-identity strategy. The first complete example retains its delegate so that the removal operation is unambiguous.

The event lifetime explanation explores retained targets in more detail. The qualification matters: a reachable publisher can retain a subscriber; an unreachable publisher/subscriber cycle is not, by that fact alone, permanently rooted. No collection timing is established by an event declaration.

Treat unsubscribe as a change to future registration membership, not a universal shutdown barrier. A publisher may already have captured a delegate snapshot, and a handler may already have started. If the component needs an explicit stop-and-drain operation, that is an additional lifecycle contract with additional implementation requirements.

Frequently asked questions

Is an event a delegate type?

No. An event is a member declared with a delegate type. The type describes compatible calls; the event exposes subscription accessors. A field-like event additionally has compiler-provided delegate storage, which explains its special use inside the declaring class.

Can a subscriber raise another object's event?

Not by directly invoking the event member from outside its declaring type. External code can add or remove accessible handlers. If the publisher exposes a public operation that raises the event, callers may use that operation under its own contract.

Does every subscriber run when an event is raised?

Not under an unconditional guarantee. Ordinary synchronous multicast invocation stops when an invoked method throws an uncaught exception. A custom publisher can implement another policy, but the policy must come from its code rather than from the event keyword.

Does GetInvocationList return every handler's result?

No. It returns an array representing the delegate's invocation list. Calling those delegates and collecting results is a separate operation. Direct multicast invocation, by contrast, exposes only the final invoked entry's return value when it completes normally.

Are event handlers always asynchronous?

No. Delegate invocation calls methods synchronously in invocation-list order. A compatible async handler can represent work that continues after it yields, but that does not make the conventional void event caller able to await completion or prove background-thread execution.

Does unsubscribing stop a handler that already started?

No such cancellation guarantee follows from removal. Removing a registration changes the stored delegate for later snapshots. Previously captured invocation lists and work already dispatched need their own coordination or cancellation contracts if stopping them is required.

Putting the model together

Events and delegates in C# become clearer when each responsibility has a name. The delegate defines compatible calls. The event governs subscription access. The publisher chooses the notification point and failure policy. A subscription owner defines how long a reaction remains useful.

That model supports small, readable notifications without confusing them with mandatory decisions, asynchronous completion, or durable delivery. The useful question is not merely whether an event can be declared. It is what the caller and subscribers are entitled to conclude when the operation returns.

An error has occurred. This application may no longer respond until reloaded. Reload