← Open in the full interactive course (progress tracking, search & more)

You already know how to use a delegate. This lesson opens it up — the class hierarchy underneath, the exact object it wraps, and the two gotchas (exceptions mid-chain, races on +=) that only show up under production load.

Back in lessons 096 and 097 you learned that a delegate is a type-safe reference to a method, that every delegate is secretly a multicast delegate capable of holding a chain of subscribers, and that +=/-= add and remove entries from that chain. That's everything you need to use delegates correctly.

This lesson is about what happens when that knowledge isn't enough — when a production incident report says "one of our five event handlers threw, and the other four silently never ran," or a code review flags _handlers += SomeHandler; as a possible race condition, or a teammate asks why Func<Dog> can be assigned to a Func<Animal> variable without a cast. All three of those are answerable once you know exactly what a delegate is, as a real .NET type, not just how to write one.

You'll come out of this lesson able to reason precisely about multicast failure modes, thread-safety, and delegate variance — the kind of detail that separates "I can use delegates" from "I can debug delegates in production."

What Is It?

The Simple Explanation

Every delegate type you've ever declared or used — MathOperation from lesson 096, Action<string>, Func<int, bool>, even your own custom event delegate types — is, underneath the delegate keyword's friendly syntax, an honest-to-goodness class in a real inheritance chain rooted in System.Object. Nothing about a delegate is compiler magic that stops at the IL boundary; it's a first-class .NET type you can inspect, reflect over, and reason about exactly like any other object.

The Technical Definition

Every delegate type declared with the delegate keyword — including the compiler-generated ones behind Action and Func — compiles to a sealed class deriving from System.MulticastDelegate, which itself derives from System.Delegate, which derives from System.Object:

System.Object
    └── System.Delegate            (abstract)
            └── System.MulticastDelegate   (abstract)
                    └── YourDelegateType   (sealed, compiler-generated)

Every delegate type in C# — every single one — derives from MulticastDelegate, not directly from Delegate. There is no such thing, in current C#, as a "single-cast-only" delegate; the multicast capability (an internal invocation list, +=/-=, sequential invocation of every subscriber) is baked into the base class every delegate type inherits from, whether you ever actually add more than one subscriber or not.

What You Already Knew

What's New Here

Why Does It Exist?

The Problem — Surface-Level Knowledge Breaks Under Real Conditions

"Delegates call the methods you assign to them" is true, and it's enough to write correct code in the happy path. It stops being enough the moment you hit any of these, all of which are ordinary production scenarios rather than edge cases:

The Solution — Know the Real Object, Not Just the Syntax

Every one of those questions has a precise, correct answer — but only if you know what a delegate instance actually contains, how MulticastDelegate invokes its list, and how C#'s generic variance rules (which you already studied in depth in lesson 181) apply to delegate types specifically. That's the gap this lesson closes.

The key insight

None of what follows is a new feature bolted onto delegates. It's the same mechanism from 096/097, examined closely enough to explain why it behaves the way it does — which is exactly what lets you predict its behavior under conditions you haven't personally tested yet.

Big Picture

A DELEGATE INSTANCE, CONCEPTUALLY
Target
The instance a method runs on (null for a static method)
Method pointer
Which method to actually jump to
Invocation list
The array of (target, method) pairs to call, in order — even a single-subscriber delegate has one

This is the shape every delegate has, regardless of whether it's a custom type, Action<T>, or Func<T, TResult>.

How It Works

MULTICAST INVOCATION, PRECISELY
1. INVOKING A DELEGATE RUNS ITS INVOCATION LIST, ONE ENTRY AT A TIME
2. THE LOOP IS A PLAIN, SYNCHRONOUS, SEQUENTIAL LOOP
3. AN UNCAUGHT EXCEPTION IN ENTRY N ABORTS THE WHOLE INVOCATION
notify += LogToFile;     // entry 1 — runs fine
notify += SendEmail;     // entry 2 — throws
notify += UpdateMetrics; // entry 3 — NEVER RUNS

notify(); // LogToFile runs, SendEmail throws,
          // the exception propagates out of notify() immediately,
          // UpdateMetrics is skipped entirely
4. IF YOU NEED "RUN EVERYONE REGARDLESS," YOU DRIVE THE LOOP YOURSELF
foreach (Action handler in notify.GetInvocationList().Cast<Action>())
{
    try { handler(); }
    catch (Exception ex) { LogFailure(handler, ex); }
}

Simple Example

using System;

class Program
{
    static void Main()
    {
        Action pipeline = Step1;
        pipeline += Step2; // throws
        pipeline += Step3;

        Console.WriteLine($"Invocation list has {pipeline.GetInvocationList().Length} entries.");
        // 3 — every subscriber is registered, regardless of what happens later

        try
        {
            pipeline(); // Step1 runs, Step2 throws, Step3 NEVER RUNS
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine($"Pipeline aborted: {ex.Message}");
        }

        Console.WriteLine("--- Running defensively instead ---");
        foreach (Action step in pipeline.GetInvocationList().Cast<Action>())
        {
            try { step(); }
            catch (InvalidOperationException ex) { Console.WriteLine($"Step failed, continuing: {ex.Message}"); }
        }
        // Step1, then Step2 fails but is caught, then Step3 DOES run this time
    }

    static void Step1() => Console.WriteLine("Step1 ran.");
    static void Step2() => throw new InvalidOperationException("Step2 failed.");
    static void Step3() => Console.WriteLine("Step3 ran.");
}

Code → Meaning → Result: GetInvocationList().Length proves all three subscribers were registered — the failure isn't in registration, it's in invocation. Calling pipeline() directly stops dead after Step2. Only the manual foreach-with-try/catch loop gives every subscriber a turn, because that loop — not the delegate itself — is what supplies the fault isolation.

Real-World Example

An order-processing service raises an internal "order placed" notification that three independent, unrelated subsystems subscribe to: inventory reservation, email confirmation, and analytics. If the email provider has an outage, inventory reservation and analytics should still run — one subsystem's failure shouldn't silently disable the other two.

using System;
using System.Linq;

public record Order(int Id, decimal Total);

public class OrderPublisher
{
    private Action<Order>? _onOrderPlaced;

    public void Subscribe(Action<Order> handler) => _onOrderPlaced += handler;

    public void PublishOrderPlaced(Order order)
    {
        if (_onOrderPlaced is null) return;

        // Drive the invocation list manually so one failing subscriber
        // never prevents the others from running.
        foreach (Action<Order> handler in _onOrderPlaced.GetInvocationList().Cast<Action<Order>>())
        {
            try
            {
                handler(order);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Subscriber failed for order {order.Id}: {ex.Message} — continuing with remaining subscribers.");
            }
        }
    }
}

class Program
{
    static void Main()
    {
        var publisher = new OrderPublisher();
        publisher.Subscribe(o => Console.WriteLine($"Inventory reserved for order {o.Id}."));
        publisher.Subscribe(o => throw new InvalidOperationException("Email provider timeout."));
        publisher.Subscribe(o => Console.WriteLine($"Analytics recorded for order {o.Id}, total {o.Total:C}."));

        publisher.PublishOrderPlaced(new Order(1042, 89.99m));
        // Inventory reserved for order 1042.
        // Subscriber failed for order 1042: Email provider timeout. — continuing with remaining subscribers.
        // Analytics recorded for order 1042, total $89.99.
    }
}

This is exactly the gap between "how delegates behave by default" and "how a resilient pub/sub mechanism needs to behave" — and it's why real event-raising code in production systems often loops over GetInvocationList() rather than invoking the delegate directly, whenever independent subscribers genuinely shouldn't be able to break each other.

Analogy

A Relay Race With No Backup Runner

A multicast delegate's default invocation is a relay race: the baton (control flow) passes from runner to runner (subscriber to subscriber) in a fixed order, on one track (one thread). If runner #2 trips and doesn't get up, runner #3 never gets the baton — the race simply stops there. Nobody radios ahead to say "skip #2, go straight to #3."

Driving GetInvocationList() yourself with a try/catch per entry is like giving every runner their own independent track: if one falls, the others still finish their own leg, because nothing about their run depended on the failed one physically handing anything off.

Under the Hood

THREE THINGS THAT ONLY MATTER AT PRODUCTION SCALE
1. DELEGATE INSTANCES ARE IMMUTABLE — BUT THE FIELD HOLDING ONE ISN'T
2. WHY UNSYNCHRONIZED += CAN LOSE SUBSCRIBERS UNDER CONCURRENCY
// Two threads run this concurrently on the SAME field, with no lock:
_handlers += HandlerA;   // Thread 1
_handlers += HandlerB;   // Thread 2
3. DELEGATE VARIANCE — RETURN TYPES ARE COVARIANT, PARAMETERS ARE CONTRAVARIANT

Lesson 181 showed that IEnumerable<out T> is covariant because T only ever appears in output positions, and IComparer<in T> is contravariant because T only ever appears in input positions. Delegate types support the exact same generic variance mechanism — because a delegate, structurally, is also just a pure contract (a signature) with no storage of its own, the same proof from 181 applies unchanged:

public delegate TResult Producer<out TResult>(); // TResult is output-only → covariant
public delegate void Consumer<in T>(T item);      // T is input-only → contravariant

class Animal { }
class Dog : Animal { }

Producer<Dog> dogFactory = () => new Dog();
Producer<Animal> animalFactory = dogFactory;      //  covariant — a Dog producer IS a valid Animal producer

Consumer<Animal> animalHandler = a => Console.WriteLine("handled");
Consumer<Dog> dogHandler = animalHandler;         //  contravariant — anything that can handle any Animal can handle a Dog

This isn't a special case invented for delegates — it's literally why System.Func<out TResult> (single type parameter) is declared covariant in the BCL, and it's exactly why Func<Dog> dogFactory = ...; Func<Animal> animalFactory = dogFactory; compiles with no cast. The next lesson looks at Func and Action's actual BCL variance annotations directly.

Common Confusion

1. "Multicast delegates catch exceptions from subscribers automatically" — they don't, ever

There is no hidden try/catch anywhere in MulticastDelegate's invocation loop. If you want fault isolation between subscribers, you write it — via GetInvocationList() and your own loop, as shown above. Nothing about declaring a delegate, an Action, or even a C# event changes this default.

2. "Delegates are immutable, so += must be thread-safe" — the object is immutable, the field's update sequence is not

This is a genuinely easy conflation. Immutability of the delegate object only guarantees you never corrupt an in-flight invocation by adding a subscriber elsewhere — it says nothing about whether reading, combining, and writing back a field concurrently is safe. Those are two different claims, and only the first one is automatically true.

3. Delegate variance is not the same thing as implicit type conversion of the underlying types

Producer<Animal> x = dogFactory; works because the delegate type itself is generically variant — not because Dog silently converts to Animal in some new way. The underlying reference conversion (DogAnimal) has always been legal; what 181 and this lesson establish is that the compiler can prove it's safe to apply that same conversion one level up, to the generic type argument of an interface or delegate, precisely because of where the type parameter appears in the signature.

Common Mistakes

Mistake 1 — Assuming every subscriber ran just because the delegate call didn't throw

Wrapping the entire notify() call in one outer try/catch and assuming "no exception reached here" means every subscriber executed successfully.

If a later subscriber's failure to run is itself a problem worth knowing about (not just "swallow and move on"), drive GetInvocationList() yourself so you can observe and log each subscriber's outcome individually.

Mistake 2 — Mutating a shared delegate field from multiple threads with no synchronization

publicField += handler; called concurrently from several threads, with no lock and no Interlocked, then being surprised when a subscriber silently goes missing under load.

//  Unsafe under concurrent access
public Action? OnComplete;

//  Safe — guard the read-modify-write with a lock
private readonly object _lock = new();
private Action? _onComplete;
public void Subscribe(Action handler)
{
    lock (_lock) { _onComplete += handler; }
}

Treat a delegate field shared across threads exactly like any other shared mutable field — guard reads and writes with a lock (or Interlocked.CompareExchange in a retry loop) rather than trusting +=/-= alone.

Mistake 3 — Trying to make a custom delegate covariant/contravariant on a type parameter used both ways

public delegate T Transformer<in T>(T input); — this won't compile, because T appears in both an input position (the parameter) and an output position (the return type), which violates the "used exclusively one direction" rule from 181 that applies identically to delegates.

Only mark a type parameter out if it's used exclusively as output across the whole delegate signature, and in only if it's used exclusively as input — exactly the same rule you already learned for interfaces.

When Should I Use It?

Mental Model

Every delegate = Object → Delegate → MulticastDelegate → your type.
Invocation = a plain, unprotected, sequential loop — one throw stops everyone after it.
+=/-= = safe delegate objects, but an unsafe field update without a lock.
Delegate variance = the same output-only-is-covariant, input-only-is-contravariant proof from lesson 181, applied to a signature instead of an interface.

Key Takeaway


Check Your Understanding

You've gone from "delegates hold methods" to precise reasoning about invocation, thread-safety, and variance. Let's confirm it landed.

1. A delegate field has three subscribers, A, B, and C, added in that order. Invoking the delegate directly (myDelegate();) causes B to throw an uncaught exception. What happens to C?

Show answer

Correct: B

Why B is correct: MulticastDelegate invocation is an unprotected sequential loop. When B throws, the exception propagates out of the direct call immediately — the loop never reaches C. This is documented, correct .NET behavior, not a bug.

Why A is incorrect: There is no automatic fault isolation anywhere in default multicast invocation — you must build it yourself with GetInvocationList() and a per-entry try/catch.

Why C is incorrect: Invocation order is strictly the order subscribers were added; nothing reorders around an exception.

Why D is incorrect: There is no built-in retry mechanism in delegate invocation.

Reinforcement: "One throw stops everyone after it" is the single most important production gotcha about multicast delegates.

2. Two threads concurrently execute _handlers += SomeHandler; on the same field, with no lock and no Interlocked usage. What is the realistic risk?

Show answer

Correct: C

Why C is correct: += expands to read-current-value, combine, write-back — three separate steps. Two threads can both read the same original value before either writes, so the second write silently discards the first thread's update. This is a classic lost-update race condition.

Why A is incorrect: Delegate.Combine itself doesn't corrupt anything, but that safety doesn't extend to the field's read-modify-write sequence around it — that's the actual source of the race.

Why B is incorrect: Nothing about this race throws or crashes — it fails silently, which is precisely what makes it dangerous and hard to diagnose.

Why D is incorrect: The field ends up holding a valid delegate — just possibly missing one of the two handlers that should have been added.

Reinforcement: Delegate object immutability protects the objects, not the field holding a reference to one — synchronize the field yourself under concurrent access.

3. Given public delegate TResult Producer<out TResult>();, why does Producer<Animal> p = someDogProducer; (where someDogProducer is a Producer<Dog>) compile without a cast?

Show answer

Correct: B

Why B is correct: Marking TResult as out is only legal because it's used exclusively in output position across the delegate's signature — exactly the structural proof from lesson 181 that makes IEnumerable<out T> sound. A Producer<Dog> can never produce anything a caller expecting an Animal couldn't accept.

Why A is incorrect: Unrelated delegate types (like Predicate<T> and Func<T, bool>) are not implicitly convertible to each other — only generically variant type arguments of the same delegate type are.

Why C is incorrect: This conversion is checked entirely at compile time with zero runtime cast — that's precisely what makes it provably safe rather than merely hopeful.

Why D is incorrect: A shared base class alone means nothing without the out/in variance annotation — an invariant generic delegate with the same type hierarchy would not support this assignment.

Reinforcement: Delegate variance is the identical output-only/input-only proof from interface variance, just applied to a delegate's parameter and return positions.

4. You need every independent subscriber on a notification delegate to run, even if one throws. Which approach correctly achieves this?

Show answer

Correct: C

Why C is correct: Only driving the invocation list yourself gives you a chance to catch each subscriber's exception individually and continue to the next one — that's the one place fault isolation can actually be introduced.

Why A is incorrect: One outer try/catch stops the exception from crashing the caller, but it doesn't let any subscriber after the failing one run — the loop already aborted before the catch is reached.

Why B is incorrect: volatile affects memory visibility across threads, not exception propagation during invocation — it's unrelated to this problem.

Why D is incorrect: The event keyword restricts who can invoke and subscribe from outside the declaring type — it does not change the underlying invocation loop's exception behavior at all.

Reinforcement: Fault isolation between subscribers is something you build explicitly with GetInvocationList() — it is never automatic, regardless of delegate vs. event.

You can now reason precisely about multicast invocation failure, delegate field thread-safety, and delegate variance. Next: a close look at Action and Func's actual BCL declarations — including the variance annotations you just saw in action here.


dotnetmadeeasy.com — Learn C# and .NET, the right way.