BrandGhost
Observer Pattern Real-World Example in C#: Complete Implementation

Observer Pattern Real-World Example in C#: Complete Implementation

Observer Pattern Real-World Example in C#: Complete Implementation

Most observer pattern tutorials wire up a "WeatherStation" that prints temperature to the console and stop there. That's great for understanding the concept, but it doesn't help when you're building a system where inventory changes need to trigger emails, update dashboards, reorder stock, and create audit records -- all without coupling those responsibilities together. This article builds a complete observer pattern real-world example in C# from the ground up: a real-time inventory alert system where multiple independent subscribers react to stock-level changes through a clean, extensible notification pipeline.

By the end, you'll have a full set of compilable classes that you can drop into a .NET project. We'll design the observer interfaces, build a thread-safe inventory subject, implement four distinct observers that each handle a different business concern, wire everything together with dependency injection, and write tests that verify each piece in isolation. If you've been looking for a practical observer pattern implementation that goes beyond textbook examples, this is it.

The Problem: Inventory Notifications Without the Observer Pattern

Consider a warehouse management system that needs to react when product stock levels change. Without the observer pattern, the inventory tracking class becomes the single place where every notification concern lives. Here's what that looks like in practice:

public class InventoryManager
{
    private readonly IEmailService _emailService;
    private readonly IDashboardService _dashboardService;
    private readonly IReorderService _reorderService;
    private readonly IAuditLogger _auditLogger;
    private readonly Dictionary<string, int> _stockLevels = new();

    public InventoryManager(
        IEmailService emailService,
        IDashboardService dashboardService,
        IReorderService reorderService,
        IAuditLogger auditLogger)
    {
        _emailService = emailService;
        _dashboardService = dashboardService;
        _reorderService = reorderService;
        _auditLogger = auditLogger;
    }

    public void UpdateStock(
        string productId,
        int newQuantity)
    {
        int oldQuantity = _stockLevels
            .GetValueOrDefault(productId, 0);
        _stockLevels[productId] = newQuantity;

        // Email alert when stock is low
        if (newQuantity < 10)
        {
            _emailService.SendLowStockAlert(
                productId, newQuantity);
        }

        // Dashboard update
        _dashboardService.RefreshProductDisplay(
            productId, oldQuantity, newQuantity);

        // Automatic reorder
        if (newQuantity < 5)
        {
            _reorderService.PlaceReorder(
                productId, 100 - newQuantity);
        }

        // Audit log
        _auditLogger.LogChange(
            productId, oldQuantity, newQuantity);
    }
}

This class violates the Single Responsibility Principle in a way that creates real problems. The InventoryManager has to know about email thresholds, dashboard refresh logic, reorder quantities, and audit formatting. Every time you add a new notification type -- say, a Slack channel alert or a metrics collector -- you're modifying this class. Every time you change a threshold for one concern, you risk introducing bugs in another.

Testing is painful because you need to mock four different services just to verify that a stock update works correctly. And if you want to disable email alerts in a staging environment, you're either passing in null services or adding feature flags to a class that's already doing too much. The observer pattern eliminates all of this by decoupling the "something changed" event from the "here's what we do about it" logic.

Designing the Observer Interfaces

Every observer pattern real-world example in C# starts with two clean interfaces: one for the subject that publishes changes and one for the observers that react to them. We also need an event object to carry the details of what changed. Let's design all three:

using System;

namespace InventoryAlerts.Core;

public sealed record InventoryChangeEvent(
    string ProductId,
    string ProductName,
    int PreviousQuantity,
    int NewQuantity,
    DateTimeOffset Timestamp)
{
    public int QuantityDelta =>
        NewQuantity - PreviousQuantity;

    public bool IsStockDecreased =>
        NewQuantity < PreviousQuantity;

    public bool IsStockDepleted =>
        NewQuantity == 0;
}

The InventoryChangeEvent is a positional record, which makes it immutable and gives us value-based equality for free. It carries everything an observer needs to decide how to react: the product identifier, human-readable name, previous and new quantities, and a timestamp. The computed properties QuantityDelta, IsStockDecreased, and IsStockDepleted save observers from recalculating the same values independently.

Now for the observer and subject interfaces:

using System.Threading;
using System.Threading.Tasks;

namespace InventoryAlerts.Core;

public interface IInventoryObserver
{
    string ObserverName { get; }

    Task OnInventoryChangedAsync(
        InventoryChangeEvent changeEvent,
        CancellationToken cancellationToken = default);
}

public interface IInventorySubject
{
    void Subscribe(IInventoryObserver observer);
    void Unsubscribe(IInventoryObserver observer);
    Task NotifyObserversAsync(
        InventoryChangeEvent changeEvent,
        CancellationToken cancellationToken = default);
}

There are two deliberate design choices here. First, the observer's notification method is asynchronous. In a real inventory system, sending emails and calling external APIs are I/O-bound operations. Making the interface async from the start means observers don't need to block threads while waiting for network calls. If you've worked with async event handlers in C#, you know that retrofitting async onto a synchronous event model creates headaches -- so we avoid that entirely. Second, the ObserverName property gives each observer a human-readable identifier for logging and diagnostics, which is valuable when debugging a pipeline with multiple subscribers.

Building the Inventory Subject

The InventoryTracker class is our concrete subject. It manages product stock levels, maintains the list of registered observers, and notifies them when quantities change. Thread safety matters here because inventory updates can come from multiple sources -- API endpoints, background jobs, manual adjustments -- potentially on different threads:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

using Microsoft.Extensions.Logging;

namespace InventoryAlerts.Core;

public sealed class InventoryTracker : IInventorySubject
{
    private readonly List<IInventoryObserver> _observers = new();
    private readonly Dictionary<string, (string Name, int Quantity)> _products = new();
    private readonly ILogger<InventoryTracker> _logger;
    private readonly object _lock = new();

    public InventoryTracker(
        ILogger<InventoryTracker> logger)
    {
        _logger = logger;
    }

    public void Subscribe(IInventoryObserver observer)
    {
        lock (_lock)
        {
            if (_observers.Contains(observer))
            {
                _logger.LogWarning(
                    "Observer {Name} is already subscribed",
                    observer.ObserverName);
                return;
            }

            _observers.Add(observer);
            _logger.LogInformation(
                "Observer {Name} subscribed. " +
                "Total observers: {Count}",
                observer.ObserverName,
                _observers.Count);
        }
    }

    public void Unsubscribe(IInventoryObserver observer)
    {
        lock (_lock)
        {
            if (_observers.Remove(observer))
            {
                _logger.LogInformation(
                    "Observer {Name} unsubscribed. " +
                    "Total observers: {Count}",
                    observer.ObserverName,
                    _observers.Count);
            }
        }
    }

    public async Task NotifyObserversAsync(
        InventoryChangeEvent changeEvent,
        CancellationToken cancellationToken = default)
    {
        List<IInventoryObserver> snapshot;
        lock (_lock)
        {
            snapshot = _observers.ToList();
        }

        foreach (var observer in snapshot)
        {
            try
            {
                await observer.OnInventoryChangedAsync(
                    changeEvent, cancellationToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(
                    ex,
                    "Observer {Name} threw an exception " +
                    "while processing event for {ProductId}",
                    observer.ObserverName,
                    changeEvent.ProductId);
            }
        }
    }

    public async Task UpdateStockAsync(
        string productId,
        string productName,
        int newQuantity,
        CancellationToken cancellationToken = default)
    {
        int previousQuantity;
        lock (_lock)
        {
            previousQuantity = _products
                .TryGetValue(productId, out var existing)
                ? existing.Quantity
                : 0;

            _products[productId] = (productName, newQuantity);
        }

        var changeEvent = new InventoryChangeEvent(
            productId,
            productName,
            previousQuantity,
            newQuantity,
            DateTimeOffset.UtcNow);

        _logger.LogInformation(
            "Stock updated for {ProductId}: " +
            "{Previous} -> {New}",
            productId,
            previousQuantity,
            newQuantity);

        await NotifyObserversAsync(
            changeEvent, cancellationToken);
    }

    public int GetStockLevel(string productId)
    {
        lock (_lock)
        {
            return _products
                .TryGetValue(productId, out var product)
                ? product.Quantity
                : 0;
        }
    }
}

A few things to call out in this implementation. The lock around the observer list ensures that subscribing, unsubscribing, and iterating over observers won't cause a ConcurrentModificationException. The NotifyObserversAsync method takes a snapshot of the observer list before iterating, so an observer can safely unsubscribe itself during notification without invalidating the enumeration.

The try-catch inside the notification loop is critical for production systems. If the email observer throws because the SMTP server is down, you still want the dashboard, reorder, and audit observers to run. Without this safeguard, a single failing observer would silently kill the entire notification pipeline. This is one area where a textbook implementation often falls short -- error isolation between observers is what makes the pattern production-grade. If you're interested in how other patterns handle pipeline-style execution with error boundaries, the pipeline design pattern uses a similar approach.

Observer 1: Email Alert Observer

The first observer sends email notifications when stock drops below a configurable threshold. This is the kind of observer that connects to an external service, which is why the async interface matters:

using System.Threading;
using System.Threading.Tasks;

using Microsoft.Extensions.Logging;

namespace InventoryAlerts.Observers;

public sealed class EmailAlertObserver : IInventoryObserver
{
    private readonly IEmailService _emailService;
    private readonly ILogger<EmailAlertObserver> _logger;
    private readonly int _lowStockThreshold;

    public string ObserverName => "EmailAlert";

    public EmailAlertObserver(
        IEmailService emailService,
        ILogger<EmailAlertObserver> logger,
        int lowStockThreshold = 10)
    {
        _emailService = emailService;
        _logger = logger;
        _lowStockThreshold = lowStockThreshold;
    }

    public async Task OnInventoryChangedAsync(
        InventoryChangeEvent changeEvent,
        CancellationToken cancellationToken)
    {
        if (changeEvent.NewQuantity >= _lowStockThreshold)
        {
            return;
        }

        var subject = changeEvent.IsStockDepleted
            ? $"URGENT: {changeEvent.ProductName} " +
              "is out of stock"
            : $"Low stock alert: {changeEvent.ProductName} " +
              $"({changeEvent.NewQuantity} remaining)";

        var body =
            $"Product: {changeEvent.ProductName}
" +
            $"Product ID: {changeEvent.ProductId}
" +
            $"Previous Quantity: " +
            $"{changeEvent.PreviousQuantity}
" +
            $"Current Quantity: " +
            $"{changeEvent.NewQuantity}
" +
            $"Change: {changeEvent.QuantityDelta}
" +
            $"Timestamp: {changeEvent.Timestamp:O}";

        await _emailService.SendAsync(
            to: "[email protected]",
            subject: subject,
            body: body,
            cancellationToken: cancellationToken);

        _logger.LogInformation(
            "Low stock email sent for {ProductId} " +
            "(quantity: {Quantity})",
            changeEvent.ProductId,
            changeEvent.NewQuantity);
    }
}

public interface IEmailService
{
    Task SendAsync(
        string to,
        string subject,
        string body,
        CancellationToken cancellationToken = default);
}

This is the part of our observer pattern real-world example in C# where each subscriber starts to show its personality. The EmailAlertObserver makes its own decision about whether to act. It checks the stock level against its threshold and returns immediately if the quantity is still acceptable. This is a key advantage of the observer pattern -- each observer encapsulates its own filtering logic. The subject doesn't need to know that emails should only go out below a certain threshold, and other observers don't need to wait for the email logic to finish deciding.

The distinction between "out of stock" and "low stock" subject lines demonstrates that observers can contain meaningful business logic, not just forwarding data. Each observer is the expert on its own concern.

Observer 2: Dashboard Update Observer

The dashboard observer updates a real-time display whenever inventory levels change. Unlike the email observer, this one fires on every change regardless of quantity, because the dashboard should always reflect the current state:

using System.Threading;
using System.Threading.Tasks;

using Microsoft.Extensions.Logging;

namespace InventoryAlerts.Observers;

public sealed class DashboardObserver : IInventoryObserver
{
    private readonly IDashboardClient _dashboardClient;
    private readonly ILogger<DashboardObserver> _logger;

    public string ObserverName => "Dashboard";

    public DashboardObserver(
        IDashboardClient dashboardClient,
        ILogger<DashboardObserver> logger)
    {
        _dashboardClient = dashboardClient;
        _logger = logger;
    }

    public async Task OnInventoryChangedAsync(
        InventoryChangeEvent changeEvent,
        CancellationToken cancellationToken)
    {
        var status = changeEvent.NewQuantity switch
        {
            0 => StockStatus.OutOfStock,
            < 5 => StockStatus.Critical,
            < 20 => StockStatus.Low,
            _ => StockStatus.Normal
        };

        var update = new DashboardUpdate(
            changeEvent.ProductId,
            changeEvent.ProductName,
            changeEvent.NewQuantity,
            changeEvent.QuantityDelta,
            status,
            changeEvent.Timestamp);

        await _dashboardClient.PushUpdateAsync(
            update, cancellationToken);

        _logger.LogDebug(
            "Dashboard updated for {ProductId} " +
            "-- status: {Status}",
            changeEvent.ProductId,
            status);
    }
}

public enum StockStatus
{
    Normal,
    Low,
    Critical,
    OutOfStock
}

public sealed record DashboardUpdate(
    string ProductId,
    string ProductName,
    int CurrentQuantity,
    int QuantityDelta,
    StockStatus Status,
    DateTimeOffset Timestamp);

public interface IDashboardClient
{
    Task PushUpdateAsync(
        DashboardUpdate update,
        CancellationToken cancellationToken = default);
}

The switch expression maps stock quantities to meaningful status levels, giving the dashboard richer context than raw numbers alone. The DashboardUpdate record bundles everything the dashboard needs into a single immutable message. This observer demonstrates how the same InventoryChangeEvent can be transformed into entirely different shapes depending on the consumer's needs -- the email observer builds a string body, while the dashboard observer builds a typed data transfer object.

Observer 3: Automatic Reorder Observer

The reorder observer triggers purchase orders when stock drops below a reorder point. This is where the observer pattern really shines for business logic -- the reorder rules live entirely within this observer, completely independent of inventory tracking:

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

using Microsoft.Extensions.Logging;

namespace InventoryAlerts.Observers;

public sealed class ReorderObserver : IInventoryObserver
{
    private readonly IPurchaseOrderService _purchaseOrderService;
    private readonly ILogger<ReorderObserver> _logger;
    private readonly int _reorderPoint;
    private readonly int _reorderQuantity;
    private readonly HashSet<string> _pendingReorders = new();
    private readonly object _lock = new();

    public string ObserverName => "AutoReorder";

    public ReorderObserver(
        IPurchaseOrderService purchaseOrderService,
        ILogger<ReorderObserver> logger,
        int reorderPoint = 5,
        int reorderQuantity = 100)
    {
        _purchaseOrderService = purchaseOrderService;
        _logger = logger;
        _reorderPoint = reorderPoint;
        _reorderQuantity = reorderQuantity;
    }

    public async Task OnInventoryChangedAsync(
        InventoryChangeEvent changeEvent,
        CancellationToken cancellationToken)
    {
        if (changeEvent.NewQuantity >= _reorderPoint)
        {
            lock (_lock)
            {
                _pendingReorders.Remove(
                    changeEvent.ProductId);
            }
            return;
        }

        lock (_lock)
        {
            if (!_pendingReorders.Add(
                changeEvent.ProductId))
            {
                _logger.LogDebug(
                    "Reorder already pending for " +
                    "{ProductId} -- skipping",
                    changeEvent.ProductId);
                return;
            }
        }

        var order = new PurchaseOrder(
            changeEvent.ProductId,
            changeEvent.ProductName,
            _reorderQuantity,
            changeEvent.Timestamp);

        await _purchaseOrderService.SubmitOrderAsync(
            order, cancellationToken);

        _logger.LogInformation(
            "Reorder placed for {ProductId}: " +
            "{Quantity} units (stock was {Current})",
            changeEvent.ProductId,
            _reorderQuantity,
            changeEvent.NewQuantity);
    }
}

public sealed record PurchaseOrder(
    string ProductId,
    string ProductName,
    int Quantity,
    DateTimeOffset RequestedAt);

public interface IPurchaseOrderService
{
    Task SubmitOrderAsync(
        PurchaseOrder order,
        CancellationToken cancellationToken = default);
}

The _pendingReorders set prevents duplicate orders. If stock for "WIDGET-42" drops from 4 to 3 and then from 3 to 2, the observer places a reorder on the first drop and skips the second. When stock climbs back above the reorder point (because the reorder arrived), the product is removed from the pending set so future drops will trigger fresh orders. This kind of stateful observer logic is common in production systems and is exactly the sort of thing that becomes unmaintainable when it's tangled inside the inventory tracking class.

Observer 4: Audit Log Observer

The audit log observer records every inventory change for compliance and traceability. Unlike the other observers, this one never filters -- every single change gets logged:

using System;
using System.Threading;
using System.Threading.Tasks;

using Microsoft.Extensions.Logging;

namespace InventoryAlerts.Observers;

public sealed class AuditLogObserver : IInventoryObserver
{
    private readonly IAuditRepository _auditRepository;
    private readonly ILogger<AuditLogObserver> _logger;

    public string ObserverName => "AuditLog";

    public AuditLogObserver(
        IAuditRepository auditRepository,
        ILogger<AuditLogObserver> logger)
    {
        _auditRepository = auditRepository;
        _logger = logger;
    }

    public async Task OnInventoryChangedAsync(
        InventoryChangeEvent changeEvent,
        CancellationToken cancellationToken)
    {
        var entry = new AuditEntry(
            Id: Guid.NewGuid().ToString(),
            EntityType: "InventoryItem",
            EntityId: changeEvent.ProductId,
            Action: changeEvent.QuantityDelta >= 0
                ? "StockIncreased"
                : "StockDecreased",
            PreviousValue: changeEvent.PreviousQuantity
                .ToString(),
            NewValue: changeEvent.NewQuantity.ToString(),
            Timestamp: changeEvent.Timestamp,
            Details:
                $"{changeEvent.ProductName}: " +
                $"{changeEvent.PreviousQuantity} -> " +
                $"{changeEvent.NewQuantity} " +
                $"(delta: {changeEvent.QuantityDelta})");

        await _auditRepository.SaveAsync(
            entry, cancellationToken);

        _logger.LogDebug(
            "Audit entry created for {ProductId}: " +
            "{Action}",
            changeEvent.ProductId,
            entry.Action);
    }
}

public sealed record AuditEntry(
    string Id,
    string EntityType,
    string EntityId,
    string Action,
    string PreviousValue,
    string NewValue,
    DateTimeOffset Timestamp,
    string Details);

public interface IAuditRepository
{
    Task SaveAsync(
        AuditEntry entry,
        CancellationToken cancellationToken = default);
}

The audit observer captures structured data rather than free-form text. Every entry includes the entity type, a unique ID, the action classification, and both the previous and new values. This structure makes it easy to query the audit trail later -- "show me all StockDecreased events for WIDGET-42 in the last 30 days" becomes a straightforward database query rather than a text-parsing exercise. The observer pattern makes this kind of comprehensive logging trivial to add because the audit observer is completely independent of every other observer. You don't have to touch the email, dashboard, or reorder code to add compliance logging.

Wiring It All Together with Dependency Injection

With all four observers implemented, we need to register them with the IServiceCollection and connect them to the inventory subject. Here's how the dependency injection setup looks in a .NET application:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace InventoryAlerts.Configuration;

public static class InventoryServiceExtensions
{
    public static IServiceCollection AddInventoryAlerts(
        this IServiceCollection services,
        int lowStockThreshold = 10,
        int reorderPoint = 5,
        int reorderQuantity = 100)
    {
        services.AddSingleton<InventoryTracker>(sp =>
        {
            var tracker = new InventoryTracker(
                sp.GetRequiredService<
                    ILogger<InventoryTracker>>());

            var observers = sp
                .GetServices<IInventoryObserver>();

            foreach (var observer in observers)
            {
                tracker.Subscribe(observer);
            }

            return tracker;
        });

        services
            .AddSingleton<IInventorySubject>(sp =>
                sp.GetRequiredService<InventoryTracker>());

        services
            .AddSingleton<IInventoryObserver>(sp =>
                new EmailAlertObserver(
                    sp.GetRequiredService<IEmailService>(),
                    sp.GetRequiredService<
                        ILogger<EmailAlertObserver>>(),
                    lowStockThreshold));

        services
            .AddSingleton<IInventoryObserver>(sp =>
                new DashboardObserver(
                    sp.GetRequiredService<
                        IDashboardClient>(),
                    sp.GetRequiredService<
                        ILogger<DashboardObserver>>()));

        services
            .AddSingleton<IInventoryObserver>(sp =>
                new ReorderObserver(
                    sp.GetRequiredService<
                        IPurchaseOrderService>(),
                    sp.GetRequiredService<
                        ILogger<ReorderObserver>>(),
                    reorderPoint,
                    reorderQuantity));

        services
            .AddSingleton<IInventoryObserver>(sp =>
                new AuditLogObserver(
                    sp.GetRequiredService<
                        IAuditRepository>(),
                    sp.GetRequiredService<
                        ILogger<AuditLogObserver>>()));

        return services;
    }
}

This extension method gives you a single call to configure the entire observer pipeline. Each observer is registered as an IInventoryObserver, and the InventoryTracker factory resolves all of them and subscribes them automatically. Adding a new observer type is a one-line change -- register it as IInventoryObserver and the tracker picks it up.

Here's how you'd use it in Program.cs:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddInventoryAlerts(
    lowStockThreshold: 15,
    reorderPoint: 8,
    reorderQuantity: 200);

// Register your infrastructure implementations
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
builder.Services.AddSingleton<IDashboardClient, SignalRDashboardClient>();
builder.Services.AddSingleton<IPurchaseOrderService, ErpPurchaseOrderService>();
builder.Services.AddSingleton<IAuditRepository, SqlAuditRepository>();

var app = builder.Build();

var tracker = app.Services
    .GetRequiredService<InventoryTracker>();

await tracker.UpdateStockAsync(
    "WIDGET-42", "Premium Widget", 3);

The separation here is clean. The AddInventoryAlerts call sets up the observer pattern infrastructure. The concrete service registrations (SmtpEmailService, SignalRDashboardClient, etc.) are your infrastructure implementations that you swap out per environment. In staging, you might register a ConsoleEmailService instead of SmtpEmailService -- the observer doesn't care what's behind the IEmailService interface. If you're interested in how other patterns use this same DI composition approach, the decorator pattern uses a similar layering technique with IServiceCollection.

Testing the Observer Pipeline

No observer pattern real-world example in C# is complete without tests. Testing individual observers is straightforward because each one depends only on the IInventoryObserver interface contract and its own specific service. Here are tests for the email and reorder observers, plus an integration test that verifies the full pipeline:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

using Microsoft.Extensions.Logging;

using Xunit;

public class EmailAlertObserverTests
{
    [Fact]
    public async Task
        OnInventoryChanged_BelowThreshold_SendsEmail()
    {
        // Arrange
        var emailService = new FakeEmailService();
        var logger =
            new FakeLogger<EmailAlertObserver>();
        var observer = new EmailAlertObserver(
            emailService, logger, lowStockThreshold: 10);

        var changeEvent = new InventoryChangeEvent(
            "WIDGET-42", "Premium Widget",
            PreviousQuantity: 15,
            NewQuantity: 7,
            DateTimeOffset.UtcNow);

        // Act
        await observer.OnInventoryChangedAsync(
            changeEvent, CancellationToken.None);

        // Assert
        Assert.Single(emailService.SentEmails);
        Assert.Contains(
            "Low stock alert",
            emailService.SentEmails[0].Subject);
    }

    [Fact]
    public async Task
        OnInventoryChanged_AboveThreshold_NoEmail()
    {
        // Arrange
        var emailService = new FakeEmailService();
        var observer = new EmailAlertObserver(
            emailService,
            new FakeLogger<EmailAlertObserver>(),
            lowStockThreshold: 10);

        var changeEvent = new InventoryChangeEvent(
            "WIDGET-42", "Premium Widget",
            PreviousQuantity: 20,
            NewQuantity: 15,
            DateTimeOffset.UtcNow);

        // Act
        await observer.OnInventoryChangedAsync(
            changeEvent, CancellationToken.None);

        // Assert
        Assert.Empty(emailService.SentEmails);
    }
}

public class ReorderObserverTests
{
    [Fact]
    public async Task
        OnInventoryChanged_BelowReorderPoint_PlacesOrder()
    {
        // Arrange
        var purchaseOrderService =
            new FakePurchaseOrderService();
        var logger =
            new FakeLogger<ReorderObserver>();
        var observer = new ReorderObserver(
            purchaseOrderService, logger,
            reorderPoint: 5, reorderQuantity: 100);

        var changeEvent = new InventoryChangeEvent(
            "WIDGET-42", "Premium Widget",
            PreviousQuantity: 6,
            NewQuantity: 3,
            DateTimeOffset.UtcNow);

        // Act
        await observer.OnInventoryChangedAsync(
            changeEvent, CancellationToken.None);

        // Assert
        Assert.Single(purchaseOrderService.Orders);
        Assert.Equal(
            100,
            purchaseOrderService.Orders[0].Quantity);
    }

    [Fact]
    public async Task
        OnInventoryChanged_DuplicateDrop_SkipsSecondReorder()
    {
        // Arrange
        var purchaseOrderService =
            new FakePurchaseOrderService();
        var observer = new ReorderObserver(
            purchaseOrderService,
            new FakeLogger<ReorderObserver>(),
            reorderPoint: 5, reorderQuantity: 100);

        var firstDrop = new InventoryChangeEvent(
            "WIDGET-42", "Premium Widget",
            PreviousQuantity: 6, NewQuantity: 4,
            DateTimeOffset.UtcNow);

        var secondDrop = new InventoryChangeEvent(
            "WIDGET-42", "Premium Widget",
            PreviousQuantity: 4, NewQuantity: 2,
            DateTimeOffset.UtcNow);

        // Act
        await observer.OnInventoryChangedAsync(
            firstDrop, CancellationToken.None);
        await observer.OnInventoryChangedAsync(
            secondDrop, CancellationToken.None);

        // Assert
        Assert.Single(purchaseOrderService.Orders);
    }
}

public class InventoryTrackerIntegrationTests
{
    [Fact]
    public async Task
        UpdateStock_NotifiesAllObservers()
    {
        // Arrange
        var emailService = new FakeEmailService();
        var dashboardClient = new FakeDashboardClient();
        var purchaseOrderService =
            new FakePurchaseOrderService();
        var auditRepository = new FakeAuditRepository();

        var tracker = new InventoryTracker(
            new FakeLogger<InventoryTracker>());

        tracker.Subscribe(new EmailAlertObserver(
            emailService,
            new FakeLogger<EmailAlertObserver>(),
            lowStockThreshold: 10));

        tracker.Subscribe(new DashboardObserver(
            dashboardClient,
            new FakeLogger<DashboardObserver>()));

        tracker.Subscribe(new ReorderObserver(
            purchaseOrderService,
            new FakeLogger<ReorderObserver>(),
            reorderPoint: 5,
            reorderQuantity: 50));

        tracker.Subscribe(new AuditLogObserver(
            auditRepository,
            new FakeLogger<AuditLogObserver>()));

        // Act
        await tracker.UpdateStockAsync(
            "WIDGET-42", "Premium Widget", 3);

        // Assert
        Assert.Single(emailService.SentEmails);
        Assert.Single(dashboardClient.Updates);
        Assert.Single(purchaseOrderService.Orders);
        Assert.Single(auditRepository.Entries);

        Assert.Equal(
            StockStatus.Critical,
            dashboardClient.Updates[0].Status);
    }

    [Fact]
    public async Task
        FailingObserver_DoesNotBlockOtherObservers()
    {
        // Arrange
        var auditRepository = new FakeAuditRepository();
        var tracker = new InventoryTracker(
            new FakeLogger<InventoryTracker>());

        tracker.Subscribe(new ExplodingObserver());

        tracker.Subscribe(new AuditLogObserver(
            auditRepository,
            new FakeLogger<AuditLogObserver>()));

        // Act
        await tracker.UpdateStockAsync(
            "WIDGET-42", "Premium Widget", 10);

        // Assert
        Assert.Single(auditRepository.Entries);
    }
}

The test doubles that support these tests follow a simple pattern -- each fake service collects the calls it receives into a list for assertion:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

using Microsoft.Extensions.Logging;

public class FakeEmailService : IEmailService
{
    public List<(string To, string Subject, string Body)>
        SentEmails { get; } = new();

    public Task SendAsync(
        string to, string subject, string body,
        CancellationToken cancellationToken)
    {
        SentEmails.Add((to, subject, body));
        return Task.CompletedTask;
    }
}

public class FakeDashboardClient : IDashboardClient
{
    public List<DashboardUpdate> Updates { get; } = new();

    public Task PushUpdateAsync(
        DashboardUpdate update,
        CancellationToken cancellationToken)
    {
        Updates.Add(update);
        return Task.CompletedTask;
    }
}

public class FakePurchaseOrderService
    : IPurchaseOrderService
{
    public List<PurchaseOrder> Orders { get; } = new();

    public Task SubmitOrderAsync(
        PurchaseOrder order,
        CancellationToken cancellationToken)
    {
        Orders.Add(order);
        return Task.CompletedTask;
    }
}

public class FakeAuditRepository : IAuditRepository
{
    public List<AuditEntry> Entries { get; } = new();

    public Task SaveAsync(
        AuditEntry entry,
        CancellationToken cancellationToken)
    {
        Entries.Add(entry);
        return Task.CompletedTask;
    }
}

public class ExplodingObserver : IInventoryObserver
{
    public string ObserverName => "Exploding";

    public Task OnInventoryChangedAsync(
        InventoryChangeEvent changeEvent,
        CancellationToken cancellationToken)
    {
        throw new InvalidOperationException(
            "This observer always fails");
    }
}

public class FakeLogger<T> : ILogger<T>
{
    public List<string> Messages { get; } = new();

    public IDisposable? BeginScope<TState>(
        TState state) where TState : notnull => null;

    public bool IsEnabled(LogLevel logLevel) => true;

    public void Log<TState>(
        LogLevel logLevel, EventId eventId,
        TState state, Exception? exception,
        Func<TState, Exception?, string> formatter)
    {
        Messages.Add(formatter(state, exception));
    }
}

Each observer test is fully isolated. The EmailAlertObserverTests only need a FakeEmailService -- they don't know or care about dashboards, reorders, or audit logs. The integration test verifies the full pipeline end to end, including the critical behavior where a failing observer doesn't block the rest. That FailingObserver_DoesNotBlockOtherObservers test catches a bug that would be invisible in unit tests but catastrophic in production. You'll find this same philosophy of isolating concerns for testability across other design patterns -- the strategy pattern benefits from the same kind of interface-driven test doubles.

Frequently Asked Questions

How do I prevent memory leaks when observers hold references to the subject?

The observer pattern creates a reference from the subject to each observer through the subscriber list. If the subject is long-lived (like a singleton service) and observers are short-lived, those observers can't be garbage collected because the subject still holds references to them. Always call Unsubscribe when an observer's lifetime ends. For scenarios where explicit unsubscription is impractical, consider using weak events in C# so the garbage collector can reclaim observers even when the subject still exists.

Should observers run sequentially or in parallel?

It depends on your requirements. Sequential execution (as shown in this article) is simpler and guarantees ordering -- the audit log always runs after the reorder, for example. Parallel execution with Task.WhenAll improves throughput but makes error handling more complex and removes ordering guarantees. If your observers are independent and don't share mutable state, parallel execution is safe. If any observer's behavior depends on side effects from another observer, stick with sequential.

What's the difference between the observer pattern and C# events?

C# events and delegates are a language-level implementation of the observer pattern. They're more concise and idiomatic for .NET code, but they offer less control. With custom observer interfaces, you get async support, typed event data, named observers for diagnostics, and the ability to implement error isolation between subscribers. C# events use void delegates by convention, which makes async event handling awkward. Use events for simple scenarios within a single class hierarchy, and use custom observer interfaces when you need the flexibility demonstrated in this article. For a deeper comparison, you can check out how to work with async event handlers in C#.

How do I handle observer ordering when it matters?

If specific observers need to run before others, add a Priority property to the IInventoryObserver interface and sort the subscriber list when notifying. Alternatively, create separate event stages -- "pre-change" and "post-change" -- and let observers register for the appropriate stage. Avoid encoding ordering assumptions in the subject, because that couples the subject to specific observer implementations.

Can I add or remove observers at runtime?

Yes, and the thread-safe implementation in this article supports it. The Subscribe and Unsubscribe methods use locking, and NotifyObserversAsync takes a snapshot of the observer list before iterating. This means an observer can even unsubscribe itself during notification without causing a ConcurrentModificationException. Runtime subscription changes are common in production systems -- for example, you might subscribe a monitoring observer only during business hours.

How does the observer pattern compare to using a message bus or event broker?

The observer pattern is an in-process mechanism where the subject directly calls observer methods. A message bus (like RabbitMQ, Azure Service Bus, or MediatR) adds a layer of indirection and typically supports cross-process or cross-service communication. Use the observer pattern when all participants live in the same process and you want compile-time type safety. Use a message bus when you need to decouple across service boundaries, persist events, or support replay. The observer pattern is lighter and faster; message buses are more resilient and distributed.

What happens if an observer throws an exception during notification?

In our implementation, exceptions are caught and logged, and the remaining observers continue to execute. This is a deliberate design decision for production resilience. If the email server is down, you still want the audit log to record the inventory change. Some implementations let exceptions propagate, which means a single failing observer can break the entire notification pipeline. Always wrap observer calls in try-catch blocks unless you have a specific reason to let failures cascade.

Wrapping Up This Observer Pattern Real-World Example

This observer pattern real-world example in C# demonstrates the pattern solving a genuine business problem -- turning a monolithic inventory notification system into a set of independent, testable subscribers that each handle exactly one concern. We started with a tightly coupled InventoryManager that knew about emails, dashboards, reorders, and audit logs. We ended with an InventoryTracker that knows nothing about what happens after a stock level changes, and four focused observer classes that each own their own logic.

The observer pattern isn't about adding architectural overhead. It's about putting business rules where they belong. Email thresholds live in the email observer. Reorder logic lives in the reorder observer. Audit formatting lives in the audit observer. When a new requirement comes in -- say, pushing notifications to a mobile app -- you write one new class, register it in DI, and the existing code doesn't change at all.

Take this observer pattern real-world example in C#, adapt the IInventoryObserver interface to your domain, and start building your own observer pipeline. Whether you're tracking stock levels, monitoring sensor data, or broadcasting user activity, the pattern works the same way: define the contract, implement the subscribers, and let the subject do its job without knowing who's listening.

Observer Design Pattern in C#: Complete Guide with Examples

Master the observer design pattern in C# with practical code examples, event-driven architecture guidance, and real-world use cases.

When to Use Observer Pattern in C#: Decision Guide with Examples

Discover when to use observer pattern in C# with a practical decision guide, code examples, and real scenarios for event-driven architecture.

Prototype Pattern Real-World Example in C#: Complete Implementation

Prototype pattern real-world example in C#: complete implementation with game object system, code examples, and practical use case demonstration.

An error has occurred. This application may no longer respond until reloaded. Reload