Skip to main content
ANVISoftware Solutions
Lesson 12 of 19Advanced18 min

Observer Pattern

By the end of this lesson

Notify interested parties without coupling them to the source.

Observer means the thing that changed announces what happened, and whoever cares reacts. The announcement does not name its audience, so a new reaction is a new class rather than an edit to the code that raised it.

The pain it answers is a method that keeps growing. Approving an invoice started as a status change. Then it emailed the customer. Then it updated a finance dashboard, wrote an audit row, and nudged a reporting table. Five reasons to change in one method, and the fifth was added by someone who had to understand the other four first.

A fact, a contract for reacting, and a publisher
C#
// The fact, in the past tense, carrying what a reactor needs
public sealed record InvoiceApproved(
    int InvoiceId,
    string Number,
    decimal Total,
    DateTime ApprovedUtc);

public interface IHandle<in TNotification>
{
    Task HandleAsync(TNotification notification, CancellationToken token);
}

internal sealed class EmailCustomerOnApproval(ICustomerNotifier notifier)
    : IHandle<InvoiceApproved>
{
    public Task HandleAsync(InvoiceApproved approved, CancellationToken token) =>
        notifier.InvoiceApprovedAsync(approved.Number, approved.Total, token);
}

public sealed class InProcessPublisher(
    IServiceProvider services,
    ILogger<InProcessPublisher> log) : IPublisher
{
    public async Task PublishAsync<TNotification>(
        TNotification notification, CancellationToken token)
    {
        foreach (IHandle<TNotification> handler in services.GetServices<IHandle<TNotification>>())
        {
            log.LogInformation("Handling {Notification} with {Handler}.",
                typeof(TNotification).Name, handler.GetType().Name);

            await handler.HandleAsync(notification, token);
        }
    }
}
  • The notification is a record in the past tense. That naming is a design constraint, not a style rule: a fact cannot be refused, so a handler that fails does not undo it. Name it SendApprovalEmail and you have written an instruction, which belongs in a direct call.
  • The in on the type parameter makes the interface contravariant, so a handler written against a base notification type can receive a derived one. Useful for a single audit handler covering a family of events.
  • The publisher resolves handlers from the container and knows none of them by name. Adding a finance dashboard update is one class and one registration, with no change to the approval code.
  • The log line inside the loop is the most important line in this example. Without it, nobody reading production logs can tell which reactions ran, and that is the cost this pattern imposes.
  • Two behaviours here need a deliberate decision. The loop is sequential in registration order, so a slow handler delays the response and an accidental dependency on ordering will appear to work. And an exception in the second handler abandons the third, leaving the work half done with the fact already true.
The leak: a subscription that is added and never removed
C#
public sealed class InvoiceFeed
{
    // Long-lived: registered as a singleton.
    public event EventHandler<InvoiceApprovedArgs>? Approved;

    public void Announce(InvoiceApprovedArgs args) => Approved?.Invoke(this, args);
}

public sealed class ApprovalAuditWatcher : IDisposable
{
    private readonly InvoiceFeed _feed;

    public ApprovalAuditWatcher(InvoiceFeed feed)
    {
        _feed = feed;
        _feed.Approved += OnApproved;   // subscribe
    }

    private void OnApproved(object? sender, InvoiceApprovedArgs args)
    {
        // write an audit row
    }

    // Remove this method, or fail to dispose the watcher, and the leak is live.
    public void Dispose() => _feed.Approved -= OnApproved;
}
  • The += stores a reference to OnApproved, which holds a reference to the watcher instance. The feed is a singleton, so that reference outlives the request the watcher was created for.
  • Nothing is ever collected. A watcher created per request means the feed's invocation list grows for the lifetime of the process, and each dead watcher still runs when the event fires.
  • The symptoms arrive in this order: memory climbs slowly, then the same audit row appears several times, then dozens of times. By the time anyone notices the duplicates, the memory graph has been odd for a fortnight.
  • Two fixes. Match every += with a -= and make sure the subscriber is disposed — which means something has to own its lifetime. Or avoid instance subscriptions entirely and resolve handlers per notification, as the previous example does, so there is no list to grow.
  • This catches nearly everyone once. It is the specific reason many teams prefer resolved handlers over .NET events for anything longer-lived than a single object graph.

What keeps an event-driven flow debuggable, given that the call site tells you nothing:

  • Log at publish, naming the notification and every handler that ran, with a correlation identifier that follows the request.
  • Name handlers after what they do — EmailCustomerOnApproval, not InvoiceApprovedHandler2 — so a log line is self-explanatory.
  • Keep handlers for one notification together in one folder, so the set is discoverable by looking rather than by searching.
  • Cap the depth. A handler that publishes another fact, whose handler publishes a third, is a control flow nobody can hold in their head. One level is manageable; three is a maze.
  • Test that the fact was published, separately from testing each handler. That keeps the publisher's test stable when a fourth reaction appears.
  • Decide the failure policy explicitly: does one failing handler abort the rest, or are they independent? Write it down, because the default is whatever your loop happens to do.

Everything above happens in one process, inside one transaction boundary if you choose. That keeps two properties you should notice while you have them: a handler either ran or threw, and nothing was delivered twice.

The next module takes the same idea across process boundaries, where neither property holds. Once a fact travels over a network, it can arrive twice, arrive late, or arrive out of order, and every handler has to be written for that. The pattern looks the same; the guarantees do not.

Summary

  • Publishing a fact lets reactions be added without editing the code that raised it
  • Name notifications in the past tense; an instruction with one correct handler should be a direct call
  • The cost is invisible control flow, so naming, grouping and logging at publish are part of the pattern
  • Match every event subscription with an unsubscribe, or resolve handlers per notification instead
  • Publish after the transaction commits, and decide explicitly what a failing handler does to the others

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Two emails

A bug report says that approving an invoice sometimes sends the customer two emails. The approval code publishes one InvoiceApproved fact, and there is one email handler.

List the explanations worth checking, in the order you would check them, and say what the difficulty of this investigation tells you about the pattern.

Show solution

Start with registration. The same handler registered twice — often because two module registration methods both add it — makes the publisher call it twice per fact. Cheap to check and a common cause.

Then look for a second handler that also emails. Two handlers with different names can both end up notifying the customer, and neither author knew about the other.

Then look for leaked subscriptions if any part of this uses += on a long-lived object. Intermittent duplication that worsens over the day points here.

Then check whether the fact is published more than once: a retry at the HTTP layer, or a publish inside a loop that runs twice for an invoice with two lines.

If the fact crosses a message broker, add redelivery to the list. At-least-once delivery means the handler must be idempotent, which is the next module's subject.

What the exercise shows is the real cost of the pattern. "One publish, one handler" was not enough information to reason about, because the set of reactions is assembled at run time. That is why the discoverability habits in this lesson — naming, logging, grouping — are part of using it rather than polish on top.

Try it yourself

Assert the fact, not the reactions

Take a use case that currently performs three side effects inline. Move them behind a published fact, then write two kinds of test: one that the use case publishes the fact with the right values, and one per handler that it does its job when given the fact.

Then add a fourth reaction and note which tests you had to touch.

Show solution

You should have touched none of the existing tests. That is the property worth having: the use case's test asserts what it decided, not what the system happened to do afterwards, so it stays stable while the reactions grow.

It also fixes a common test smell. A use-case test that verifies an email was sent, a dashboard updated and an audit row written has four reasons to fail, and three of them tell you nothing about the use case.

The cost shows up immediately in the same exercise: nothing now fails if the fourth handler is never registered. Add a test that asserts the expected handlers are resolvable for the notification, or accept that a missing registration is a run-time discovery.

Keep the fact's payload to what handlers need. Passing the whole entity is convenient and couples every handler to the entity's shape, so a field rename becomes four edits.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A class created once per request subscribes to a singleton's event with += and is never disposed. What happens?
An approval method publishes InvoiceApproved and then calls SaveChangesAsync. What is the risk?

Saved in this browser only.