Comparing Delegates vs Events: Event-Driven Programming in C#

Understanding Event-Driven Communication

Imagine you're building a notification system for an e-commerce application. When an order status changes, multiple parts of your application need to respond: send an email to the customer, update the inventory system, log the event for analytics, and trigger a webhook for third-party integrations. You could manually call each of these services from your order processing code, but that creates tight coupling and makes your code brittle.

This is where delegates and events shine in C#. They let you build loosely coupled systems where objects can communicate without knowing the specifics of who's listening. Delegates provide the mechanism for referencing methods, while events build on delegates to create a publish-subscribe pattern with proper encapsulation. By the end of this article, you'll understand exactly when to use each and how to implement them correctly.

You'll learn the fundamental differences between delegates and events, see practical examples of both, understand thread-safe event raising patterns, and master the EventHandler conventions that make your code consistent with .NET framework guidelines.

Delegates: Type-Safe Function Pointers

A delegate is a type that represents references to methods with a specific signature. Think of it as a type-safe function pointer that can hold one or more method references. Delegates enable you to pass methods as parameters, store them in collections, or invoke them dynamically at runtime.

In C#, delegates are multicast by default, meaning a single delegate instance can reference multiple methods. When you invoke the delegate, all referenced methods execute in the order they were added. This forms the foundation for how events work.

DelegateBasics.cs
namespace DelegateExample;

// Define a delegate type
public delegate void PriceChangedHandler(decimal oldPrice, decimal newPrice);

public class Product
{
    private decimal _price;

    // Public delegate field - anyone can invoke or reassign
    public PriceChangedHandler PriceChanged;

    public decimal Price
    {
        get => _price;
        set
        {
            if (_price != value)
            {
                decimal oldPrice = _price;
                _price = value;

                // Invoke the delegate if it has subscribers
                PriceChanged?.Invoke(oldPrice, value);
            }
        }
    }
}

// Usage
var product = new Product { Price = 100m };

// Subscribe multiple handlers
product.PriceChanged += (oldPrice, newPrice) =>
    Console.WriteLine($"Price changed from ${oldPrice} to ${newPrice}");

product.PriceChanged += (oldPrice, newPrice) =>
    Console.WriteLine($"Discount: {((oldPrice - newPrice) / oldPrice) * 100:F1}% off");

product.Price = 75m;

// Output:
// Price changed from $100 to $75
// Discount: 25.0% off

This example shows delegates in their purest form. The PriceChanged delegate is a public field, which means external code can invoke it, clear all subscriptions by setting it to null, or completely replace it. While this flexibility is useful in certain scenarios like callbacks, it creates encapsulation problems when you want controlled notification patterns.

Events: Encapsulated Publish-Subscribe

Events build on delegates to provide a safer publish-subscribe pattern. When you declare a field as an event rather than a bare delegate, C# adds compiler restrictions that prevent external code from invoking the event or clearing all subscribers. External code can only subscribe (+=) or unsubscribe (-=) from events.

This encapsulation is crucial for building robust systems. It ensures that only the class that owns the event can decide when to raise it, and subscribers can't accidentally or maliciously trigger the event or remove other subscribers.

EventBasics.cs
namespace EventExample;

public class StockAlert
{
    // Event using EventHandler pattern (recommended)
    public event EventHandler PriceChanged;

    private decimal _currentPrice;

    public decimal CurrentPrice
    {
        get => _currentPrice;
        set
        {
            if (_currentPrice != value)
            {
                var args = new StockPriceChangedEventArgs
                {
                    OldPrice = _currentPrice,
                    NewPrice = value,
                    Timestamp = DateTime.UtcNow
                };

                _currentPrice = value;
                OnPriceChanged(args);
            }
        }
    }

    // Protected virtual method for raising events (best practice)
    protected virtual void OnPriceChanged(StockPriceChangedEventArgs e)
    {
        PriceChanged?.Invoke(this, e);
    }
}

public class StockPriceChangedEventArgs : EventArgs
{
    public decimal OldPrice { get; init; }
    public decimal NewPrice { get; init; }
    public DateTime Timestamp { get; init; }
    public decimal PercentChange =>
        OldPrice != 0 ? ((NewPrice - OldPrice) / OldPrice) * 100 : 0;
}

// Usage
var stock = new StockAlert { CurrentPrice = 150.00m };

stock.PriceChanged += (sender, e) =>
{
    Console.WriteLine($"Alert: Price moved {e.PercentChange:+0.00;-0.00}%");
};

stock.PriceChanged += (sender, e) =>
{
    if (Math.Abs(e.PercentChange) > 5)
        Console.WriteLine($"URGENT: Major price movement at {e.Timestamp}");
};

stock.CurrentPrice = 160.50m;

// Output:
// Alert: Price moved +7.00%
// URGENT: Major price movement at 11/4/2025 10:30:15 AM

The event keyword changes everything. External code can't do stock.PriceChanged?.Invoke(...) or stock.PriceChanged = null. This protection ensures only the StockAlert class controls when notifications occur. The EventHandler<T> pattern with custom EventArgs provides strongly-typed event data while following .NET conventions.

Key Differences in Practice

Let's examine the practical implications of choosing delegates versus events. The differences affect API design, encapsulation, and how your code can be used or misused by other developers.

Comparison.cs
namespace DelegateVsEvent;

public class DelegateApproach
{
    public delegate void NotificationHandler(string message);
    public NotificationHandler OnNotification;

    public void Trigger() => OnNotification?.Invoke("Delegate notification");
}

public class EventApproach
{
    public event EventHandler OnNotification;

    public void Trigger() => OnNotification?.Invoke(this, "Event notification");
}

// Demonstrating the differences
var delegateObj = new DelegateApproach();
var eventObj = new EventApproach();

// Both allow subscription
delegateObj.OnNotification += msg => Console.WriteLine($"Received: {msg}");
eventObj.OnNotification += (sender, msg) => Console.WriteLine($"Received: {msg}");

// Delegates allow external invocation (DANGEROUS)
delegateObj.OnNotification?.Invoke("External trigger");  // Works!

// Events prevent external invocation (SAFE)
// eventObj.OnNotification?.Invoke(eventObj, "External trigger");  // Compiler error!

// Delegates allow clearing all subscribers (DANGEROUS)
delegateObj.OnNotification = null;  // Works - removes all handlers!

// Events prevent clearing (SAFE)
// eventObj.OnNotification = null;  // Compiler error!

// Delegates can be reassigned entirely (DANGEROUS)
delegateObj.OnNotification = msg => Console.WriteLine("Replaced all handlers");

// Events cannot be reassigned (SAFE)
// eventObj.OnNotification = (s, msg) => { };  // Compiler error!

These restrictions make events the correct choice for the observer pattern. Delegates work well for callbacks where you want the caller to control invocation, like LINQ methods (Where, Select) or strategy patterns. Events excel when you're implementing notifications where the publisher controls timing and multiple observers might be listening.

Thread-Safe Event Patterns

In multithreaded applications, race conditions can occur when raising events. A subscriber might unsubscribe between your null check and invocation, causing a NullReferenceException. The null-conditional operator solves this elegantly.

The ?. operator creates a local copy of the delegate reference before checking for null and invoking. This atomic operation prevents the race condition where another thread removes the last subscriber between your check and invocation.

ThreadSafeEvents.cs
namespace ThreadSafeEvents;

public class DataProcessor
{
    public event EventHandler DataProcessed;
    public event EventHandler ErrorOccurred;

    public async Task ProcessBatchAsync(IEnumerable items)
    {
        foreach (var item in items)
        {
            try
            {
                await ProcessItemAsync(item);

                // Thread-safe event raising with null-conditional
                DataProcessed?.Invoke(this, new DataProcessedEventArgs
                {
                    ItemId = item,
                    ProcessedAt = DateTime.UtcNow
                });
            }
            catch (Exception ex)
            {
                // Thread-safe error event
                ErrorOccurred?.Invoke(this, new ErrorEventArgs
                {
                    Error = ex,
                    ItemId = item
                });
            }
        }
    }

    private Task ProcessItemAsync(string item)
    {
        return Task.Delay(10); // Simulate work
    }
}

public class DataProcessedEventArgs : EventArgs
{
    public string ItemId { get; init; }
    public DateTime ProcessedAt { get; init; }
}

public class ErrorEventArgs : EventArgs
{
    public Exception Error { get; init; }
    public string ItemId { get; init; }
}

The null-conditional operator (?.) is now the standard way to raise events. It's concise, thread-safe, and handles the null case gracefully. Avoid the old pattern of checking if (MyEvent != null) because that check and invocation aren't atomic.

Try It Yourself

Here's a complete example demonstrating both delegates and events in a real-world order processing scenario. This code shows how events provide better encapsulation for notifications while delegates work well for callbacks and strategy patterns.

Program.cs
using System;

namespace OrderProcessing;

// Delegate for validation strategy
public delegate bool OrderValidator(Order order);

public class Order
{
    public int Id { get; init; }
    public decimal Total { get; init; }
    public string Status { get; set; }
}

public class OrderProcessor
{
    // Event for notifications (proper encapsulation)
    public event EventHandler OrderStatusChanged;

    // Delegate for validation strategy (flexible callback)
    private OrderValidator _validator;

    public void SetValidator(OrderValidator validator)
    {
        _validator = validator;
    }

    public void ProcessOrder(Order order)
    {
        // Use delegate for validation
        if (_validator != null && !_validator(order))
        {
            Console.WriteLine($"Order {order.Id} failed validation");
            return;
        }

        // Process order
        order.Status = "Processing";
        RaiseStatusChanged(order, "Created", "Processing");

        // Complete order
        order.Status = "Completed";
        RaiseStatusChanged(order, "Processing", "Completed");
    }

    private void RaiseStatusChanged(Order order, string oldStatus, string newStatus)
    {
        OrderStatusChanged?.Invoke(this, new OrderEventArgs
        {
            Order = order,
            OldStatus = oldStatus,
            NewStatus = newStatus
        });
    }
}

public class OrderEventArgs : EventArgs
{
    public Order Order { get; init; }
    public string OldStatus { get; init; }
    public string NewStatus { get; init; }
}

class Program
{
    static void Main()
    {
        var processor = new OrderProcessor();

        // Subscribe to events (multiple handlers)
        processor.OrderStatusChanged += (sender, e) =>
            Console.WriteLine($"[EMAIL] Order {e.Order.Id}: {e.OldStatus} -> {e.NewStatus}");

        processor.OrderStatusChanged += (sender, e) =>
            Console.WriteLine($"[LOG] Status change logged for order {e.Order.Id}");

        // Set validation delegate (single strategy)
        processor.SetValidator(order => order.Total > 0);

        // Process orders
        var order1 = new Order { Id = 1, Total = 100m };
        var order2 = new Order { Id = 2, Total = 0m };

        processor.ProcessOrder(order1);
        Console.WriteLine();
        processor.ProcessOrder(order2);
    }
}

// Output:
// [EMAIL] Order 1: Created -> Processing
// [LOG] Status change logged for order 1
// [EMAIL] Order 1: Processing -> Completed
// [LOG] Status change logged for order 1
//
// Order 2 failed validation

Save this as Program.cs and create a project file:

OrderProcessing.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Run with: dotnet run

Testing Event Behavior

Events and delegates have observable behavior that you can verify in tests. Testing ensures your event-raising logic works correctly and that subscribers receive notifications as expected. Here's how to write reliable tests for event-driven code.

The key to testing events is tracking invocations using counters or captured arguments. You verify that events fire when expected, with the correct data, and only under the right conditions.

Tests/EventTests.cs
using Xunit;

public class EventTests
{
    [Fact]
    public void PriceChanged_RaisesEvent_WhenPriceChanges()
    {
        // Arrange
        var product = new Product { Price = 100m };
        int eventRaisedCount = 0;
        decimal capturedOldPrice = 0;
        decimal capturedNewPrice = 0;

        product.PriceChanged += (oldPrice, newPrice) =>
        {
            eventRaisedCount++;
            capturedOldPrice = oldPrice;
            capturedNewPrice = newPrice;
        };

        // Act
        product.Price = 150m;

        // Assert
        Assert.Equal(1, eventRaisedCount);
        Assert.Equal(100m, capturedOldPrice);
        Assert.Equal(150m, capturedNewPrice);
    }

    [Fact]
    public void PriceChanged_DoesNotRaise_WhenPriceUnchanged()
    {
        // Arrange
        var product = new Product { Price = 100m };
        int eventRaisedCount = 0;
        product.PriceChanged += (_, _) => eventRaisedCount++;

        // Act
        product.Price = 100m;

        // Assert
        Assert.Equal(0, eventRaisedCount);
    }
}

These tests verify the contract of your events: they should fire when state changes and pass correct data. Test edge cases like unchanged values, null handlers, and multiple subscribers to ensure robustness in production scenarios.

Conclusion

Understanding when to use delegates versus events is crucial for building well-designed APIs. The choice affects how other developers can interact with your code and whether you can maintain control over when notifications occur.

Use events when: You're implementing the observer pattern where multiple subscribers need to react to state changes. Events provide proper encapsulation, preventing external code from triggering notifications or clearing subscribers. This is the right choice for UI controls, business objects raising notifications, or any scenario where the object owns the timing of notifications.

Use delegates when: You need callbacks or want to pass behavior as a parameter. Delegates work well for strategy patterns, LINQ-style operations, or scenarios where the caller should control invocation. If you need to store and invoke a method reference at a later time based on external triggers, delegates provide the flexibility you need.

Migration strategy: If you've exposed a public delegate field and want to switch to an event, you'll break existing code. Consider the compatibility impact. For new APIs, prefer events for notifications. You can always expose a delegate property if you genuinely need external invocation, but make that choice deliberately rather than by default.

Frequently Asked Questions (FAQ)

What's the main difference between delegates and events in C#?

Delegates are type-safe function pointers that can reference methods, while events are a wrapper around delegates that restrict how they can be invoked. Events provide encapsulation by only allowing the declaring class to raise them, preventing external code from triggering or clearing subscriptions.

When should I use a delegate instead of an event?

Use delegates for callbacks, strategy patterns, or when you need to pass behavior as a parameter. Use events when implementing the observer pattern or when multiple subscribers need to react to state changes in your object.

Can external code invoke an event directly?

No, events can only be invoked from within the declaring class. External code can only subscribe (+=) or unsubscribe (-=) from events. This encapsulation prevents unauthorized code from triggering events or clearing all subscribers.

How do I safely raise an event in a multithreaded environment?

Use the null-conditional operator to raise events safely: MyEvent?.Invoke(this, EventArgs.Empty). This creates a local copy of the delegate and checks for null in a thread-safe manner, preventing race conditions when subscribers are added or removed.

What is EventHandler<T> and when should I use it?

EventHandler<T> is the standard generic delegate for events in .NET. Use it for all events instead of creating custom delegate types. It follows the pattern (object sender, T args) and provides consistency across your codebase.

Back to Articles