Building Event-Driven Applications with Async Event Handling in C#

Why Event-Driven Architecture Matters

Events let components communicate without tight coupling. A publisher raises events when something happens, and subscribers react without the publisher knowing who's listening. This pattern scales well and keeps your codebase maintainable as complexity grows.

Modern applications often need async event handlers for operations like saving to databases, calling APIs, or sending notifications. C# events were designed before async/await existed, creating challenges when you mix events with asynchronous code.

You'll learn how to implement events with EventHandler<T>, handle async operations safely in event handlers, avoid common pitfalls like async void exceptions, and test event-driven code effectively.

Understanding EventHandler<T>

EventHandler<T> is the standard delegate for events in .NET. It takes two parameters: sender (the object raising the event) and args (custom event data). You define custom event arguments by inheriting from EventArgs to pass specific data to subscribers.

The sender parameter lets subscribers know where the event came from. Event args carry the payload. This pattern is consistent across the .NET ecosystem, making events predictable and easy to understand.

Here's how to implement basic events with custom event arguments:

Program.cs - Basic event implementation
using System;

// Custom event arguments
public class OrderPlacedEventArgs : EventArgs
{
    public int OrderId { get; set; }
    public decimal TotalAmount { get; set; }
    public DateTime OrderDate { get; set; }
}

// Publisher
public class OrderService
{
    // Event declaration
    public event EventHandler<OrderPlacedEventArgs> OrderPlaced;

    public void PlaceOrder(int orderId, decimal amount)
    {
        Console.WriteLine($"Processing order {orderId}...");

        // Do order processing work
        ProcessOrder(orderId, amount);

        // Raise event - thread-safe with null-conditional operator
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs
        {
            OrderId = orderId,
            TotalAmount = amount,
            OrderDate = DateTime.Now
        });
    }

    private void ProcessOrder(int orderId, decimal amount)
    {
        // Simulate processing
        Console.WriteLine($"Order {orderId} placed for ${amount}");
    }
}

// Subscribers
public class EmailNotifier
{
    public void Subscribe(OrderService service)
    {
        service.OrderPlaced += OnOrderPlaced;
    }

    public void Unsubscribe(OrderService service)
    {
        service.OrderPlaced -= OnOrderPlaced;
    }

    private void OnOrderPlaced(object sender, OrderPlacedEventArgs e)
    {
        Console.WriteLine($"[EMAIL] Sending confirmation for order {e.OrderId}");
    }
}

public class InventoryManager
{
    public void Subscribe(OrderService service)
    {
        service.OrderPlaced += OnOrderPlaced;
    }

    private void OnOrderPlaced(object sender, OrderPlacedEventArgs e)
    {
        Console.WriteLine($"[INVENTORY] Updating stock for order {e.OrderId}");
    }
}

// Usage
var orderService = new OrderService();
var emailNotifier = new EmailNotifier();
var inventoryManager = new InventoryManager();

emailNotifier.Subscribe(orderService);
inventoryManager.Subscribe(orderService);

orderService.PlaceOrder(1001, 299.99m);
orderService.PlaceOrder(1002, 149.50m);

The null-conditional operator (?.) makes event raising thread-safe in one line. It checks if any subscribers exist and invokes them atomically. Never check for null separately before invoking because another thread might unsubscribe between the check and the invocation.

Handling Async Operations in Events

Event handlers must return void because of delegate signatures, but you often need to do async work like database calls or HTTP requests. This forces you to use async void, which is dangerous because exceptions can't be caught by the caller.

The solution is wrapping your async logic in try-catch blocks inside the handler. This prevents exceptions from crashing your application. For better control, you can create custom async event patterns that return Task.

Here's how to safely handle async operations in event handlers:

Program.cs - Async event handlers
using System;
using System.Threading.Tasks;

public class DataSyncEventArgs : EventArgs
{
    public int RecordCount { get; set; }
    public string Source { get; set; }
}

public class DataSyncService
{
    public event EventHandler<DataSyncEventArgs> DataSynced;

    public void SyncData(string source, int count)
    {
        Console.WriteLine($"Syncing {count} records from {source}...");

        // Raise event
        DataSynced?.Invoke(this, new DataSyncEventArgs
        {
            RecordCount = count,
            Source = source
        });
    }
}

// Subscriber with async void handler (standard pattern)
public class NotificationService
{
    public void Subscribe(DataSyncService service)
    {
        service.DataSynced += OnDataSynced;
    }

    // async void is required for event handlers
    private async void OnDataSynced(object sender, DataSyncEventArgs e)
    {
        try
        {
            Console.WriteLine($"[NOTIFICATION] Processing {e.RecordCount} records...");
            await SendNotificationsAsync(e.RecordCount);
            Console.WriteLine("[NOTIFICATION] Notifications sent successfully");
        }
        catch (Exception ex)
        {
            // Must handle exceptions - they can't bubble up
            Console.WriteLine($"[ERROR] Notification failed: {ex.Message}");
        }
    }

    private async Task SendNotificationsAsync(int count)
    {
        await Task.Delay(500); // Simulate async work
    }
}

// Subscriber with async work
public class LoggingService
{
    public void Subscribe(DataSyncService service)
    {
        service.DataSynced += OnDataSynced;
    }

    private async void OnDataSynced(object sender, DataSyncEventArgs e)
    {
        try
        {
            Console.WriteLine($"[LOGGING] Writing {e.RecordCount} records to log...");
            await WriteToLogAsync(e);
            Console.WriteLine("[LOGGING] Log entry created");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[ERROR] Logging failed: {ex.Message}");
        }
    }

    private async Task WriteToLogAsync(DataSyncEventArgs e)
    {
        await Task.Delay(300); // Simulate async database write
    }
}

// Usage
var syncService = new DataSyncService();
var notificationService = new NotificationService();
var loggingService = new LoggingService();

notificationService.Subscribe(syncService);
loggingService.Subscribe(syncService);

syncService.SyncData("Database", 150);

// Give async handlers time to complete
await Task.Delay(1000);
Console.WriteLine("All handlers completed");

Every async void event handler needs comprehensive try-catch blocks. Exceptions in async void crash your application because there's no caller to catch them. Log all exceptions and implement fallback behavior when handlers fail.

Creating Awaitable Event Patterns

Standard events don't let you await handlers, but you can create custom patterns using Func<Task> delegates. This gives you control over async flow and lets you wait for all handlers to complete before continuing.

The downside is you can't use the standard event keyword. You need to manually manage the delegate list. This pattern works well when you need guaranteed completion or want to aggregate results from handlers.

Here's how to implement awaitable events:

Program.cs - Awaitable event pattern
using System;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;

public class ProcessingEventArgs : EventArgs
{
    public int BatchId { get; set; }
    public List<string> Items { get; set; }
}

// Awaitable event implementation
public class BatchProcessor
{
    private Func<object, ProcessingEventArgs, Task> _batchProcessed;

    // Custom event add/remove for async handlers
    public event Func<object, ProcessingEventArgs, Task> BatchProcessed
    {
        add { _batchProcessed += value; }
        remove { _batchProcessed -= value; }
    }

    public async Task ProcessBatchAsync(int batchId, List<string> items)
    {
        Console.WriteLine($"Processing batch {batchId} with {items.Count} items...");

        var args = new ProcessingEventArgs
        {
            BatchId = batchId,
            Items = items
        };

        // Await all handlers
        if (_batchProcessed != null)
        {
            var handlers = _batchProcessed.GetInvocationList()
                .Cast<Func<object, ProcessingEventArgs, Task>>();

            var tasks = handlers.Select(handler => handler(this, args));
            await Task.WhenAll(tasks);

            Console.WriteLine($"All handlers completed for batch {batchId}");
        }
    }
}

// Async subscribers
public class ValidationService
{
    public void Subscribe(BatchProcessor processor)
    {
        processor.BatchProcessed += OnBatchProcessed;
    }

    private async Task OnBatchProcessed(object sender, ProcessingEventArgs e)
    {
        Console.WriteLine($"[VALIDATION] Validating batch {e.BatchId}...");
        await Task.Delay(200); // Simulate async validation
        Console.WriteLine($"[VALIDATION] Batch {e.BatchId} validated");
    }
}

public class StorageService
{
    public void Subscribe(BatchProcessor processor)
    {
        processor.BatchProcessed += OnBatchProcessed;
    }

    private async Task OnBatchProcessed(object sender, ProcessingEventArgs e)
    {
        Console.WriteLine($"[STORAGE] Storing batch {e.BatchId}...");
        await Task.Delay(300); // Simulate async storage
        Console.WriteLine($"[STORAGE] Batch {e.BatchId} stored");
    }
}

// Usage
var processor = new BatchProcessor();
var validator = new ValidationService();
var storage = new StorageService();

validator.Subscribe(processor);
storage.Subscribe(processor);

var items = new List<string> { "Item1", "Item2", "Item3" };
await processor.ProcessBatchAsync(1, items);

Console.WriteLine("Batch processing complete");

Task.WhenAll waits for all handlers to finish. If any handler throws an exception, WhenAll aggregates them into an AggregateException. You can catch this and handle failures from individual handlers separately.

Thread-Safe Event Raising

Events can be subscribed to and unsubscribed from on different threads. If you check for null separately from invoking, another thread might unsubscribe between the check and invocation, causing a null reference exception.

The null-conditional operator (?.) solves this by creating a local copy of the delegate atomically. This is the recommended pattern for all event raising in C#.

Here's how thread-safety works with events:

Program.cs - Thread-safe event patterns
using System;
using System.Threading;
using System.Threading.Tasks;

public class StatusChangeEventArgs : EventArgs
{
    public string OldStatus { get; set; }
    public string NewStatus { get; set; }
}

public class ServiceMonitor
{
    public event EventHandler<StatusChangeEventArgs> StatusChanged;

    private string _status = "Stopped";

    public async Task ChangeStatusAsync(string newStatus)
    {
        string oldStatus = _status;
        _status = newStatus;

        Console.WriteLine($"Status changed: {oldStatus} -> {newStatus}");

        // Thread-safe event raising - correct pattern
        StatusChanged?.Invoke(this, new StatusChangeEventArgs
        {
            OldStatus = oldStatus,
            NewStatus = newStatus
        });

        await Task.Delay(100);
    }

    // Alternative: explicit null check with local copy
    protected virtual void OnStatusChanged(StatusChangeEventArgs e)
    {
        EventHandler<StatusChangeEventArgs> handler = StatusChanged;
        handler?.Invoke(this, e);
    }
}

// Test concurrent subscriptions and events
var monitor = new ServiceMonitor();
int handlerCallCount = 0;

// Subscribe from multiple threads
var subscribeTask = Task.Run(() =>
{
    for (int i = 0; i < 5; i++)
    {
        monitor.StatusChanged += (s, e) =>
        {
            Interlocked.Increment(ref handlerCallCount);
            Console.WriteLine($"Handler called: {e.NewStatus}");
        };
        Thread.Sleep(10);
    }
});

// Raise events from multiple threads
var raiseTask1 = Task.Run(async () =>
{
    for (int i = 0; i < 3; i++)
    {
        await monitor.ChangeStatusAsync($"Running_{i}");
        await Task.Delay(20);
    }
});

var raiseTask2 = Task.Run(async () =>
{
    for (int i = 0; i < 3; i++)
    {
        await monitor.ChangeStatusAsync($"Idle_{i}");
        await Task.Delay(25);
    }
});

await Task.WhenAll(subscribeTask, raiseTask1, raiseTask2);
Console.WriteLine($"\nTotal handler calls: {handlerCallCount}");

The null-conditional operator atomically copies the delegate reference and checks for null, preventing race conditions. This one-liner replaces the old pattern of copying to a local variable first.

Try It Yourself

This complete example demonstrates an order processing system with async event handlers, proper error handling, and multiple subscribers.

Program.cs - Complete event system
using System;
using System.Threading.Tasks;

public class OrderEventArgs : EventArgs
{
    public int OrderId { get; set; }
    public string CustomerEmail { get; set; }
    public decimal Amount { get; set; }
}

public class OrderProcessor
{
    public event EventHandler<OrderEventArgs> OrderCompleted;

    public async Task ProcessOrderAsync(int orderId, string email, decimal amount)
    {
        Console.WriteLine($"=== Processing Order {orderId} ===");
        await Task.Delay(200); // Simulate processing

        OrderCompleted?.Invoke(this, new OrderEventArgs
        {
            OrderId = orderId,
            CustomerEmail = email,
            Amount = amount
        });

        Console.WriteLine($"Order {orderId} completed\n");
    }
}

public class EmailService
{
    public void Subscribe(OrderProcessor processor)
    {
        processor.OrderCompleted += OnOrderCompleted;
    }

    private async void OnOrderCompleted(object sender, OrderEventArgs e)
    {
        try
        {
            Console.WriteLine($"[EMAIL] Sending receipt to {e.CustomerEmail}...");
            await Task.Delay(300);
            Console.WriteLine($"[EMAIL] Receipt sent for order {e.OrderId}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[EMAIL ERROR] {ex.Message}");
        }
    }
}

public class AnalyticsService
{
    public void Subscribe(OrderProcessor processor)
    {
        processor.OrderCompleted += OnOrderCompleted;
    }

    private async void OnOrderCompleted(object sender, OrderEventArgs e)
    {
        try
        {
            Console.WriteLine($"[ANALYTICS] Recording order {e.OrderId}...");
            await Task.Delay(150);
            Console.WriteLine($"[ANALYTICS] Order {e.OrderId} recorded (${e.Amount})");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[ANALYTICS ERROR] {ex.Message}");
        }
    }
}

// Main program
var processor = new OrderProcessor();
var emailService = new EmailService();
var analyticsService = new AnalyticsService();

emailService.Subscribe(processor);
analyticsService.Subscribe(processor);

await processor.ProcessOrderAsync(1001, "customer@example.com", 299.99m);
await Task.Delay(500); // Wait for async handlers

await processor.ProcessOrderAsync(1002, "another@example.com", 149.50m);
await Task.Delay(500);

Console.WriteLine("All orders processed!");
EventDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

Running the example:

  1. Create a new folder and save both files
  2. Run dotnet run to see the event system in action
  3. Notice how handlers run asynchronously
  4. Try adding more subscribers with different delays
  5. Experiment with exception handling in handlers

Testing & Verification

Testing event-driven code requires verifying that events fire correctly and handlers execute as expected. You need to check that the right data passes through event arguments and that async handlers complete properly.

xUnit provides excellent async test support. You can await async operations in tests and verify that events raised correctly. Track handler invocations with counters or flags to confirm execution.

Here's how to write comprehensive tests for event-driven code:

OrderProcessorTests.cs - xUnit tests
using System;
using System.Threading.Tasks;
using Xunit;

public class OrderProcessorTests
{
    [Fact]
    public async Task ProcessOrder_RaisesOrderCompleted()
    {
        // Arrange
        var processor = new OrderProcessor();
        bool eventRaised = false;
        OrderEventArgs receivedArgs = null;

        processor.OrderCompleted += (sender, args) =>
        {
            eventRaised = true;
            receivedArgs = args;
        };

        // Act
        await processor.ProcessOrderAsync(100, "test@example.com", 99.99m);

        // Assert
        Assert.True(eventRaised);
        Assert.NotNull(receivedArgs);
        Assert.Equal(100, receivedArgs.OrderId);
        Assert.Equal("test@example.com", receivedArgs.CustomerEmail);
        Assert.Equal(99.99m, receivedArgs.Amount);
    }

    [Fact]
    public async Task ProcessOrder_WithAsyncHandler_CompletesSuccessfully()
    {
        // Arrange
        var processor = new OrderProcessor();
        bool handlerCompleted = false;

        processor.OrderCompleted += async (sender, args) =>
        {
            await Task.Delay(100); // Simulate async work
            handlerCompleted = true;
        };

        // Act
        await processor.ProcessOrderAsync(200, "async@example.com", 49.99m);
        await Task.Delay(200); // Wait for async handler

        // Assert
        Assert.True(handlerCompleted);
    }

    [Fact]
    public async Task ProcessOrder_WithMultipleSubscribers_CallsAllHandlers()
    {
        // Arrange
        var processor = new OrderProcessor();
        int callCount = 0;

        processor.OrderCompleted += (s, e) => callCount++;
        processor.OrderCompleted += (s, e) => callCount++;
        processor.OrderCompleted += (s, e) => callCount++;

        // Act
        await processor.ProcessOrderAsync(300, "multi@example.com", 199.99m);

        // Assert
        Assert.Equal(3, callCount);
    }

    [Fact]
    public async Task ProcessOrder_WithNoSubscribers_DoesNotThrow()
    {
        // Arrange
        var processor = new OrderProcessor();

        // Act & Assert - should not throw
        await processor.ProcessOrderAsync(400, "noone@example.com", 299.99m);
    }

    [Fact]
    public async Task ProcessOrder_HandlerException_DoesNotAffectOtherHandlers()
    {
        // Arrange
        var processor = new OrderProcessor();
        bool firstHandlerCalled = false;
        bool thirdHandlerCalled = false;

        processor.OrderCompleted += (s, e) => firstHandlerCalled = true;

        processor.OrderCompleted += (s, e) =>
        {
            throw new InvalidOperationException("Handler failed");
        };

        processor.OrderCompleted += (s, e) => thirdHandlerCalled = true;

        // Act
        await processor.ProcessOrderAsync(500, "error@example.com", 399.99m);

        // Assert - verify other handlers still ran
        Assert.True(firstHandlerCalled);
        Assert.True(thirdHandlerCalled);
    }
}

Event tests verify correct behavior under different scenarios. Test with no subscribers, single subscribers, and multiple subscribers. Verify exception handling doesn't break the event chain. For async handlers, add delays to ensure handlers complete before assertions.

Frequently Asked Questions (FAQ)

Why is async void dangerous in event handlers?

Async void methods can't be awaited, making exception handling impossible. If an async void handler throws an exception, it crashes your application. Event handlers must be async void due to delegate signatures, so use try-catch blocks inside handlers and consider a custom event pattern for better async support.

How do I make event raising thread-safe?

Use the null-conditional operator (?.) when raising events. This creates a local copy of the delegate and checks for null atomically. The pattern 'EventName?.Invoke(this, args)' is thread-safe and concise. Never check for null separately before invoking as this creates a race condition.

Can I await event handlers in the publisher?

Standard events don't support awaiting handlers because EventHandler returns void. You can create a custom async event pattern using Func<Task> delegates, allowing you to await all handlers. This gives you better control over async flow but requires a non-standard event implementation.

What's the difference between EventHandler and EventHandler<T>?

EventHandler uses EventArgs and provides no custom data. EventHandler<T> lets you pass custom event arguments by specifying a type derived from EventArgs. Use EventHandler<T> when you need to pass specific data to handlers. Both follow the same pattern with sender and event args parameters.

How do I prevent memory leaks from event subscriptions?

Always unsubscribe from events when you no longer need them, especially if the publisher lives longer than the subscriber. Use weak event patterns for long-lived publishers, implement IDisposable to handle cleanup, or use event aggregators that manage subscriptions centrally. Lambda handlers are harder to unsubscribe from.

Back to Articles