You're building an e-commerce platform. When a product price changes, three things need to happen: update the search index, invalidate the cache, and notify customers who wishlisted it.
So you put all three calls in the UpdatePrice method:
public class ProductService{ private readonly ISearchService _search; private readonly ICacheService _cache; private readonly INotificationService _notifications; public async Task UpdatePriceAsync(int productId, decimal newPrice) { var product = await _repo.GetByIdAsync(productId); product.Price = newPrice; await _repo.SaveAsync(product); // All downstream effects hardcoded here await _search.UpdateIndexAsync(product); await _cache.InvalidateAsync($"product:{productId}"); await _notifications.NotifyWishlistUsersAsync(productId, newPrice); }}
Next month, analytics wants price change tracking. Then compliance needs an audit log. Then the pricing team wants to trigger automatic competitor checks. Your UpdatePriceAsync method grows with every new subscriber.
ProductService knows about search, cache, notifications, analytics, compliance, and competitive intelligence. It shouldn't know about any of them.
The ProductService is tightly coupled to every component that cares about price changes. Adding a new reaction means modifying the service. Removing one means modifying the service. And each new dependency makes testing harder.
The publisher shouldn't care who's listening. It should announce "price changed" and move on.
The Observer pattern defines a one-to-many relationship between objects. When the subject (publisher) changes state, all registered observers (subscribers) get notified automatically. The publisher doesn't know who the observers are.
You can use C# events — the language has the Observer pattern built in:
// Event args carrying the change datapublic class PriceChangedEventArgs : EventArgs{ public int ProductId { get; init; } public decimal OldPrice { get; init; } public decimal NewPrice { get; init; } public DateTime ChangedAt { get; init; }} // Publisher - raises events, doesn't know who listenspublic class ProductService{ private readonly IProductRepository _repo; public event EventHandler<PriceChangedEventArgs>? PriceChanged; public ProductService(IProductRepository repo) => _repo = repo; public async Task UpdatePriceAsync(int productId, decimal newPrice) { var product = await _repo.GetByIdAsync(productId); var oldPrice = product.Price; product.Price = newPrice; await _repo.SaveAsync(product); // Notify all observers - publisher doesn't know who they are PriceChanged?.Invoke(this, new PriceChangedEventArgs { ProductId = productId, OldPrice = oldPrice, NewPrice = newPrice, ChangedAt = DateTime.UtcNow }); }}
Subscribers register independently:
// Each observer handles one concernpublic class SearchIndexUpdater{ private readonly ISearchService _search; public SearchIndexUpdater(ISearchService search, ProductService products) { _search = search; products.PriceChanged += OnPriceChanged; } private async void OnPriceChanged(object? sender, PriceChangedEventArgs e) { await _search.UpdatePriceAsync(e.ProductId, e.NewPrice); }} public class CacheInvalidator{ private readonly ICacheService _cache; public CacheInvalidator(ICacheService cache, ProductService products) { _cache = cache; products.PriceChanged += OnPriceChanged; } private async void OnPriceChanged(object? sender, PriceChangedEventArgs e) { await _cache.InvalidateAsync($"product:{e.ProductId}"); }} public class WishlistNotifier{ private readonly INotificationService _notifications; public WishlistNotifier(INotificationService notifications, ProductService products) { _notifications = notifications; products.PriceChanged += OnPriceChanged; } private async void OnPriceChanged(object? sender, PriceChangedEventArgs e) { if (e.NewPrice < e.OldPrice) // Only notify on price drops { await _notifications.NotifyWishlistUsersAsync(e.ProductId, e.NewPrice); } }}
Adding analytics? Create a new observer. Wire it in DI. ProductService never changes.
Zero coupling. The publisher raises an event and moves on. It doesn't import, inject, or know about any observer.
Open for extension. Adding a new reaction is adding a new class. No existing code is modified.
Each observer is independent. Cache invalidation doesn't affect search indexing. A failing observer doesn't block others (with proper error handling).
For a more structured approach, combine Observer with the Mediator pattern:
public record PriceChangedEvent( int ProductId, decimal OldPrice, decimal NewPrice) : INotification; public class ProductService{ private readonly IProductRepository _repo; private readonly IMediator _mediator; public async Task UpdatePriceAsync(int productId, decimal newPrice) { var product = await _repo.GetByIdAsync(productId); var oldPrice = product.Price; product.Price = newPrice; await _repo.SaveAsync(product); // Publish domain event - handlers discovered via DI await _mediator.Publish(new PriceChangedEvent(productId, oldPrice, newPrice)); }} // Handlers registered automatically through DI scanningpublic class UpdateSearchIndex : INotificationHandler<PriceChangedEvent>{ public Task Handle(PriceChangedEvent notification, CancellationToken ct) => _search.UpdatePriceAsync(notification.ProductId, notification.NewPrice);} public class AuditPriceChange : INotificationHandler<PriceChangedEvent>{ public Task Handle(PriceChangedEvent notification, CancellationToken ct) => _audit.LogAsync($"Price changed: {notification.OldPrice} -> {notification.NewPrice}");}
No manual event wiring. DI discovers handlers automatically.
For continuous data streams, use .NET's reactive IObservable<T>:
public class StockPriceMonitor : IObservable<StockPrice>{ private readonly List<IObserver<StockPrice>> _observers = new(); public IDisposable Subscribe(IObserver<StockPrice> observer) { _observers.Add(observer); return new Unsubscriber(_observers, observer); } public void PublishPrice(StockPrice price) { foreach (var observer in _observers) observer.OnNext(price); } private class Unsubscriber : IDisposable { private readonly List<IObserver<StockPrice>> _observers; private readonly IObserver<StockPrice> _observer; public Unsubscriber(List<IObserver<StockPrice>> observers, IObserver<StockPrice> observer) { _observers = observers; _observer = observer; } public void Dispose() => _observers.Remove(_observer); }} // Alert observerpublic class PriceAlertObserver : IObserver<StockPrice>{ private readonly decimal _threshold; public PriceAlertObserver(decimal threshold) => _threshold = threshold; public void OnNext(StockPrice value) { if (value.Price > _threshold) Console.WriteLine($"ALERT: {value.Symbol} hit ${value.Price}"); } public void OnError(Exception error) => Console.WriteLine($"Error: {error.Message}"); public void OnCompleted() => Console.WriteLine("Market closed.");}
When there's only one subscriber. If updating the price always triggers exactly one action, direct method call is clearer than the Observer machinery.
When order matters. Observers execute in an unpredictable order. If "update cache" must happen before "send notification," use explicit sequencing instead.
When you need guaranteed delivery. C# events are fire-and-forget. If an observer throws, subsequent observers may not execute. For reliable event delivery, use a message broker.
When memory leaks are a risk. Event handlers hold references. If observers don't unsubscribe, the publisher keeps them alive. This is a common source of memory leaks in long-lived applications.
event keyword is the simplest Observer implementationIObservable<T> works for continuous data streamsThe Observer pattern lets an object notify other objects when its state changes, without knowing who those objects are. Think of it like a newsletter: the publisher sends updates, subscribers receive them, and the publisher doesn't maintain a mailing list.
When a change in one object should trigger reactions in multiple other objects, and you don't want the source to know about them all. Common in UI frameworks, event-driven architectures, and domain events.
For a single subscriber doing one thing, yes. Direct method calls are simpler. Observer becomes valuable when you have 3+ independent reactions to the same event or when the set of reactions changes over time.
Message brokers (RabbitMQ, Azure Service Bus) for distributed systems. The Mediator pattern for in-process event dispatching. Polling/polling-based approaches when push notifications aren't feasible.
The Observer pattern is the backbone of event-driven programming. C# gives you three flavors: event keyword for simple cases, IObservable<T> for streams, and MediatR notifications for DI-friendly domain events.
Pick the one that matches your complexity. Don't over-engineer simple notifications with a full event bus. And always remember to unsubscribe.
That's all from me today.
P.S. Follow me on YouTube.
If you made it this far, you're clearly serious about writing better .NET code. Here's a 20% discount code: DEEP20 for Design Patterns that Deliver. Consider it a thank-you for actually reading the whole thing.
Here are 2 ebooks I have about design patterns:
Design Patterns that Deliver — 5 essential patterns (Builder, Decorator, Strategy, Adapter, Mediator) with production-ready C# code and real-world examples. Or try a free chapter on the Builder Pattern first.
Design Patterns Simplified — A beginner-friendly guide to understanding design patterns without the academic fluff.
Stop arguing about code style. In this course you get a production-proven setup with analyzers, CI quality gates, and architecture tests — the exact system I use in real projects. Join here.
Not sure yet? Grab the free Starter Kit — a drop-in setup with the essentials from Module 01.
Design Patterns that Deliver — Solve real problems with 5 battle-tested patterns (Builder, Decorator, Strategy, Adapter, Mediator) using practical, real-world examples. Trusted by 650+ developers.
Just getting started? Design Patterns Simplified covers 10 essential patterns in a beginner-friendly, 30-page guide for just $9.95.
Every Monday morning, I share 1 actionable tip on C#, .NET & Architecture that you can use right away. Join here.
Join 20,000+ subscribers who mass-improve their .NET skills with actionable tips on C#, Software Architecture & Best Practices.
Subscribe to the TheCodeMan.net and be among the 20,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.