Implementing Event-Driven Programming with Delegates in C#

Building Responsive Applications with Delegates

Delegates are type-safe function pointers that let you pass methods as arguments, store them in variables, and invoke them later. They're the foundation of event-driven programming in C#, enabling components to communicate without tight coupling. When a button gets clicked or data finishes loading, delegates make the notification system work.

Event-driven programming means your code responds to things that happen rather than following a predetermined sequence. User interactions, data arrivals, timer ticks, and system notifications all trigger events. Delegates provide the mechanism to register callbacks that execute when these events occur.

You'll learn how delegates work, how to use them for callbacks and events, when to use built-in delegate types, and how lambda expressions simplify delegate usage in modern C# code.

Understanding Delegate Fundamentals

A delegate defines a signature for methods it can reference. You declare a delegate type, create instances that point to methods matching that signature, and invoke the delegate to call those methods. This indirection lets you change behavior at runtime by swapping which method the delegate references.

DelegateBasics.cs - Declaring and using delegates
// Declare a delegate type
public delegate void ProcessDataDelegate(string data);
public delegate int CalculateDelegate(int x, int y);

public class Calculator
{
    // Methods that match the delegate signature
    public static int Add(int x, int y) => x + y;
    public static int Subtract(int x, int y) => x - y;
    public static int Multiply(int x, int y) => x * y;

    public static void DemonstrateBasicDelegates()
    {
        // Create delegate instances pointing to methods
        CalculateDelegate calc = Add;
        int result = calc(10, 5);  // Invokes Add method
        Console.WriteLine($"Add: {result}");  // Output: Add: 15

        // Reassign to point to different method
        calc = Subtract;
        result = calc(10, 5);  // Invokes Subtract method
        Console.WriteLine($"Subtract: {result}");  // Output: Subtract: 5

        // Pass delegate as parameter
        int addResult = PerformCalculation(20, 10, Add);
        int multiplyResult = PerformCalculation(20, 10, Multiply);

        Console.WriteLine($"20 + 10 = {addResult}");
        Console.WriteLine($"20 * 10 = {multiplyResult}");
    }

    // Method that accepts a delegate as parameter
    public static int PerformCalculation(int x, int y, CalculateDelegate operation)
    {
        Console.WriteLine($"Performing calculation on {x} and {y}");
        return operation(x, y);
    }
}

public class DataProcessor
{
    public void ProcessItems(List items, ProcessDataDelegate processor)
    {
        foreach (var item in items)
        {
            processor(item);  // Call the delegate for each item
        }
    }

    public static void LogData(string data)
    {
        Console.WriteLine($"[LOG] {data}");
    }

    public static void SaveData(string data)
    {
        Console.WriteLine($"[SAVE] {data}");
    }

    public static void DemonstrateCallback()
    {
        var processor = new DataProcessor();
        var items = new List { "Item1", "Item2", "Item3" };

        // Process items with logging
        processor.ProcessItems(items, LogData);

        // Process same items with saving
        processor.ProcessItems(items, SaveData);
    }
}

The delegate acts as a contract. Any method matching the signature can be assigned to it. This lets ProcessItems work with any processing logic without knowing the implementation details, making your code flexible and reusable.

Using Built-in Delegate Types

C# provides Action and Func delegates for common scenarios. Action represents methods that return void, while Func represents methods that return a value. These generic delegates eliminate most needs for custom delegate declarations.

BuiltInDelegates.cs - Action and Func delegates
public class DelegateExamples
{
    // Action delegates - no return value
    public void DemonstrateAction()
    {
        // Action with no parameters
        Action greet = () => Console.WriteLine("Hello!");
        greet();

        // Action with one parameter
        Action printMessage = message => Console.WriteLine(message);
        printMessage("Welcome to delegates");

        // Action with multiple parameters
        Action logWithLevel = (message, level) =>
            Console.WriteLine($"[Level {level}] {message}");
        logWithLevel("Processing data", 2);
    }

    // Func delegates - return a value
    public void DemonstrateFunc()
    {
        // Func with no parameters, returns int
        Func getRandomNumber = () => Random.Shared.Next(1, 100);
        Console.WriteLine($"Random: {getRandomNumber()}");

        // Func with one parameter, returns bool
        Func isEven = number => number % 2 == 0;
        Console.WriteLine($"Is 4 even? {isEven(4)}");

        // Func with multiple parameters, returns string
        Func combine = (first, second) =>
            $"{first} {second}";
        Console.WriteLine(combine("Hello", "World"));
    }

    // Practical examples with collections
    public void FilterAndTransform()
    {
        var numbers = new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

        // Predicate delegate (Func) for filtering
        Func isEven = n => n % 2 == 0;
        var evenNumbers = numbers.Where(isEven).ToList();
        Console.WriteLine($"Even numbers: {string.Join(", ", evenNumbers)}");

        // Func delegate for transformation
        Func square = n => n * n;
        var squares = numbers.Select(square).ToList();
        Console.WriteLine($"Squares: {string.Join(", ", squares)}");

        // Action delegate for processing
        Action printSquare = n => Console.WriteLine($"{n}² = {n * n}");
        numbers.ForEach(printSquare);
    }

    // Higher-order functions with delegates
    public List Filter(List items, Func predicate)
    {
        var result = new List();
        foreach (var item in items)
        {
            if (predicate(item))
                result.Add(item);
        }
        return result;
    }

    public void Transform(List items, Action transformer)
    {
        foreach (var item in items)
        {
            transformer(item);
        }
    }

    public void UseHigherOrderFunctions()
    {
        var products = new List { "Apple", "Banana", "Cherry", "Date" };

        // Filter products starting with 'C'
        var filtered = Filter(products, p => p.StartsWith("C"));
        Console.WriteLine($"Filtered: {string.Join(", ", filtered)}");

        // Transform all products to uppercase
        Transform(products, p => Console.WriteLine(p.ToUpper()));
    }
}

Action and Func cover nearly all callback scenarios. Action handles up to 16 parameters with no return value, while Func handles up to 16 parameters plus a return value. These built-in types make your code cleaner and more maintainable.

Implementing Events with Delegates

Events build on delegates to provide a safe publish-subscribe pattern. They prevent external code from clearing subscribers or invoking the event inappropriately. Only the class that declares an event can raise it, while other classes can only subscribe or unsubscribe.

EventsExample.cs - Event-driven notification
// Custom event arguments
public class PriceChangedEventArgs : EventArgs
{
    public decimal OldPrice { get; }
    public decimal NewPrice { get; }
    public decimal Change => NewPrice - OldPrice;

    public PriceChangedEventArgs(decimal oldPrice, decimal newPrice)
    {
        OldPrice = oldPrice;
        NewPrice = newPrice;
    }
}

public class Product
{
    private string name;
    private decimal price;

    public string Name
    {
        get => name;
        set => name = value;
    }

    public decimal Price
    {
        get => price;
        set
        {
            if (value != price)
            {
                decimal oldPrice = price;
                price = value;
                OnPriceChanged(new PriceChangedEventArgs(oldPrice, value));
            }
        }
    }

    // Event declaration using EventHandler delegate
    public event EventHandler PriceChanged;

    // Protected method to raise the event
    protected virtual void OnPriceChanged(PriceChangedEventArgs e)
    {
        PriceChanged?.Invoke(this, e);
    }
}

public class PriceMonitor
{
    public void MonitorProduct(Product product)
    {
        // Subscribe to event
        product.PriceChanged += OnProductPriceChanged;
    }

    private void OnProductPriceChanged(object sender, PriceChangedEventArgs e)
    {
        var product = sender as Product;
        Console.WriteLine($"{product.Name} price changed:");
        Console.WriteLine($"  Old: ${e.OldPrice:F2}");
        Console.WriteLine($"  New: ${e.NewPrice:F2}");
        Console.WriteLine($"  Change: ${e.Change:F2}");

        if (e.Change > 0)
            Console.WriteLine("  Price increased!");
        else
            Console.WriteLine("  Price decreased!");
    }

    public void StopMonitoring(Product product)
    {
        // Unsubscribe from event
        product.PriceChanged -= OnProductPriceChanged;
    }
}

// Multiple subscribers example
public class EventDemo
{
    public static void DemonstrateEvents()
    {
        var product = new Product { Name = "Laptop", Price = 999.99m };

        // Multiple subscribers
        product.PriceChanged += (sender, e) =>
            Console.WriteLine($"Logger: Price changed by ${e.Change:F2}");

        product.PriceChanged += (sender, e) =>
        {
            if (e.NewPrice < e.OldPrice)
                Console.WriteLine("Alert: Price drop detected!");
        };

        var monitor = new PriceMonitor();
        monitor.MonitorProduct(product);

        // Trigger events by changing price
        product.Price = 899.99m;  // All subscribers notified
        product.Price = 799.99m;  // All subscribers notified again

        // Unsubscribe one handler
        monitor.StopMonitoring(product);
        product.Price = 699.99m;  // Only lambda handlers notified
    }
}

Events provide encapsulation. External code can subscribe to notifications but can't invoke the event or clear other subscribers. This prevents accidental misuse and creates a clear separation between publishers and subscribers.

Multicast Delegates and Invocation

Delegates can reference multiple methods simultaneously. When you use the += operator, you add a method to the invocation list. Invoking a multicast delegate calls all referenced methods in the order they were added. This is how events notify multiple subscribers.

MulticastExample.cs - Combining multiple callbacks
public delegate void LogDelegate(string message);

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

    public static void LogToFile(string message)
    {
        Console.WriteLine($"[File] Writing: {message}");
    }

    public static void LogToDatabase(string message)
    {
        Console.WriteLine($"[Database] Storing: {message}");
    }

    public static void DemonstrateMulticast()
    {
        // Create multicast delegate
        LogDelegate logger = LogToConsole;
        logger += LogToFile;
        logger += LogToDatabase;

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

        Console.WriteLine("\nRemoving file logger:\n");

        // Remove one handler
        logger -= LogToFile;
        logger("User logged in");

        // Check if delegate is null before invoking
        LogDelegate emptyLogger = null;
        emptyLogger?.Invoke("This won't execute");
    }

    // Return values with multicast delegates
    public delegate int CalculateDelegate(int x, int y);

    public static void DemonstrateReturnValues()
    {
        CalculateDelegate calc = (x, y) => x + y;
        calc += (x, y) => x * y;
        calc += (x, y) => x - y;

        // Only the last method's return value is available
        int result = calc(10, 5);
        Console.WriteLine($"Result: {result}");  // Output: 5 (from subtraction)

        // To get all results, use GetInvocationList
        Console.WriteLine("\nAll results:");
        foreach (CalculateDelegate d in calc.GetInvocationList())
        {
            int individualResult = d(10, 5);
            Console.WriteLine($"  {individualResult}");
        }
    }

    // Exception handling with multicast delegates
    public static void DemonstrateExceptionHandling()
    {
        Action handlers = msg => Console.WriteLine($"Handler 1: {msg}");
        handlers += msg => throw new Exception("Handler 2 failed!");
        handlers += msg => Console.WriteLine($"Handler 3: {msg}");

        try
        {
            handlers("Test");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Exception caught: {ex.Message}");
            Console.WriteLine("Handler 3 was never called due to exception");
        }

        // Safe invocation of all handlers
        Console.WriteLine("\nSafe invocation:");
        foreach (Action handler in handlers.GetInvocationList())
        {
            try
            {
                handler("Test");
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Handler failed: {ex.Message}");
            }
        }
    }
}

When a multicast delegate has a return value, only the last invoked method's return value is accessible. If you need all return values, iterate through GetInvocationList(). Similarly, exceptions from one handler prevent subsequent handlers from executing unless you handle them individually.

Asynchronous Operations with Callbacks

Delegates work naturally with async operations. You can pass async methods to delegates, use them as completion callbacks, and combine them with Task-based patterns. This makes delegates essential for responsive applications that perform background work.

AsyncDelegates.cs - Asynchronous callbacks
public class AsyncOperations
{
    // Delegate for progress reporting
    public delegate void ProgressCallback(int percentComplete, string status);

    // Async operation with progress callbacks
    public async Task ProcessDataAsync(
        List items,
        ProgressCallback onProgress,
        Action onComplete)
    {
        int total = items.Count;

        for (int i = 0; i < total; i++)
        {
            // Simulate processing
            await Task.Delay(500);

            // Report progress
            int percent = (i + 1) * 100 / total;
            onProgress?.Invoke(percent, $"Processing {items[i]}");
        }

        onComplete?.Invoke("All items processed successfully");
    }

    // Using Func for async callbacks
    public async Task ExecuteWithRetry(
        Func operation,
        int maxAttempts,
        Action onRetry)
    {
        int attempt = 0;
        while (attempt < maxAttempts)
        {
            try
            {
                await operation();
                return;  // Success
            }
            catch (Exception ex)
            {
                attempt++;
                if (attempt >= maxAttempts)
                    throw;

                onRetry?.Invoke(attempt);
                await Task.Delay(1000 * attempt);  // Exponential backoff
            }
        }
    }

    // Async event handlers
    public event Func DataReceived;

    public async Task RaiseDataReceivedAsync(string data)
    {
        if (DataReceived != null)
        {
            // Invoke all async handlers
            var handlers = DataReceived.GetInvocationList()
                .Cast>();

            foreach (var handler in handlers)
            {
                await handler(data);
            }
        }
    }

    // Practical example
    public static async Task DemonstrateAsyncCallbacks()
    {
        var processor = new AsyncOperations();
        var items = new List { "File1", "File2", "File3", "File4" };

        await processor.ProcessDataAsync(
            items,
            onProgress: (percent, status) =>
                Console.WriteLine($"{percent}%: {status}"),
            onComplete: message =>
                Console.WriteLine($"\n{message}")
        );

        // Async retry with callbacks
        int retryCount = 0;
        await processor.ExecuteWithRetry(
            operation: async () =>
            {
                Console.WriteLine("Attempting operation...");
                await Task.Delay(100);
                if (++retryCount < 3)
                    throw new Exception("Simulated failure");
                Console.WriteLine("Operation succeeded!");
            },
            maxAttempts: 5,
            onRetry: attempt =>
                Console.WriteLine($"Retry attempt {attempt}")
        );

        // Subscribe to async event
        processor.DataReceived += async (data) =>
        {
            Console.WriteLine($"Handler 1 processing: {data}");
            await Task.Delay(500);
            Console.WriteLine("Handler 1 complete");
        };

        processor.DataReceived += async (data) =>
        {
            Console.WriteLine($"Handler 2 processing: {data}");
            await Task.Delay(300);
            Console.WriteLine("Handler 2 complete");
        };

        await processor.RaiseDataReceivedAsync("Sample data");
    }
}

Async delegates enable progress reporting, retry logic, and concurrent event handling. The key is using Func<Task> or Func<T, Task> instead of Action when you need async callbacks. This lets you await all handlers properly instead of fire-and-forget execution.

Lambda Expressions and Closures

Lambda expressions provide concise syntax for creating delegate instances. They can capture variables from their surrounding scope, creating closures that maintain access to those variables even after the enclosing method returns. This is powerful but requires understanding captured variable lifetimes.

LambdaClosures.cs - Capturing variables
public class ClosureExamples
{
    // Simple lambda capturing local variable
    public Action CreateCounter()
    {
        int count = 0;  // Captured variable

        // Lambda captures 'count' by reference
        return () =>
        {
            count++;
            Console.WriteLine($"Count: {count}");
        };
    }

    public void DemonstrateClosure()
    {
        var counter = CreateCounter();
        counter();  // Output: Count: 1
        counter();  // Output: Count: 2
        counter();  // Output: Count: 3

        // Each invocation sees the same 'count' variable
    }

    // Creating multiple closures
    public List CreateMultipleCounters()
    {
        var counters = new List();

        for (int i = 0; i < 3; i++)
        {
            int captured = i;  // Capture loop variable correctly

            counters.Add(() =>
                Console.WriteLine($"Counter {captured}"));
        }

        return counters;
    }

    // Common closure pitfall
    public void DemonstratePitfall()
    {
        var actions = new List();

        // Wrong way - all closures capture same variable
        for (int i = 0; i < 3; i++)
        {
            actions.Add(() => Console.WriteLine(i));
        }

        Console.WriteLine("Wrong way:");
        foreach (var action in actions)
            action();  // All print 3

        // Right way - each closure gets its own variable
        actions.Clear();
        for (int i = 0; i < 3; i++)
        {
            int captured = i;
            actions.Add(() => Console.WriteLine(captured));
        }

        Console.WriteLine("\nRight way:");
        foreach (var action in actions)
            action();  // Prints 0, 1, 2
    }

    // Practical closure example: Event filtering
    public void DemonstrateEventFiltering()
    {
        var items = new List
        {
            "apple", "banana", "cherry", "apricot", "blueberry"
        };

        // Create filter based on user input
        string searchTerm = "a";
        Func filter = item => item.Contains(searchTerm);

        var filtered = items.Where(filter).ToList();
        Console.WriteLine($"Items containing '{searchTerm}':");
        filtered.ForEach(item => Console.WriteLine($"  {item}"));

        // Change search term and create new filter
        searchTerm = "berry";
        filter = item => item.Contains(searchTerm);

        filtered = items.Where(filter).ToList();
        Console.WriteLine($"\nItems containing '{searchTerm}':");
        filtered.ForEach(item => Console.WriteLine($"  {item}"));
    }

    // Building a simple event system with closures
    public class EventBus
    {
        private readonly List> handlers = new();

        public void Subscribe(string eventType, Action handler)
        {
            // Closure captures eventType
            handlers.Add(data =>
            {
                if (data.StartsWith(eventType))
                    handler(data);
            });
        }

        public void Publish(string data)
        {
            foreach (var handler in handlers)
                handler(data);
        }
    }

    public void DemonstrateEventBus()
    {
        var eventBus = new EventBus();

        // Subscribe to different event types
        eventBus.Subscribe("error", msg =>
            Console.WriteLine($"Error handler: {msg}"));

        eventBus.Subscribe("info", msg =>
            Console.WriteLine($"Info handler: {msg}"));

        // Publish events
        eventBus.Publish("error: Something went wrong");
        eventBus.Publish("info: Processing complete");
        eventBus.Publish("warning: Low memory");  // No handler
    }
}

Closures extend variable lifetimes beyond their original scope. The captured variables live as long as the delegate referencing them exists. This enables powerful patterns but can also create memory leaks if you're not careful about keeping references to closures.

Frequently Asked Questions (FAQ)

What is a delegate in C# and why would I use one?

A delegate is a type-safe function pointer that references methods with a specific signature. You use delegates to pass methods as parameters, implement callbacks, and create event handlers. They enable flexible, decoupled code where one component can notify others without knowing their implementation details. Delegates are essential for event-driven programming and LINQ operations.

What's the difference between a delegate and an event in C#?

Events are built on top of delegates but add protection. While delegates can be invoked and reassigned by anyone with access, events can only be invoked by the declaring class and only allow subscription using += and -=. This prevents external code from clearing all subscribers or raising events inappropriately. Events provide a safer publish-subscribe pattern.

When should I use Action vs Func delegates?

Use Action when your method doesn't return a value, and Func when it does. Action delegates can take 0-16 parameters but return void. Func delegates also take 0-16 parameters but always return a value, with the last type parameter being the return type. These built-in delegates eliminate the need to declare custom delegate types for most scenarios.

Can a delegate reference multiple methods at once?

Yes, delegates support multicast behavior. You can combine delegates using += to add methods or -= to remove them. When invoked, a multicast delegate calls all referenced methods in order. This is how events work internally, allowing multiple event handlers to respond to a single event. If the delegate returns a value, only the last method's return value is accessible.

How do lambda expressions relate to delegates?

Lambda expressions are a concise syntax for creating anonymous methods that match delegate signatures. The compiler converts lambda expressions into delegate instances automatically. They're particularly useful for short callbacks, LINQ queries, and event handlers where defining a separate named method would be verbose. Lambdas can capture variables from their surrounding scope, creating closures.

Back to Articles