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:
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:
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:
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:
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.
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!");
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Running the example:
- Create a new folder and save both files
- Run
dotnet run to see the event system in action
- Notice how handlers run asynchronously
- Try adding more subscribers with different delays
- 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:
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.