C# Custom and Multicast Delegates Explained
A delegate variable doesn't have to hold just one method — it can hold a whole list of them, and call every one.
In the last lesson you defined a delegate type and pointed a single variable at a single method. That's already useful — but real applications often need more: several independent pieces of code reacting to the same thing.
Think about saving a document. You might want to: write the file to disk, update the "last saved" timestamp in the UI, and log the save for auditing. Three unrelated actions, all triggered by the same moment. Do you really want Save() to know about the UI and the logger and the disk, all mixed together?
C# delegates have a feature built in for exactly this: a single delegate variable can reference more than one method at once, and invoking it runs all of them in order. This is called a multicast delegate, and it's the mechanism that makes C# events possible.
What Is It?
The Simple Explanation
A custom delegate is simply a delegate type you declare yourself, with a signature that matches your specific need — you saw this already with MathOperation. A multicast delegate is a delegate variable holding not one method, but a chain of them, all with the same signature. Invoking it calls each one, in the order they were added.
The Technical Definition
Every delegate type you declare in C# actually derives from System.MulticastDelegate, which itself derives from System.Delegate. This means every delegate you create can hold multiple methods in its invocation list — a single method is just the special case of an invocation list with one entry.
You build up that list with the += operator (add a method) and shrink it with -= (remove a method):
public delegate void Notify(string message);
Notify onSave = LogSave;
onSave += UpdateUi;
onSave += AuditSave;
onSave("Document saved."); // calls LogSave, then UpdateUi, then AuditSave
Why Does It Exist?
The Problem — One Trigger, Many Independent Reactions
Without multicast delegates, "notify several unrelated pieces of code that something happened" means either:
- Hardcoding every reaction inside the triggering method (tight coupling —
Save()now has to know about logging, UI, and auditing directly). - Maintaining your own
List<SomeDelegate>and looping over it manually to call each one.
The Solution — Built-In Subscriber Lists
Multicast delegates give you that "list of subscribers" behavior for free. += and -= are just operator overloads that add to or remove from the invocation list — no manual list management required. This is exactly the plumbing that C#'s event keyword (covered in lesson 105) builds on: an event is a multicast delegate with extra access restrictions.
Multicast delegates let one action trigger many independent reactions, without any of those reactions knowing about each other, and without the triggering code knowing what they do. This is the seed of publish/subscribe — a pattern you'll use constantly once you reach events.
Big Picture
LogSaveUpdateUiAuditSaveHow It Works
public delegate void Notify(string message);
Notify onSave = null;
onSave += LogSave;
onSave += UpdateUi;
- The very first
+=on anulldelegate works fine — it initializes the invocation list - Each
+=appends the method to the end of the list
onSave("Document saved.");
// LogSave runs, then UpdateUi runs
onSave -= UpdateUi;
onSave("Document saved."); // only LogSave runs now
Simple Example
using System;
public delegate void Notify(string message);
class Program
{
static void LogSave(string message) => Console.WriteLine($"[LOG] {message}");
static void UpdateUi(string message) => Console.WriteLine($"[UI] Showing: {message}");
static void AuditSave(string message) => Console.WriteLine($"[AUDIT] Recorded: {message}");
static void Main()
{
Notify onSave = LogSave;
onSave += UpdateUi;
onSave += AuditSave;
onSave("Document saved.");
// [LOG] Document saved.
// [UI] Showing: Document saved.
// [AUDIT] Recorded: Document saved.
onSave -= UpdateUi; // stop updating the UI
Console.WriteLine("--- after removing UpdateUi ---");
onSave("Document saved again.");
// [LOG] Document saved again.
// [AUDIT] Recorded: Document saved again.
}
}
Code → Meaning → Result: Each += appends a method to onSave's invocation list. A single call, onSave(...), fans out to every subscriber currently on the list, in the order they were added. Removing a subscriber with -= takes it out of future invocations without touching the others.
Real-World Example
Consider a background job runner that needs to report progress to multiple listeners — a console logger, a progress bar, and a metrics collector — without knowing any of them exist.
using System;
public delegate void ProgressHandler(int percentComplete);
public class FileImportJob
{
public ProgressHandler? OnProgress; // multicast delegate field
public void Run()
{
for (int percent = 0; percent <= 100; percent += 25)
{
// simulate work...
OnProgress?.Invoke(percent); // notify every subscriber, safely
}
}
}
class Program
{
static void Main()
{
var job = new FileImportJob();
job.OnProgress += p => Console.WriteLine($"Console: {p}% done");
job.OnProgress += p => Console.Title = $"Importing... {p}%";
job.OnProgress += RecordMetric;
job.Run();
}
static void RecordMetric(int percent)
{
if (percent == 100)
Console.WriteLine("Metrics: import completed.");
}
}
Notice OnProgress?.Invoke(percent) — the null-conditional operator guards against the field still being null if nobody has subscribed yet, avoiding the classic NullReferenceException from the previous lesson's "Common Mistakes." This pattern is everywhere in real event-driven code.
Analogy
A single-method delegate is like a letter addressed to one person. A multicast delegate is a mailing list: you += to subscribe an address, -= to unsubscribe, and sending one message (invoking the delegate) delivers a copy to every address currently on the list — in signup order.
The sender doesn't maintain the list of subscribers by hand, and doesn't know or care who's on it. That's exactly the decoupling multicast delegates provide.
Under the Hood
- A
Delegateinstance in .NET is immutable —+=doesn't mutate the existing object. - Instead,
onSave += UpdateUicreates a brand-new multicast delegate instance whose invocation list is the old list plus the new method, and assigns it back toonSave. - This is why
+=and-=only work as an assignment (x += y, shorthand forx = x + y) — you're always creating and reassigning, never editing in place.
- You can inspect the chain yourself:
onSave.GetInvocationList()returns aDelegate[]— one entry per subscribed method, in call order. - Each entry is itself a single-method delegate you could invoke individually.
- Calling a multicast delegate loops through the invocation list and calls each entry, one after another, synchronously, on the calling thread.
- If one subscriber throws an exception, the remaining subscribers in the list are never called — the exception propagates immediately and interrupts the chain.
- If the delegate has a non-
voidreturn type, invoking it directly only gives you the return value of the last subscriber in the list — the others' return values are discarded (you'd have to useGetInvocationList()to inspect them all).
Common Confusion
1. "+= modifies the delegate" — not exactly
It looks like mutation, but as explained above, each += produces a new delegate object under the hood. This matters if you ever capture a delegate reference in another variable before adding a subscriber — that older reference still points at the shorter, original invocation list.
2. An unhandled exception in one subscriber kills the rest
Because invocation is a simple sequential loop, a throwing subscriber stops the chain — subscribers registered after it never run. If you need every subscriber to run regardless of failures, you must call GetInvocationList() and invoke each one yourself inside a try/catch.
3. Removing a method you never exactly added does nothing (silently)
-= removes the last matching entry from the invocation list based on target + method — if the method you're trying to remove isn't in the list, -= is a silent no-op, not an error.
Common Mistakes
Mistake 1 — Relying on a non-void multicast delegate's return value
Assuming result = multicastFunc(x) reflects all subscribers when only the last one's return value actually comes through.
For multicast scenarios, prefer void-returning delegates (or events, covered soon). If you truly need every result, call GetInvocationList() and collect each one explicitly.
Mistake 2 — Forgetting to unsubscribe
Adding a subscriber with += and never removing it when the subscribing object is no longer needed keeps that object alive (referenced) — a common source of memory leaks in long-lived publishers (more in lesson 106).
Pair every += with a matching -= once the subscriber no longer needs to listen.
Mistake 3 — Not guarding against a null invocation list before invoking
Calling onSave("x") directly on a field that no one has subscribed to yet throws a NullReferenceException.
Use onSave?.Invoke("x") — it's a habit worth building now, before you reach events, where it's essential.
When Should I Use It?
- When multiple independent pieces of code need to react to the same occurrence — progress reporting, notifications, plugin hooks.
- As the plumbing beneath events — you'll almost always reach for
eventrather than a bare public multicast field in real application code, but understanding the mechanism first makes events far less mysterious. - Avoid multicast delegates when you actually need a single, reliable return value from exactly one handler — a
Func<T>called once is simpler and clearer there.
Mental Model
+= appends. -= removes.Invoking it calls every method on the list, in order, on the calling thread.
This "one trigger, many subscribers" mechanism is exactly what powers C# events.
Key Takeaway
- Every C# delegate is technically a multicast delegate — it can hold a chain of methods, not just one.
+=adds a method to the invocation list;-=removes it. Both create a new delegate instance rather than mutating the existing one.- Invoking the delegate runs every subscriber in order, on the same thread — a thrown exception stops the remaining subscribers.
- Always guard invocation with the null-conditional operator (
onSave?.Invoke(...)) when subscribers might not exist yet. - This "list of subscribers" behavior is exactly what the
eventkeyword is built on top of, coming up later in this module.
Check Your Understanding
You've seen how a single delegate can hold and invoke multiple methods. Let's check what you've learned.
1. What does the += operator do to a delegate variable?
Show answer
Correct: B
Why B is correct: Delegate instances are immutable in .NET. += is shorthand for combining delegates with Delegate.Combine and reassigning the result to the variable.
Why A is incorrect: Nothing about the original delegate object is mutated; a new one is produced.
Why C is incorrect: That would discard existing subscribers, which is not what += does — it appends.
Why D is incorrect: += works on any delegate type, regardless of return type, though only the last subscriber's return value is observable through direct invocation.
Reinforcement: Delegate immutability is why += and -= must be assignments, not in-place mutations.
2. A multicast delegate has three subscribers: A, B, and C, added in that order. Subscriber B throws an exception when invoked. What happens?
Show answer
Correct: B
Why B is correct: Multicast delegate invocation is a simple sequential loop on the calling thread. An unhandled exception in one subscriber stops the loop immediately and propagates out — subsequent subscribers are never reached.
Why A is incorrect: Nothing in the default invocation mechanism swallows exceptions; you'd need to invoke each subscriber yourself with individual try/catch blocks to get that behavior.
Why C is incorrect: Subscribers run sequentially on one thread by default, not in parallel.
Why D is incorrect: There is no automatic retry mechanism built into delegate invocation.
Reinforcement: If you need all subscribers to run regardless of individual failures, iterate GetInvocationList() and wrap each call yourself.
3. Why is onSave?.Invoke("message") generally preferred over onSave("message") for a multicast delegate field?
Show answer
Correct: B
Why B is correct: If nobody has subscribed, the delegate field is still null. Calling it directly throws; the null-conditional operator short-circuits to doing nothing instead.
Why A is incorrect: The null-conditional operator has no effect on the sequential-vs-parallel execution model.
Why C is incorrect: It has nothing to do with exception handling inside subscribers.
Why D is incorrect: Return value capture behavior (only the last subscriber's result) is unaffected by null-conditional invocation.
Reinforcement: Guarding invocation with ?.Invoke(...) is standard practice for any delegate field that might have zero subscribers.
4. You need a delegate call that must return a value from every one of several subscribers, not just the last one. What should you do?
Show answer
Correct: B
Why B is correct: GetInvocationList() exposes each subscriber as an individual delegate; invoking them separately lets you capture every return value.
Why A is incorrect: Direct invocation of a multicast delegate only surfaces the return value of the last subscriber called; the rest are discarded.
Why C is incorrect: It's entirely possible — it just requires the explicit invocation-list approach rather than a single direct call.
Why D is incorrect: Making the delegate void means there's no return value at all, which doesn't solve the stated problem.
Reinforcement: Direct invocation is convenient but lossy for non-void multicast delegates — GetInvocationList() is the escape hatch when every result matters.
You now understand multicast delegates — the exact mechanism behind C# events. Next, you'll learn about Action, the built-in delegate type that means you'll rarely need to declare a custom void-returning delegate again.