nt patterns decouple publisher lifetime from subscriber lifetime, eliminating the primary cause of memory leaks in long-running applications. Understanding this progression transforms delegates from a debugging liability into a predictable, scalable communication primitive.
Core Solution
Implementing delegates and events correctly requires a disciplined approach that separates declaration, invocation, subscription management, and architecture selection. The following steps outline a production-ready implementation path.
Step 1: Define the Delegate Signature
Delegates are type-safe function pointers. Define them explicitly with clear parameter and return semantics. Avoid Func<T> or Action<T> for public APIs; named delegates improve readability and enable future signature evolution without breaking binary compatibility.
public delegate void DataProcessedEventHandler(object sender, DataProcessedEventArgs e);
Step 2: Declare the Event with Proper Encapsulation
Use the event keyword to generate a compiler-backed field with restricted add/remove accessors. This prevents external code from invoking the delegate directly or replacing the invocation list.
public class DataProcessor
{
public event DataProcessedEventHandler Processed;
// Compiler generates:
// private DataProcessedEventHandler Processed;
// public event DataProcessedEventHandler Processed { add; remove; }
}
Step 3: Implement Thread-Safe Invocation
Multicast delegates are immutable. Copying the delegate reference before invocation prevents race conditions where subscribers unsubscribe during execution. Use the null-conditional invoke operator for conciseness and safety.
protected virtual void OnProcessed(DataProcessedEventArgs e)
{
var handler = Processed;
handler?.Invoke(this, e);
}
Step 4: Manage Subscriber Lifecycle
Subscribers must unsubscribe when disposed. Implement IDisposable on subscribers or use a registration token pattern to guarantee cleanup.
public class LogSubscriber : IDisposable
{
private readonly DataProcessor _processor;
public LogSubscriber(DataProcessor processor)
{
_processor = processor;
_processor.Processed += HandleDataProcessed;
}
private void HandleDataProcessed(object sender, DataProcessedEventArgs e)
{
// Handle event
}
public void Dispose()
{
_processor.Processed -= HandleDataProcessed;
}
}
Step 5: Apply Weak Event Pattern for Long-Lived Publishers
When publishers outlive subscribers, strong references prevent garbage collection. Use WeakEventManager<TEventSource, TEventArgs> or implement a custom weak subscription registry.
public class WeakDataProcessor : WeakEventManager
{
private event DataProcessedEventHandler InternalProcessed;
public void AddHandler(object source, DataProcessedEventHandler handler)
{
AddHandler(source, handler, nameof(InternalProcessed));
}
public void RemoveHandler(object source, DataProcessedEventHandler handler)
{
RemoveHandler(source, handler, nameof(InternalProcessed));
}
protected override void DeliverEvent(object sender, EventArgs e)
{
InternalProcessed?.Invoke(sender, (DataProcessedEventArgs)e);
}
}
Architecture Decisions and Rationale
- Use standard events for short-lived, tightly coupled components where lifecycle boundaries are explicit.
- Use weak event patterns when publishers are singletons, services, or UI frameworks that outlive subscribers.
- Avoid multicast delegates for critical error handling; one failing handler blocks subsequent handlers. Wrap invocation in try/catch or switch to
IObserver<T>/Rx.NET for resilient pipelines.
- Prefer
async void event handlers only for UI fire-and-forget scenarios. For background processing, use async Task with explicit await chains or channel-based message passing.
Pitfall Guide
1. Invoking Events Without Null Checks or Thread-Safe Copying
Direct invocation Processed?.Invoke() is safe in single-threaded contexts, but concurrent unsubscription can cause NullReferenceException or ObjectDisposedException. Always cache the delegate reference before invocation. In high-concurrency scenarios, use Interlocked.CompareExchange or lock-free publication patterns.
2. Forgetting to Unsubscribe
Strong references from publishers to subscribers prevent garbage collection. Long-running applications will accumulate orphaned handlers, causing memory bloat and stale callback execution. Implement IDisposable or use weak references to enforce lifecycle alignment.
3. Using Raw Delegates Instead of the event Keyword
Raw delegates allow external code to overwrite the invocation list (processor.Processed = null;) or invoke directly (processor.Processed?.Invoke()). This breaks encapsulation and violates publish-subscribe semantics. The event keyword restricts external access to += and -= only.
4. Assuming Execution Order Stability
Multicast delegates execute handlers in subscription order, but this order is not guaranteed across application restarts, reflection-based registrations, or framework-generated subscriptions. Do not rely on deterministic sequencing for business logic. Use explicit pipeline patterns or message queues when order matters.
5. Exception Propagation in Multicast Delegates
If one handler throws, subsequent handlers in the invocation list are skipped. This masks failures and creates inconsistent state. Wrap invocation in a loop that catches exceptions per handler, or switch to IObserver<T> which provides OnError semantics without breaking the pipeline.
6. Modifying Collections During Event Iteration
Events often trigger business logic that mutates shared state. If a handler adds or removes items from a collection being iterated elsewhere, InvalidOperationException occurs. Use snapshotting, concurrent collections, or deferred execution patterns to prevent mutation during traversal.
7. Ignoring Synchronization Context in UI Frameworks
Raising events from background threads in WPF/WinForms/MAUI causes cross-thread exceptions. Marshal to the UI thread using Dispatcher.Invoke or SynchronizationContext.Post. Alternatively, use async/await with ConfigureAwait(false) for non-UI work and explicit context switching for UI updates.
Best Practices from Production:
- Document expected handler behavior, thread affinity, and exception tolerance in XML comments.
- Use
EventHandler<T> for standard .NET patterns; reserve custom delegates for domain-specific signatures.
- Implement explicit cleanup contracts in DI containers; avoid singleton publishers with transient subscribers without weak references.
- Profile event-driven code under load; measure invocation latency and memory retention with
dotnet-counters and dotnet-gcdump.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Short-lived component communication | Standard event | Explicit lifecycle, low overhead, compiler safety | Minimal |
| Singleton publisher with transient subscribers | Weak Event Pattern | Prevents memory leaks, decouples lifetimes | Moderate (setup complexity) |
| High-throughput async pipelines | Channel<T> or Rx.NET | Backpressure support, exception resilience, composability | High (learning curve) |
| UI framework callbacks | event + Dispatcher/SynchronizationContext | Thread safety, framework compliance | Low |
| Cross-process or distributed messaging | Message broker (RabbitMQ, Kafka) | Network resilience, persistence, scaling | High (infrastructure) |
Configuration Template
using System;
using System.Collections.Generic;
using System.Threading;
public readonly struct DataProcessedEventArgs : EventArgs
{
public int RecordId { get; }
public DateTime ProcessedAt { get; }
public DataProcessedEventArgs(int recordId)
{
RecordId = recordId;
ProcessedAt = DateTime.UtcNow;
}
}
public delegate void DataProcessedEventHandler(object sender, DataProcessedEventArgs e);
public class ProductionDataProcessor : IDisposable
{
private event DataProcessedEventHandler _processed;
private readonly object _syncRoot = new();
private bool _disposed;
public event DataProcessedEventHandler Processed
{
add
{
lock (_syncRoot)
{
_processed += value;
}
}
remove
{
lock (_syncRoot)
{
_processed -= value;
}
}
}
public void Process(int recordId)
{
if (_disposed) throw new ObjectDisposedException(nameof(ProductionDataProcessor));
var args = new DataProcessedEventArgs(recordId);
DataProcessedEventHandler handler;
lock (_syncRoot)
{
handler = _processed;
}
if (handler == null) return;
var invocationList = handler.GetInvocationList();
foreach (var del in invocationList)
{
try
{
((DataProcessedEventHandler)del)?.Invoke(this, args);
}
catch (Exception ex)
{
// Log or route to error handler; do not break pipeline
Console.Error.WriteLine($"Event handler failed: {ex.Message}");
}
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
lock (_syncRoot)
{
_processed = null;
}
}
}
Quick Start Guide
- Define your delegate signature with explicit sender and
EventArgs-derived payload. Avoid generic delegates for public contracts.
- Declare the event using the
event keyword. Implement explicit add/remove with synchronization if thread safety is required.
- Cache and invoke safely by copying the delegate reference before invocation. Iterate through
GetInvocationList() to isolate handler exceptions.
- Register subscribers with
+= and ensure disposal with -= or weak references. Validate cleanup in unit tests using memory profilers.
- Test under concurrency using
Parallel.ForEach or Task.Run to verify thread safety, exception isolation, and lifecycle alignment before deployment.