AI WisdomArchitecture & guides β†—
HT
How Things Work

Delegates, Events & Multicast Chains

How .NET delegates are type-safe function pointers β€” and why forgetting to unsubscribe causes memory leaks.

How It Works

A delegate in .NET is not just a function pointer β€” it's a full object that carries method references, supports multicast (multiple subscribers), and is type-checked at compile time. The event keyword turns a delegate field into a publication/subscription channel with enforced access rules. Understanding how the invocation list works β€” and who owns references to whom β€” is essential for writing production-grade .NET code.

1
Delegates are type-safe method references

A delegate is an object that wraps a method reference β€” think of it as a strongly-typed function pointer. The CLR validates at compile time that the method's signature matches the delegate's signature, unlike raw function pointers in C++.

2
Multicast: the invocation list

Every delegate instance has an InvocationList β€” an array of method references. When you use +=, the CLR creates a new combined delegate (delegates are immutable). When invoked, each method in the list is called in order. This is the multicast pattern.

3
The event keyword restricts access

Declaring a field as 'event' prevents external code from doing two dangerous things: assigning directly (OnOrderPlaced = null) and invoking it (OnOrderPlaced(...)). Only the containing class can invoke the event. External code can only subscribe (+= ) and unsubscribe (-=).

4
Exception propagation in multicast chains

If any handler in the invocation list throws an unhandled exception, the remaining handlers are NOT called β€” execution stops at the faulting handler. For resilient event dispatch, call GetInvocationList() and invoke each delegate in a try/catch block.

5
Memory: the publisher holds the subscriber reference

When you subscribe with +=, the publisher's delegate holds a reference to your subscriber object (capturing 'this'). As long as the publisher is alive, the subscriber cannot be garbage-collected β€” even if all other references to it are gone. Always unsubscribe in Dispose().

Key Concepts

πŸ”—Delegate

An object encapsulating a method reference with a specific signature. Sealed subclass of System.MulticastDelegate. Can be null, combined with +, or compared for equality.

πŸ“‹Invocation List

The internal array of delegate targets inside a MulticastDelegate. GetInvocationList() returns it. += creates a new combined delegate; delegates are immutable.

πŸ”’event keyword

A modifier that restricts a delegate field: external code can only += and -=. Prevents external code from clearing all subscriptions or invoking the event directly.

πŸ“EventHandler<T>

The standard BCL delegate: void EventHandler<TEventArgs>(object? sender, TEventArgs e). Use this pattern for all .NET events β€” it's consistent and compatible with WPF, WinForms, and ASP.NET.

♻️Weak Event Pattern

WeakReference-based subscription (WeakEventManager in WPF) lets the GC collect the subscriber even if the publisher is alive. Solves the memory leak problem at the cost of complexity.

⚑?.Invoke()

The null-conditional invoke pattern. OnOrderPlaced?.Invoke(this, e) is the thread-safe way to raise an event β€” it reads the delegate into a local variable before the null check, preventing a race where another thread unsubscribes between the check and the call.

Delegates, Events & Multicast β€” Production Patterns
tsx
1// Type-safe delegate declaration β€” this IS the contract
2public delegate void OrderPlacedHandler(object sender, OrderPlacedEventArgs e);
3
4// Or use the built-in EventHandler<T> (preferred)
5public class OrderService
6{
7 // 'event' keyword = access modifier: external code can only += and -=
8 // Without 'event', anyone can do OnOrderPlaced = null; ← catastrophic
9 public event EventHandler<OrderPlacedEventArgs>? OnOrderPlaced;
10
11 public async Task PlaceOrderAsync(Order order, CancellationToken cancellationToken)
12 {
13 await _dbContext.Orders.AddAsync(order, cancellationToken);
14 await _dbContext.SaveChangesAsync(cancellationToken);
15
16 // ⚠️ this bites everyone eventually β€” always use ?.Invoke()
17 // OnOrderPlaced(this, new OrderPlacedEventArgs(order)); // throws if null
18 OnOrderPlaced?.Invoke(this, new OrderPlacedEventArgs(order));
19 }
20}
21
22// Subscriber β€” in a long-lived service, this causes a MEMORY LEAK
23// The publisher holds a ref to the subscriber via the delegate
24public class EmailNotificationService
25{
26 public void Subscribe(OrderService orderService)
27 {
28 orderService.OnOrderPlaced += HandleOrderPlaced; // += captures 'this'
29 }
30
31 // DON'T forget to unsubscribe when this service is disposed
32 public void Dispose()
33 {
34 // If OrderService outlives EmailNotificationService and you don't
35 // unsubscribe, EmailNotificationService is never GC'd β€” memory leak
36 _orderService.OnOrderPlaced -= HandleOrderPlaced;
37 }
38
39 private void HandleOrderPlaced(object? sender, OrderPlacedEventArgs e)
40 {
41 _emailClient.SendAsync(e.Order.CustomerEmail, "Order confirmed", ...);
42 }
43}
44
45// Multicast delegate β€” the invocation list
46Action<string> logger = Console.WriteLine;
47logger += (msg) => File.AppendAllText("log.txt", msg); // second handler
48logger += (msg) => _metrics.Increment("messages"); // third handler
49
50// All three are called in order of subscription
51// If handler[0] throws, handler[1] and [2] are NEVER called
52// Use GetInvocationList() to call each handler in a try/catch if you need resilience
πŸ’‘
Why This Matters

Events are everywhere in .NET: UI frameworks (WPF, WinForms, MAUI), ASP.NET Core middleware hooks, EF Core interceptors, and domain-driven design domain events all use this pattern. Getting it right β€” using ?.Invoke() for thread safety, unsubscribing in Dispose(), and handling partial failures β€” is the difference between solid production code and a memory leak waiting to happen.

Common Pitfalls

⚠Memory leak via forgotten unsubscription: if the publisher outlives the subscriber and you never unsubscribe, the delegate holds a strong reference and the subscriber is never GC'd. Dispose() MUST call -=.
⚠Exception in handler[0] silently skips handler[1..n]: multicast delegates are not exception-resilient. A single faulting handler aborts the entire invocation list. Use GetInvocationList() + per-handler try/catch for resilient dispatch.
⚠Missing 'event' keyword: without 'event', external code can do OnOrderPlaced = null β€” wiping out all subscriptions β€” or invoke the delegate directly, bypassing your null checks and business rules.
⚠Thread-safety gap: checking 'if (OnOrderPlaced != null) OnOrderPlaced(...)' has a TOCTOU race β€” another thread can unsubscribe between the null check and the call. Always use ?.Invoke() which reads the delegate reference atomically.
⚠Lambda subscriptions you can't unsubscribe: subscribing with a lambda (OnEvent += (s, e) => DoThing()) creates an anonymous delegate you can't -= later. Always capture the delegate in a named method or a field if you need to unsubscribe.
Real-World Use Cases

1The Friday Night Memory Leak

Scenario

Our ASP.NET Core background service was leaking ~50 MB per hour in production. Memory profiler showed thousands of live instances of ReportEmailer β€” a service that should have been disposed after each job run.

Problem

JobSchedulerService registered ReportEmailer instances as event subscribers (schedulerService.OnJobComplete += emailer.Notify) but never unsubscribed. Because JobSchedulerService was a Singleton (alive for the app lifetime), its delegate's invocation list held strong references to every ReportEmailer ever created. None could be GC'd.

Solution

Added IDisposable to ReportEmailer with _schedulerService.OnJobComplete -= Notify in Dispose(), and registered it as Scoped so the DI container called Dispose() after each request scope. The weak event pattern (WeakEventManager) was considered but deemed overkill for this case.

πŸ’‘

Takeaway: In .NET, 'publisher outlives subscriber' is the #1 cause of event-related memory leaks. If your subscriber is shorter-lived than the publisher, you MUST unsubscribe β€” the GC will not save you.

2Invocation List Exception Swallowing

Scenario

Our e-commerce platform had an OrderPlaced event with 4 handlers: logging, inventory reservation, email notification, and analytics. The logging handler started throwing intermittently due to a log sink outage. Result: inventory was never reserved and customers received no confirmation emails for ~2 hours.

Problem

C# multicast delegates abort the entire invocation list on first exception. LoggingHandler was at index 0. When it threw, InventoryHandler[1] and EmailHandler[2] were silently skipped. The exception was caught at the top of PlaceOrder and logged as 'logging failed' β€” nobody noticed that orders were being accepted but not fulfilled.

Solution

Replaced the single event raise with explicit GetInvocationList() iteration with per-handler try/catch. Each handler failure is logged independently and does not block subsequent handlers. Critical handlers (inventory) were moved to a separate, guaranteed execution path using an outbox pattern.

πŸ’‘

Takeaway: Never assume all handlers in a multicast chain will execute. Either wrap each in try/catch via GetInvocationList(), or use a proper message bus (MediatR, NServiceBus) for production event dispatch where partial failure handling matters.