synchronization for subscription changes.
Core Solution
Implementing delegates and events in production requires strict adherence to encapsulation, thread-safe invocation, and deterministic lifecycle management. The following implementation demonstrates the gold standard for a publisher component.
1. Define Strongly-Typed EventArgs
Always derive from EventArgs. For performance, reuse EventArgs.Empty when no data is passed.
public sealed class OrderProcessedEventArgs : EventArgs
{
public Guid OrderId { get; }
public decimal TotalAmount { get; }
public DateTime ProcessedAt { get; }
public OrderProcessedEventArgs(Guid orderId, decimal totalAmount)
{
OrderId = orderId;
TotalAmount = totalAmount;
ProcessedAt = DateTime.UtcNow;
}
}
2. Implement the Event Publisher
Use the event keyword to prevent external manipulation of the invocation list. Implement a protected virtual method for raising the event to support inheritance and testability.
public class OrderProcessor : IDisposable
{
// Event declaration restricts external access to += and -=
public event EventHandler<OrderProcessedEventArgs>? OrderProcessed;
// Thread-safe invocation pattern
// Modern C# compilers optimize ?.Invoke with a volatile read copy.
protected virtual void OnOrderProcessed(OrderProcessedEventArgs e)
{
OrderProcessed?.Invoke(this, e);
}
public void Process(Order order)
{
// Business logic...
var args = new OrderProcessedEventArgs(order.Id, order.Total);
OnOrderProcessed(args);
}
public void Dispose()
{
// Clear subscriptions to prevent leaks if publisher outlives subscribers
OrderProcessed = null;
}
}
3. Subscription and Unsubscription
Subscribers must manage the lifecycle of their subscriptions. In scoped services, unsubscription should occur during disposal.
public class AuditService : IDisposable
{
private readonly OrderProcessor _processor;
public AuditService(OrderProcessor processor)
{
_processor = processor;
_processor.OrderProcessed += OnOrderProcessed;
}
private void OnOrderProcessed(object? sender, OrderProcessedEventArgs e)
{
// Handler logic
// Avoid blocking calls here; offload to a queue if necessary.
}
public void Dispose()
{
// Critical: Unsubscribe to break the reference chain
_processor.OrderProcessed -= OnOrderProcessed;
}
}
Architecture Decisions
- Encapsulation: The
event keyword generates add and remove accessors. This prevents subscribers from calling the event directly or setting it to null, which would clear all other subscribers.
- Inheritance: The
protected virtual raise method allows derived classes to raise the event without exposing the invocation capability publicly.
- Performance: Using
EventHandler<T> leverages the runtime's optimized delegate invocation list and allows for EventArgs pooling strategies in high-load scenarios.
Pitfall Guide
1. Public Delegate Exposure
Mistake: Declaring public delegate void MyHandler(); instead of public event EventHandler....
Impact: Any code with access to the instance can execute obj.MyHandler = null, destroying all subscriptions. It also allows obj.MyHandler(), bypassing the publisher's control.
Fix: Always use the event keyword.
2. Memory Leaks via Strong References
Mistake: Subscribing to a long-lived publisher (e.g., a static AppEvents or singleton MessageBus) without unsubscribing.
Impact: The publisher holds a reference to the delegate, which holds a reference to the subscriber's target object. The GC cannot collect the subscriber, leading to heap growth.
Fix: Implement IDisposable on subscribers and unsubscribe. For transient subscribers, consider WeakReference patterns or WeakEventManager.
3. Thread Safety on Invocation
Mistake: Checking for null and invoking in two steps: if (Event != null) Event(this, args);.
Impact: A race condition where the last subscriber unsubscribes between the check and the invocation, causing a NullReferenceException.
Fix: Use Event?.Invoke(this, args). In older frameworks, copy to a local variable: var handler = Event; handler?.Invoke(...).
4. Blocking the Publisher
Mistake: Performing I/O or long-running logic inside an event handler.
Impact: The publisher blocks until all handlers complete. This serializes execution and can cause thread pool starvation or UI freezing.
Fix: Offload work to a Channel<T>, BackgroundService, or use Task.Run within the handler if fire-and-forget semantics are acceptable.
5. Async Void Handlers
Mistake: Defining handlers as async void.
Impact: Exceptions thrown in async void methods crash the process because they cannot be caught by the caller.
Fix: Use async Task handlers where possible. If the event signature requires void, wrap the async call carefully or use a dedicated async event pattern.
6. Exception Isolation
Mistake: Assuming the invocation list is atomic.
Impact: If one handler throws, subsequent handlers in the invocation list are skipped.
Fix: In critical systems, iterate over GetInvocationList() and wrap each invocation in a try-catch to ensure all subscribers receive the event.
7. Over-Engineering Custom Delegates
Mistake: Creating public delegate void DataHandler(string id, int code, object data); for every event.
Impact: Proliferation of types, poor tooling support, and difficulty in serializing or logging event metadata.
Fix: Standardize on EventHandler<TEventArgs>. Put all data in TEventArgs.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| UI Framework Binding | INotifyPropertyChanged / EventHandler | Frameworks expect standard event patterns; enables data binding. | Low |
| High-Frequency Trading | Action<T> with Struct Args | Minimizes allocation; EventHandler<T> overhead is unnecessary for internal callbacks. | High Perf |
| Plugin Architecture | EventHandler<T> | Enforces contract; allows plugin isolation and metadata inspection. | Medium |
| Audit Logging | Action<T> via DI | Decoupled logging can be injected as a delegate; avoids event overhead. | Low |
| Memory-Constrained IoT | EventHandler<T> with Pooling | Reuse EventArgs instances to reduce GC pressure on constrained heaps. | Low |
Configuration Template
Safe Event Publisher with Exception Isolation
Use this template when event delivery reliability is critical and handler failures must not disrupt the publisher.
public static class EventExtensions
{
/// <summary>
/// Raises an event safely, isolating exceptions per handler.
/// Ensures all subscribers are invoked even if one fails.
/// </summary>
public static void RaiseSafely<TEventArgs>(
this EventHandler<TEventArgs>? eventHandler,
object sender,
TEventArgs args,
Action<Exception>? onError = null) where TEventArgs : EventArgs
{
if (eventHandler is null) return;
foreach (var handler in eventHandler.GetInvocationList())
{
try
{
((EventHandler<TEventArgs>)handler)(sender, args);
}
catch (Exception ex)
{
onError?.Invoke(ex);
// Optionally log or continue based on policy
}
}
}
}
// Usage in Publisher:
protected virtual void OnDataReceived(DataReceivedEventArgs e)
{
DataReceived.RaiseSafely(this, e, ex => _logger.LogError(ex, "Handler failed"));
}
Quick Start Guide
- Define Args: Create a class inheriting
EventArgs with required properties.
public record MyEventArgs(string Payload) : EventArgs;
- Declare Event: Add
public event EventHandler<MyEventArgs>? MyEvent; to the publisher.
- Raise Event: Implement
protected virtual void OnMyEvent(MyEventArgs e) => MyEvent?.Invoke(this, e);.
- Subscribe: In the consumer, call
publisher.MyEvent += HandlerMethod;.
- Unsubscribe: In the consumer's
Dispose, call publisher.MyEvent -= HandlerMethod;.
This structure ensures type safety, thread safety, memory efficiency, and maintainability across the application lifecycle. Adhering to these patterns eliminates the majority of delegate-related defects in C# production systems.