Observer Pattern Best Practices in C#: Code Organization and Maintainability
You can implement the observer pattern in a weekend. Keeping it maintainable for a year? That's where the real challenge lives. Subscriptions leak memory. Event handlers fire on the wrong thread. One failing observer crashes the entire notification chain. The observer pattern best practices in C# that separate a clean event-driven architecture from an unmaintainable tangle of subscriptions require deliberate attention to memory management, thread safety, naming conventions, and error handling.
This guide covers unsubscription strategies, strong typing, thread-safe notification, project structure, exception handling, testing, and common pitfalls. If you need a refresher on how the observer pattern fits among other design patterns, start there and come back when you're ready to level up.
Always Unsubscribe: Preventing Memory Leaks
The single most important observer pattern best practice in C# is cleaning up subscriptions. When an observer subscribes to an event, the publisher holds a reference to the observer. If the observer goes out of scope without unsubscribing, the garbage collector cannot collect it -- the publisher's delegate list keeps it alive. In long-running applications like web servers or desktop apps, this creates a slow memory leak that's difficult to diagnose.
Here's the anti-pattern -- subscribing without any cleanup mechanism:
using System;
public class InventoryService
{
public event EventHandler<InventoryChangedEventArgs>?
InventoryChanged;
public void UpdateStock(string sku, int quantity)
{
// Update logic here...
InventoryChanged?.Invoke(
this,
new InventoryChangedEventArgs(sku, quantity));
}
}
public class InventoryChangedEventArgs : EventArgs
{
public string Sku { get; }
public int Quantity { get; }
public InventoryChangedEventArgs(
string sku,
int quantity)
{
Sku = sku;
Quantity = quantity;
}
}
// Bad: subscribes but never unsubscribes
public class DashboardWidget
{
public DashboardWidget(InventoryService service)
{
service.InventoryChanged += OnInventoryChanged;
}
private void OnInventoryChanged(
object? sender,
InventoryChangedEventArgs e)
{
Console.WriteLine(
$"Widget updating: {e.Sku} = {e.Quantity}");
}
}
When DashboardWidget instances are created and discarded repeatedly, each stays in memory because InventoryService holds a reference through the event delegate. The fix is implementing IDisposable:
using System;
public class DashboardWidget : IDisposable
{
private readonly InventoryService _service;
private bool _disposed;
public DashboardWidget(InventoryService service)
{
_service = service
?? throw new ArgumentNullException(
nameof(service));
_service.InventoryChanged += OnInventoryChanged;
}
private void OnInventoryChanged(
object? sender,
InventoryChangedEventArgs e)
{
Console.WriteLine(
$"Widget updating: {e.Sku} = {e.Quantity}");
}
public void Dispose()
{
if (!_disposed)
{
_service.InventoryChanged -= OnInventoryChanged;
_disposed = true;
}
}
}
Now callers can wrap DashboardWidget in a using block or call Dispose() explicitly. Implementing IDisposable is the standard approach for deterministic cleanup. For scenarios where you can't control the observer's lifecycle, weak events offer an alternative -- they use WeakReference<T> so the publisher doesn't prevent garbage collection of the subscriber.
The rule is simple: every += should have a corresponding -=. If you can't guarantee that the unsubscription will happen, use weak references.
Use Strong Typing for Event Data
Generic event data defeats the purpose of a type-safe language. When observers receive object payloads and cast them at runtime, you lose compile-time safety. A best practice is using strongly-typed event arguments that make the publisher-subscriber contract explicit and verifiable at compile time.
Here's the weak typing approach that creates problems:
using System;
// Bad: loosely typed event data
public class OrderService
{
public event EventHandler? OrderPlaced;
public void PlaceOrder(string orderId, decimal total)
{
// Process order...
OrderPlaced?.Invoke(this, EventArgs.Empty);
// Observer has no idea what order was placed
}
}
The observer knows something happened but has no structured access to the details. The strongly-typed alternative:
using System;
public class OrderPlacedEventArgs : EventArgs
{
public string OrderId { get; }
public decimal Total { get; }
public DateTime PlacedAt { get; }
public OrderPlacedEventArgs(
string orderId,
decimal total,
DateTime placedAt)
{
OrderId = orderId;
Total = total;
PlacedAt = placedAt;
}
}
public class OrderService
{
public event EventHandler<OrderPlacedEventArgs>?
OrderPlaced;
public void PlaceOrder(string orderId, decimal total)
{
// Process order...
OrderPlaced?.Invoke(
this,
new OrderPlacedEventArgs(
orderId,
total,
DateTime.UtcNow));
}
}
Now every observer receives a typed OrderPlacedEventArgs with named properties. Renaming a property triggers a compile error in every observer. Adding a new property is safe because existing observers don't break.
Make your EventArgs subclasses immutable. Set properties through the constructor and expose them as read-only. This prevents observers from mutating event data that affects other observers down the chain and simplifies threading concerns.
Thread Safety in Multi-Threaded Observers
Web applications, background workers, and message handlers all process requests concurrently. If your observer infrastructure isn't thread-safe, you'll hit race conditions that are intermittent, difficult to reproduce, and painful to debug. Thread safety is an observer pattern best practice in C# that's easy to overlook during development and critical in production.
The classic problem is the null-check race condition when raising events:
using System;
public class SensorMonitor
{
public event EventHandler<SensorReadingEventArgs>?
ReadingReceived;
// Bad: race condition between null check and invoke
public void ReportReading(double value)
{
if (ReadingReceived != null)
{
// Another thread could unsubscribe the last
// handler between the null check and this call
ReadingReceived(
this,
new SensorReadingEventArgs(value));
}
}
}
public class SensorReadingEventArgs : EventArgs
{
public double Value { get; }
public SensorReadingEventArgs(double value)
{
Value = value;
}
}
Between the null check and the invocation, another thread could unsubscribe the last handler, causing a NullReferenceException. The fix is the null-conditional invoke pattern:
using System;
public class SensorMonitor
{
public event EventHandler<SensorReadingEventArgs>?
ReadingReceived;
// Good: thread-safe raise pattern
public void ReportReading(double value)
{
ReadingReceived?.Invoke(
this,
new SensorReadingEventArgs(value));
}
}
The ?.Invoke() pattern captures the delegate reference atomically. This is the standard thread-safe event raising pattern in modern C# and should be your default.
For custom observer implementations that maintain subscriber lists manually, use thread-safe collections:
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
public class NotificationHub<T>
{
private readonly ConcurrentDictionary<
Guid,
Action<T>> _observers = new();
public IDisposable Subscribe(Action<T> observer)
{
var key = Guid.NewGuid();
_observers.TryAdd(key, observer);
return new Subscription(
() => _observers.TryRemove(key, out _));
}
public void Notify(T data)
{
foreach (var observer in _observers.Values)
{
observer(data);
}
}
private sealed class Subscription : IDisposable
{
private readonly Action _unsubscribe;
private bool _disposed;
public Subscription(Action unsubscribe)
{
_unsubscribe = unsubscribe;
}
public void Dispose()
{
if (!_disposed)
{
_unsubscribe();
_disposed = true;
}
}
}
}
ConcurrentDictionary allows safe concurrent subscription, unsubscription, and iteration. Snapshot iteration means that if an observer unsubscribes during notification, the iteration completes without throwing. This is significantly safer than a plain List<T> with manual locking.
For async event handlers, thread safety becomes even more nuanced -- asynchronous observers introduce questions about which thread processes the notification and whether exceptions propagate correctly.
Organize Event Infrastructure
Consistent naming and deliberate project structure keep observer-based code navigable. Good organization feels unnecessary with two events but becomes critical with twenty.
Naming Conventions
Follow established C# conventions for event-related types. Name events using past tense or descriptive nouns: OrderPlaced, InventoryChanged, TemperatureExceeded. Avoid verb-first names like PlaceOrder -- that reads like a command, not an event. Suffix event data classes with EventArgs -- OrderPlacedEventArgs, InventoryChangedEventArgs. Prefix handler methods with On: OnOrderPlaced, OnInventoryChanged. Use OnXxx in the publisher for the protected method that raises the event.
These conventions align with .NET framework standards, IntelliSense, and code analysis rules.
Folder Structure
Group event-related types alongside the domain they belong to, with a dedicated subfolder for event arguments:
src/
Orders/
IOrderService.cs
OrderService.cs
Events/
OrderPlacedEventArgs.cs
OrderCancelledEventArgs.cs
OrderShippedEventArgs.cs
Inventory/
IInventoryService.cs
InventoryService.cs
Events/
InventoryChangedEventArgs.cs
StockDepletedEventArgs.cs
This structure keeps related code together. The namespace mirrors the folder: MyApp.Orders.Events. This approach is consistent with how composition-based designs organize related components. Avoid scattering event args across a top-level Events/ folder that mixes concerns from every domain -- domain-grouped folders scale better.
Limit Observer Coupling
Observers should be independent of each other. If observer A depends on observer B having already processed the notification, you've introduced hidden coupling that's fragile and difficult to debug.
Here's the principle: each observer should process a notification as if it's the only subscriber. It should not assume anything about notification order, the existence of other subscribers, or the side effects produced by other observers.
Violation of this principle often looks like this: a DiscountCalculator observer modifies a shared order object, and then a TaxCalculator observer reads the modified total. If DiscountCalculator doesn't fire first, tax is calculated on the wrong amount. The fix is restructuring so each observer works from the original immutable event data.
This connects to the strong typing advice from earlier. When your EventArgs objects are immutable, observers physically cannot modify data that others depend on. If you genuinely need sequenced processing, consider a pipeline or chain of responsibility instead -- these patterns make ordering explicit.
Handle Observer Exceptions Gracefully
When one observer throws an exception during notification, what happens to the remaining observers? With standard C# events, the answer is stark: they don't execute. The exception propagates up to the publisher, and every observer after the failing one is silently skipped. Handling this properly is a critical best practice.
Here's the problem in action:
using System;
public class PriceService
{
public event EventHandler<PriceChangedEventArgs>?
PriceChanged;
public void UpdatePrice(string symbol, decimal price)
{
// If any observer throws, the rest are skipped
PriceChanged?.Invoke(
this,
new PriceChangedEventArgs(symbol, price));
}
}
public class PriceChangedEventArgs : EventArgs
{
public string Symbol { get; }
public decimal Price { get; }
public PriceChangedEventArgs(
string symbol,
decimal price)
{
Symbol = symbol;
Price = price;
}
}
If you have three subscribers -- a dashboard updater, an alert service, and an audit logger -- and the alert service throws, the audit logger never fires. The solution is a safe notification method that catches exceptions per observer:
using System;
using System.Linq;
public class PriceService
{
public event EventHandler<PriceChangedEventArgs>?
PriceChanged;
protected virtual void OnPriceChanged(
PriceChangedEventArgs e)
{
var handler = PriceChanged;
if (handler == null)
{
return;
}
foreach (var subscriber in
handler.GetInvocationList()
.Cast<EventHandler<PriceChangedEventArgs>>())
{
try
{
subscriber(this, e);
}
catch (Exception ex)
{
Console.WriteLine(
$"Observer failed: {ex.Message}");
// Log the exception, but continue
// notifying remaining observers
}
}
}
public void UpdatePrice(string symbol, decimal price)
{
OnPriceChanged(
new PriceChangedEventArgs(symbol, price));
}
}
By iterating through GetInvocationList() and wrapping each invocation in a try/catch, one failing observer cannot prevent the others from receiving the notification. Log the exception so failures are visible, but don't let one misbehaving subscriber take down the entire chain.
Testing Observer-Based Code
Testability is one of the observer pattern's greatest strengths in C#, but only if you approach it correctly. The decoupled nature of publishers and subscribers means you can test each side independently with mocks and assertions. This is an observer pattern best practice that keeps your test suite fast, focused, and maintainable.
Here's the core testing strategy. For the publisher, verify that events fire with the correct data. For the subscriber, verify the handler reacts correctly when invoked:
using System;
using Xunit;
public class InventoryServiceTests
{
[Fact]
public void UpdateStock_RaisesInventoryChanged()
{
// Arrange
var service = new InventoryService();
InventoryChangedEventArgs? receivedArgs = null;
service.InventoryChanged += (sender, args) =>
{
receivedArgs = args;
};
// Act
service.UpdateStock("SKU-100", 42);
// Assert
Assert.NotNull(receivedArgs);
Assert.Equal("SKU-100", receivedArgs.Sku);
Assert.Equal(42, receivedArgs.Quantity);
}
[Fact]
public void UpdateStock_NoSubscribers_DoesNotThrow()
{
// Arrange
var service = new InventoryService();
// Act & Assert -- no exception
service.UpdateStock("SKU-100", 42);
}
[Fact]
public void Dispose_PreventsSubsequentNotifications()
{
// Arrange
var service = new InventoryService();
var callCount = 0;
var widget = new DashboardWidget(service);
service.InventoryChanged += (_, _) => callCount++;
// Act
widget.Dispose();
service.UpdateStock("SKU-100", 42);
// Assert
Assert.Equal(1, callCount);
}
}
A few testing guidelines for observer pattern best practices in C#:
- Test that events fire with correct data. Subscribe a lambda, capture the event args, and assert on the values.
- Test unsubscription. Verify that after
Dispose()or-=, the observer stops receiving notifications. - Test with no subscribers. Verify no
NullReferenceExceptionwhen no one is subscribed. - Test observer independence. Invoke the handler directly with known event args rather than going through the publisher.
For dependency injection scenarios, IServiceCollection makes it straightforward to register publishers and verify wiring in integration tests.
Avoid These Common Pitfalls
Even experienced developers trip over these mistakes when building observer-based systems. Knowing these pitfalls upfront is one of the most practical things you can internalize.
Event Handler Memory Leaks
This was covered in the unsubscription section, but it's worth repeating: every += needs a corresponding -=. If your observer has a shorter lifecycle than the publisher, implement IDisposable. Use weak events when lifecycle management is impractical.
Modifying the Subscriber Collection During Iteration
If an observer unsubscribes during notification, you risk an InvalidOperationException from modifying a collection while iterating it. Standard C# events handle this safely because multicast delegates are immutable. But custom observer lists using List<T> will crash. Use ConcurrentDictionary, snapshot the list before iterating, or use immutable collections.
Deep Notification Chains
Observer A fires an event. Observer B handles it and modifies state that triggers another event. Observer C handles that and triggers yet another. Before long, a single state change cascades through a chain that's nearly impossible to trace, and can even cause infinite loops.
Limit chain depth to two levels at most. If you need complex multi-step reactions, use an explicit workflow or saga pattern.
Excessive Event Granularity
Not every property change needs its own event. A class with NameChanged, EmailChanged, PhoneChanged, and AddressChanged events creates an explosion of subscription management. Consider consolidating into fewer meaningful events like ProfileUpdated with event args that indicate what changed.
Blocking the Publisher in Event Handlers
If an observer does expensive work synchronously -- a database call, an HTTP request, file I/O -- it blocks the publisher until the handler completes. Push expensive work onto background threads or queues within the observer rather than blocking the notification pipeline.
Frequently Asked Questions
How do I prevent memory leaks with C# events and the observer pattern?
Implement IDisposable on your observer classes and unsubscribe from events in the Dispose() method. Every += should have a corresponding -=. For scenarios where you can't control the observer lifecycle, use weak events so the publisher doesn't prevent garbage collection.
Should I use C# events or IObservable for the observer pattern?
C# events are the simpler choice for most applications -- they're built into the language and understood by every C# developer. IObservable<T> with Reactive Extensions (Rx) is more powerful when you need composition operators like filtering, throttling, or buffering event streams. If your notification needs are straightforward, use events. If you're building complex event processing pipelines, IObservable<T> is worth the learning curve.
What naming conventions should I follow for events and event handlers in C#?
Name events using past tense or descriptive nouns: OrderPlaced, InventoryChanged. Suffix event data classes with EventArgs. Prefix handler methods with On. Use EventHandler<T> rather than custom delegate types. These conventions align with .NET framework standards.
How do I make event-driven code testable?
Test publishers and subscribers independently. For publishers, subscribe a lambda to capture event args and assert on the values. For subscribers, invoke the handler directly with known event args. Always test unsubscription and the no-subscribers case.
How does the observer pattern relate to other design patterns for code organization?
The observer pattern focuses on broadcasting state changes to multiple interested parties. It pairs well with other patterns that address different organizational concerns. Strategy pattern best practices cover swapping algorithms at runtime, while decorator pattern best practices address layering cross-cutting behavior. You'll often combine these patterns in a single system -- using observer for notifications, strategy for pluggable behavior, and decorators for concerns like logging or retry. The big list of design patterns provides context on how they fit together.
Can I use async event handlers with the observer pattern in C#?
You can, but it requires careful handling. Standard C# events don't natively support async delegates -- marking a handler as async void means exceptions won't propagate correctly and you lose the ability to await completion. For async scenarios, consider Func<T, Task> delegates and await each handler explicitly. Read more about async event handlers in C# before adopting this approach.
When should I use a mediator instead of the observer pattern?
Use a mediator when you need request/response semantics rather than broadcast notifications, when notification ordering matters, or when you want a central coordinator for complex interactions. The observer pattern excels at simple one-to-many broadcasting where subscribers are independent. If you find yourself adding ordering constraints or subscriber-to-subscriber communication, a mediator provides more structure.
Wrapping Up Observer Pattern Best Practices
Applying these observer pattern best practices in C# will help you build event-driven systems that stay maintainable as your codebase grows. The core themes are consistent: always unsubscribe to prevent memory leaks, use strong typing for event data, make notification thread-safe, organize event infrastructure by domain, keep observers independent, handle exceptions per observer, and test both sides in isolation.
The observer pattern is at its best when you treat events as contracts between independent components. The publisher defines what happened. The subscriber decides what to do about it. When you enforce immutable event data, implement IDisposable for cleanup, and wrap observer invocations in try/catch blocks, you get a notification system that's resilient, testable, and easy to extend.
Start simple with standard C# events and add complexity only as the need becomes clear. Not every application needs thread-safe custom observer lists or weak event patterns. But when you do need them, knowing these observer pattern best practices upfront saves you from debugging memory leaks and race conditions in production.

