Combining Multiple Methods with Multicast Delegates in C#

The Power of Broadcasting Method Calls

Imagine building an order processing system where placing an order needs to trigger multiple actions: update inventory, send a confirmation email, log the transaction, and notify the warehouse. You could call each method individually, but that tightly couples your order logic to every downstream system. When you add fraud detection or analytics, you'd need to modify the order placement code again.

Multicast delegates solve this by letting you combine multiple method references into a single invokable unit. When you invoke a multicast delegate, it automatically calls all attached methods in sequence. This decouples the caller from knowing which operations need to happen—it just broadcasts the event, and subscribers handle their own responsibilities. It's the foundation for C#'s event system and callback patterns.

You'll learn how multicast delegates work, how to combine and remove methods, handle exceptions safely, and apply them to real scenarios like event handling and notification systems. By the end, you'll be able to build flexible, loosely coupled systems using delegate chaining.

How Multicast Delegates Work

A multicast delegate maintains an internal invocation list—a collection of method references. When you use the += operator, you add a method to this list. When you invoke the delegate, it calls each method in the order they were added. This is fundamentally different from a single-cast delegate that references only one method.

The key insight is that delegates in C# are immutable. When you use += or -=, you're creating a new delegate instance with an updated invocation list. This immutability makes delegates thread-safe for adding and removing subscribers, though the methods they invoke might not be thread-safe themselves.

BasicMulticast.cs
// Define a delegate type
public delegate void LogHandler(string message);

public class Logger
{
    public static void LogToConsole(string message)
    {
        Console.WriteLine($"[Console] {message}");
    }

    public static void LogToFile(string message)
    {
        Console.WriteLine($"[File] Writing: {message}");
        // Actual file writing would go here
    }

    public static void LogToDatabase(string message)
    {
        Console.WriteLine($"[Database] Logging: {message}");
        // Database insert would go here
    }

    public static void Demo()
    {
        // Create multicast delegate by combining methods
        LogHandler logger = LogToConsole;
        logger += LogToFile;
        logger += LogToDatabase;

        // Single invocation calls all three methods
        logger("Application started");

        Console.WriteLine("\nRemoving file logger:");
        logger -= LogToFile;

        logger("Processing data");
    }
}

// Output:
// [Console] Application started
// [File] Writing: Application started
// [Database] Logging: Application started
//
// Removing file logger:
// [Console] Processing data
// [Database] Logging: Processing data

The logger delegate starts with one method and grows to three using +=. A single call to logger invokes all three logging methods in order. When you remove LogToFile with -=, subsequent invocations skip it. This pattern lets you dynamically configure which operations happen without hardcoding the calls.

Building a Notification System

Multicast delegates excel at implementing observer patterns and event broadcasting. Here's a practical example of a notification system where multiple subscribers react to order events. Each subscriber handles its own concern without the order processor needing to know who's listening.

OrderNotification.cs
public class Order
{
    public int OrderId { get; set; }
    public decimal Total { get; set; }
    public string CustomerEmail { get; set; }
}

// Delegate definition
public delegate void OrderPlacedHandler(Order order);

public class OrderProcessor
{
    // Multicast delegate field
    public OrderPlacedHandler OrderPlaced;

    public void PlaceOrder(Order order)
    {
        Console.WriteLine($"Processing order {order.OrderId}...\n");

        // Business logic here
        ValidateOrder(order);
        SaveToDatabase(order);

        // Notify all subscribers
        OrderPlaced?.Invoke(order);
    }

    private void ValidateOrder(Order order)
    {
        Console.WriteLine($"Order {order.OrderId} validated");
    }

    private void SaveToDatabase(Order order)
    {
        Console.WriteLine($"Order {order.OrderId} saved to database");
    }
}

// Subscriber: Email service
public class EmailService
{
    public void SendConfirmation(Order order)
    {
        Console.WriteLine($"Email sent to {order.CustomerEmail} for order {order.OrderId}");
    }
}

// Subscriber: Inventory service
public class InventoryService
{
    public void UpdateStock(Order order)
    {
        Console.WriteLine($"Inventory updated for order {order.OrderId}");
    }
}

// Subscriber: Analytics service
public class AnalyticsService
{
    public void TrackOrder(Order order)
    {
        Console.WriteLine($"Analytics tracked: Order {order.OrderId}, " +
                          $"Total ${order.Total:F2}");
    }
}

// Usage
public class Program
{
    public static void Main()
    {
        var processor = new OrderProcessor();
        var emailService = new EmailService();
        var inventoryService = new InventoryService();
        var analyticsService = new AnalyticsService();

        // Subscribe all services
        processor.OrderPlaced += emailService.SendConfirmation;
        processor.OrderPlaced += inventoryService.UpdateStock;
        processor.OrderPlaced += analyticsService.TrackOrder;

        // Place order - all subscribers get notified
        var order = new Order
        {
            OrderId = 12345,
            Total = 299.99m,
            CustomerEmail = "customer@example.com"
        };

        processor.PlaceOrder(order);
    }
}

The OrderProcessor doesn't know or care how many subscribers exist. It just invokes OrderPlaced, and each service handles its responsibility. This makes it trivial to add a new subscriber like a warehouse service without modifying OrderProcessor. The ?. operator safely handles the case where no subscribers are attached.

Handling Exceptions in Multicast Delegates

A critical gotcha with multicast delegates is exception handling. If any method in the invocation list throws an exception, the chain stops immediately, and subsequent methods don't execute. For reliability, you should iterate through GetInvocationList and handle exceptions individually.

SafeInvocation.cs
public delegate void DataProcessor(string data);

public class SafeMulticastDemo
{
    public static void ProcessA(string data)
    {
        Console.WriteLine($"ProcessA: {data}");
    }

    public static void ProcessB(string data)
    {
        Console.WriteLine($"ProcessB: Starting...");
        throw new InvalidOperationException("ProcessB failed!");
    }

    public static void ProcessC(string data)
    {
        Console.WriteLine($"ProcessC: {data}");
    }

    public static void UnsafeInvocation()
    {
        Console.WriteLine("=== Unsafe invocation ===");
        DataProcessor processor = ProcessA;
        processor += ProcessB;
        processor += ProcessC;

        try
        {
            processor("test data");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Exception caught: {ex.Message}");
        }
        Console.WriteLine("ProcessC never executed!\n");
    }

    public static void SafeInvocation()
    {
        Console.WriteLine("=== Safe invocation ===");
        DataProcessor processor = ProcessA;
        processor += ProcessB;
        processor += ProcessC;

        if (processor != null)
        {
            foreach (DataProcessor handler in processor.GetInvocationList())
            {
                try
                {
                    handler("test data");
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"Exception in {handler.Method.Name}: " +
                                      $"{ex.Message}");
                }
            }
        }
        Console.WriteLine("All handlers executed!\n");
    }
}

// Output from UnsafeInvocation:
// ProcessA: test data
// ProcessB: Starting...
// Exception caught: ProcessB failed!
// ProcessC never executed!
//
// Output from SafeInvocation:
// ProcessA: test data
// ProcessB: Starting...
// Exception in ProcessB: ProcessB failed!
// ProcessC: test data
// All handlers executed!

The unsafe approach stops at ProcessB's exception, never reaching ProcessC. The safe approach iterates each delegate manually and catches exceptions individually, ensuring all subscribers execute even if some fail. This is crucial for event systems where one failing subscriber shouldn't break others.

Mistakes to Avoid

Using = instead of +=: A common mistake is using = to add subscribers, which replaces the entire invocation list instead of adding to it. The previous subscribers are lost. Always use += to add and -= to remove delegates. If you accidentally use =, only the last assigned method will execute.

Ignoring return values: When a multicast delegate has a return type, only the last invoked method's return value is accessible. All previous return values are discarded. If you need all return values, iterate through GetInvocationList manually and collect results in a list or array.

Not handling exceptions: As shown earlier, exceptions stop the invocation chain. In production code, especially for events, always iterate GetInvocationList with try-catch blocks to ensure all subscribers execute reliably. This prevents one buggy subscriber from breaking the entire system.

Creating memory leaks with events: If you subscribe a long-lived object's method to a short-lived object's delegate, you create a reference that prevents garbage collection. Always unsubscribe with -= when done, or use weak event patterns for long-lived subscriptions.

Build Your Own Event System

Let's create a working publish-subscribe system using multicast delegates. This example demonstrates thread-safe event raising and proper subscription management. You'll see how multiple subscribers can react to events independently.

Steps

  1. Create the project: dotnet new console -n DelegateEvents
  2. Navigate into it: cd DelegateEvents
  3. Replace Program.cs with the code below
  4. Update DelegateEvents.csproj as shown
  5. Execute: dotnet run
DelegateEvents.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
// Event data
record StockPriceChanged(string Symbol, decimal Price);

// Publisher
class StockTicker
{
    public delegate void PriceChangedHandler(StockPriceChanged data);
    public event PriceChangedHandler? PriceChanged;

    public void UpdatePrice(string symbol, decimal price)
    {
        Console.WriteLine($"\n[StockTicker] Price update: {symbol} = ${price}");

        // Thread-safe event raising
        PriceChanged?.Invoke(new StockPriceChanged(symbol, price));
    }
}

// Subscribers
class AlertService
{
    public void OnPriceChanged(StockPriceChanged data)
    {
        if (data.Price > 150m)
            Console.WriteLine($"[Alert] {data.Symbol} exceeded $150!");
    }
}

class Logger
{
    public void OnPriceChanged(StockPriceChanged data)
    {
        Console.WriteLine($"[Log] {DateTime.Now:HH:mm:ss} - " +
                          $"{data.Symbol}: ${data.Price}");
    }
}

class Portfolio
{
    public void OnPriceChanged(StockPriceChanged data)
    {
        Console.WriteLine($"[Portfolio] Recalculating value for {data.Symbol}");
    }
}

// Demo
var ticker = new StockTicker();
var alert = new AlertService();
var logger = new Logger();
var portfolio = new Portfolio();

// Subscribe
ticker.PriceChanged += alert.OnPriceChanged;
ticker.PriceChanged += logger.OnPriceChanged;
ticker.PriceChanged += portfolio.OnPriceChanged;

// Trigger events
ticker.UpdatePrice("AAPL", 145.50m);
ticker.UpdatePrice("GOOGL", 155.75m);

// Unsubscribe one
ticker.PriceChanged -= alert.OnPriceChanged;
Console.WriteLine("\n--- Alert service unsubscribed ---");

ticker.UpdatePrice("MSFT", 320.00m);

Console

[StockTicker] Price update: AAPL = $145.50
[Log] 14:22:35 - AAPL: $145.50
[Portfolio] Recalculating value for AAPL

[StockTicker] Price update: GOOGL = $155.75
[Alert] GOOGL exceeded $150!
[Log] 14:22:35 - GOOGL: $155.75
[Portfolio] Recalculating value for GOOGL

--- Alert service unsubscribed ---

[StockTicker] Price update: MSFT = $320.00
[Log] 14:22:35 - MSFT: $320.00
[Portfolio] Recalculating value for MSFT

The output shows how all three subscribers react to the first two price updates. After unsubscribing the alert service, it stops receiving notifications while the logger and portfolio continue working. This demonstrates the flexibility of multicast delegates for building loosely coupled event systems.

Testing Strategies

Testing multicast delegates requires verifying that all subscribers execute and handle their responsibilities correctly. The challenge is isolating behavior when multiple methods are chained together. You can use test doubles and invocation tracking to verify delegate behavior.

One effective approach is to create test subscriber methods that record their invocations. Attach these to your delegate, invoke it, and assert that each test method was called with the expected parameters. This validates the invocation chain without depending on real dependencies.

Tests/DelegateTests.cs
using Xunit;

public class MulticastDelegateTests
{
    public delegate void MessageHandler(string message);

    [Fact]
    public void AllSubscribersExecute_WhenDelegateInvoked()
    {
        // Arrange
        var firstCalled = false;
        var secondCalled = false;
        var thirdCalled = false;

        MessageHandler handler = msg => firstCalled = true;
        handler += msg => secondCalled = true;
        handler += msg => thirdCalled = true;

        // Act
        handler("test");

        // Assert
        Assert.True(firstCalled, "First handler should execute");
        Assert.True(secondCalled, "Second handler should execute");
        Assert.True(thirdCalled, "Third handler should execute");
    }

    [Fact]
    public void SubscribersExecuteInOrder()
    {
        // Arrange
        var executionOrder = new List();

        MessageHandler handler = msg => executionOrder.Add(1);
        handler += msg => executionOrder.Add(2);
        handler += msg => executionOrder.Add(3);

        // Act
        handler("test");

        // Assert
        Assert.Equal(new[] { 1, 2, 3 }, executionOrder);
    }
}

These tests verify that all subscribers execute and do so in the correct order. The first test uses boolean flags to track invocations, while the second captures execution sequence in a list. This pattern works well for validating multicast behavior in unit tests.

Quick Answers

What happens if one method in a multicast delegate throws an exception?

The exception stops the invocation chain, and subsequent delegates won't execute. To prevent this, manually iterate through GetInvocationList and wrap each call in a try-catch block. This ensures all subscribers run even if one fails.

How do return values work with multicast delegates?

Only the last delegate's return value is accessible when you invoke a multicast delegate. Earlier return values are discarded. If you need all return values, iterate through GetInvocationList manually and collect results in a list or array.

Is multicast delegate invocation order guaranteed?

Yes, delegates execute in the order they were added using += operator. The invocation list maintains insertion order. However, if subscribers remove and re-add themselves, they move to the end of the list.

Can I use multicast delegates with async methods?

Yes, but invoking them directly only awaits the last delegate. Use GetInvocationList with Task.WhenAll to await all async delegates properly. This ensures all asynchronous operations complete and captures any exceptions from all invocations.

What's the difference between += and = for delegates?

The += operator adds a method to the invocation list, while = replaces the entire list. Using = discards all previous subscribers, which is usually unintended. Always use += to subscribe and -= to unsubscribe from multicast delegates.

Are multicast delegates thread-safe?

Delegates themselves are immutable, so += and -= are thread-safe for reference updates. However, the methods they invoke might not be thread-safe. Use thread-safe event patterns or locks if subscribers access shared state concurrently.

Back to Articles