← Open in the full interactive course (progress tracking, search & more)

Interfaces give you pluggable parts. Events give you a way for one part to speak without knowing who's listening. Put them together and you get a real notification system.

Somewhere in almost every real application, something happens that other parts of the system need to know about — an order ships, a payment fails, a password gets reset — and someone needs to be told: an email, a text message, a push notification, maybe all three. The naive way to build this is to hardcode a call to "send an email" directly inside whatever code processes the order. The problem is obvious the moment product asks for SMS too: now you're editing the order-processing code every time the notification requirements change, and the two concerns — "an order shipped" and "how we tell someone about it" — are welded together.

This project builds a small, pluggable Notification Service that keeps those concerns apart. You'll define a common interface for anything that can deliver a message, write a few interchangeable implementations behind it (composition, from Part I), and connect the whole thing to the rest of an application using a genuine C# event — so that "an order shipped" and "notify someone about it" never need to know about each other directly.

Project Brief

Build a notification system with three interchangeable delivery channels — Email, SMS, and Push — that other parts of an application can trigger without knowing which channels actually exist.

Requirements

Designing the System

Three separate ideas, each doing one job, composed together:

Interface — the contract

Composition — the dispatcher

Event — the trigger

Delegate — the formatting

HOW A SHIPPED ORDER BECOMES THREE NOTIFICATIONS
1. ORDER SERVICE SHIPS AN ORDER
2. THE EVENT FIRES
3. THE SUBSCRIBED HANDLER RUNS
4. THE DISPATCHER FANS OUT

Building It Step by Step

Step 1 — The channel interface and three implementations

public interface INotificationChannel
{
    string ChannelName { get; }
    Task SendAsync(string recipient, string message, CancellationToken ct = default);
}

public class EmailChannel(ILogger<EmailChannel> logger) : INotificationChannel
{
    public string ChannelName => "Email";

    public Task SendAsync(string recipient, string message, CancellationToken ct = default)
    {
        logger.LogInformation("Emailing {Recipient}: {Message}", recipient, message);
        return Task.CompletedTask; // a real implementation would call an email provider here
    }
}

public class SmsChannel(ILogger<SmsChannel> logger) : INotificationChannel
{
    public string ChannelName => "SMS";

    public Task SendAsync(string recipient, string message, CancellationToken ct = default)
    {
        logger.LogInformation("Texting {Recipient}: {Message}", recipient, message);
        return Task.CompletedTask;
    }
}

public class PushChannel(ILogger<PushChannel> logger) : INotificationChannel
{
    public string ChannelName => "Push";

    public Task SendAsync(string recipient, string message, CancellationToken ct = default)
    {
        logger.LogInformation("Push-notifying {Recipient}: {Message}", recipient, message);
        return Task.CompletedTask;
    }
}

Each channel is async-shaped (returning Task) even though this project's stand-ins don't actually await anything — that's deliberate, because a real email or SMS provider call would genuinely be an awaited network operation, and designing the interface that way from the start means swapping in real providers later requires no signature changes.

Step 2 — The dispatcher, composed from channels

public class NotificationDispatcher(
    IEnumerable<INotificationChannel> channels,
    ILogger<NotificationDispatcher> logger)
{
    public async Task NotifyAsync(string recipient, string message, CancellationToken ct = default)
    {
        var tasks = channels.Select(async channel =>
        {
            try
            {
                await channel.SendAsync(recipient, message, ct);
            }
            catch (Exception ex)
            {
                // One channel failing should never take down the others
                logger.LogError(ex, "{Channel} failed to deliver to {Recipient}", channel.ChannelName, recipient);
            }
        });

        await Task.WhenAll(tasks);
    }
}

The dispatcher holds an IEnumerable<INotificationChannel> — it has no idea whether that's one channel or five, and no idea whether any of them are EmailChannel, SmsChannel, or something written next year. That's composition doing exactly the job Part I described: behavior assembled from independent, swappable parts rather than baked into a rigid hierarchy. Task.WhenAll, from the async programming part, sends through every channel concurrently rather than one at a time — and the try/catch inside the lambda means one channel throwing never stops the others mid-flight, satisfying the brief's isolation requirement directly.

Step 3 — Registering channels with DI

services.AddSingleton<INotificationChannel, EmailChannel>();
services.AddSingleton<INotificationChannel, SmsChannel>();
services.AddSingleton<INotificationChannel, PushChannel>();
services.AddSingleton<NotificationDispatcher>();

Registering the same interface three times, with three different implementations, is exactly what makes IEnumerable<INotificationChannel> resolve to all three at once when it's injected into NotificationDispatcher — the DI container from Part V collects every registration against a given service type into the enumerable automatically. Adding a fourth channel later — SlackChannel, say — means writing the class, adding one more AddSingleton line, and nothing else changes.

Step 4 — OrderService, with no idea notifications exist

public record Order(int Id, string CustomerContact, decimal Total);

public class OrderShippedEventArgs(Order order) : EventArgs
{
    public Order Order { get; } = order;
}

public class OrderService
{
    public event EventHandler<OrderShippedEventArgs>? OrderShipped;

    public void ShipOrder(Order order)
    {
        // ... real shipping logic would go here ...
        Console.WriteLine($"Order {order.Id} has shipped.");

        OrderShipped?.Invoke(this, new OrderShippedEventArgs(order));
    }
}

This is the standard .NET event pattern from the delegates and events part of this tier: a class deriving from EventArgs carries the data about what happened, the event itself is declared with the event keyword (so outside code can only +=/-=, never call it directly or overwrite every subscriber with =), and the ?.Invoke(...) null-conditional call means firing the event is completely safe even when nobody has subscribed yet. Crucially: OrderService imports nothing about notifications, dispatchers, or channels. It just announces "this happened" and moves on.

Step 5 — Wiring the event to the dispatcher, with pluggable formatting

public class OrderShippedNotifier
{
    private readonly NotificationDispatcher _dispatcher;
    private readonly Func<Order, string> _formatMessage;

    public OrderShippedNotifier(NotificationDispatcher dispatcher, Func<Order, string>? formatMessage = null)
    {
        _dispatcher = dispatcher;
        _formatMessage = formatMessage ?? (order => $"Your order #{order.Id} (${order.Total}) has shipped!");
    }

    public void Subscribe(OrderService orderService) =>
        orderService.OrderShipped += HandleOrderShipped;

    public void Unsubscribe(OrderService orderService) =>
        orderService.OrderShipped -= HandleOrderShipped;

    private async void HandleOrderShipped(object? sender, OrderShippedEventArgs e)
    {
        string message = _formatMessage(e.Order);
        await _dispatcher.NotifyAsync(e.Order.CustomerContact, message);
    }
}

Two ideas from the delegates part converge here. First, Func<Order, string> is the pluggable-formatting requirement made literal — the default wording lives inline as a fallback, but any caller can pass a completely different formatting function (a different language, a promotional message, a terser SMS-friendly version) without touching OrderShippedNotifier's code at all. Second, notice HandleOrderShipped is async void, not async Task — that's the one legitimate place async void belongs, covered back in the async programming part: an event handler's signature is fixed by the delegate type it's assigned to (EventHandler<T> returns void), so there's no Task to return even if you wanted one.

Step 6 — Putting it together

var dispatcher = new NotificationDispatcher(
    [new EmailChannel(emailLogger), new SmsChannel(smsLogger), new PushChannel(pushLogger)],
    dispatcherLogger);

var notifier = new OrderShippedNotifier(dispatcher);
var orderService = new OrderService();

notifier.Subscribe(orderService);

orderService.ShipOrder(new Order(1001, "customer@example.com", 49.99m));
// → logs a delivery attempt through Email, SMS, and Push — concurrently, independently

Trace the dependency direction carefully: OrderShippedNotifier knows about both OrderService and NotificationDispatcher — it's the glue. But OrderService knows about neither the notifier nor the dispatcher, and the dispatcher knows nothing about orders at all, only about INotificationChannel. That's what makes this design genuinely decoupled: you could delete the entire notification system and OrderService would compile and run exactly as before, just with nobody listening.

Complete Solution

// ── Channels ──
public interface INotificationChannel
{
    string ChannelName { get; }
    Task SendAsync(string recipient, string message, CancellationToken ct = default);
}

public class EmailChannel(ILogger<EmailChannel> logger) : INotificationChannel
{
    public string ChannelName => "Email";
    public Task SendAsync(string recipient, string message, CancellationToken ct = default)
    {
        logger.LogInformation("Emailing {Recipient}: {Message}", recipient, message);
        return Task.CompletedTask;
    }
}

public class SmsChannel(ILogger<SmsChannel> logger) : INotificationChannel
{
    public string ChannelName => "SMS";
    public Task SendAsync(string recipient, string message, CancellationToken ct = default)
    {
        logger.LogInformation("Texting {Recipient}: {Message}", recipient, message);
        return Task.CompletedTask;
    }
}

public class PushChannel(ILogger<PushChannel> logger) : INotificationChannel
{
    public string ChannelName => "Push";
    public Task SendAsync(string recipient, string message, CancellationToken ct = default)
    {
        logger.LogInformation("Push-notifying {Recipient}: {Message}", recipient, message);
        return Task.CompletedTask;
    }
}

// ── Dispatcher (composition) ──
public class NotificationDispatcher(
    IEnumerable<INotificationChannel> channels,
    ILogger<NotificationDispatcher> logger)
{
    public async Task NotifyAsync(string recipient, string message, CancellationToken ct = default)
    {
        var tasks = channels.Select(async channel =>
        {
            try { await channel.SendAsync(recipient, message, ct); }
            catch (Exception ex)
            {
                logger.LogError(ex, "{Channel} failed to deliver to {Recipient}", channel.ChannelName, recipient);
            }
        });
        await Task.WhenAll(tasks);
    }
}

// ── Order domain — knows nothing about notifications ──
public record Order(int Id, string CustomerContact, decimal Total);

public class OrderShippedEventArgs(Order order) : EventArgs
{
    public Order Order { get; } = order;
}

public class OrderService
{
    public event EventHandler<OrderShippedEventArgs>? OrderShipped;

    public void ShipOrder(Order order)
    {
        Console.WriteLine($"Order {order.Id} has shipped.");
        OrderShipped?.Invoke(this, new OrderShippedEventArgs(order));
    }
}

// ── The glue: subscribes to the event, drives the dispatcher ──
public class OrderShippedNotifier
{
    private readonly NotificationDispatcher _dispatcher;
    private readonly Func<Order, string> _formatMessage;

    public OrderShippedNotifier(NotificationDispatcher dispatcher, Func<Order, string>? formatMessage = null)
    {
        _dispatcher = dispatcher;
        _formatMessage = formatMessage ?? (order => $"Your order #{order.Id} (${order.Total}) has shipped!");
    }

    public void Subscribe(OrderService orderService) => orderService.OrderShipped += HandleOrderShipped;
    public void Unsubscribe(OrderService orderService) => orderService.OrderShipped -= HandleOrderShipped;

    private async void HandleOrderShipped(object? sender, OrderShippedEventArgs e)
    {
        string message = _formatMessage(e.Order);
        await _dispatcher.NotifyAsync(e.Order.CustomerContact, message);
    }
}

// ── Usage ──
using var loggerFactory = LoggerFactory.Create(b => b.AddConsole());

var dispatcher = new NotificationDispatcher(
    [
        new EmailChannel(loggerFactory.CreateLogger<EmailChannel>()),
        new SmsChannel(loggerFactory.CreateLogger<SmsChannel>()),
        new PushChannel(loggerFactory.CreateLogger<PushChannel>())
    ],
    loggerFactory.CreateLogger<NotificationDispatcher>());

var notifier = new OrderShippedNotifier(dispatcher);
var orderService = new OrderService();
notifier.Subscribe(orderService);

orderService.ShipOrder(new Order(1001, "customer@example.com", 49.99m));

Run it and you'll see the "Order shipped" line print first, synchronously, followed by three log lines — one per channel — as the notifications fire off concurrently in the background.

Try It Yourself — Extension Challenges

Challenge 1 — Add a Slack channelEasy

Add a fourth SlackChannel implementing INotificationChannel, and register it — without changing NotificationDispatcher at all.

Hint

If you needed to touch NotificationDispatcher's code to add this, something's coupled that shouldn't be — the whole point of coding against INotificationChannel is that the dispatcher never needs to change when a new channel appears.

Challenge 2 — A second event: OrderCancelledEasy

Add a second event to OrderService, OrderCancelled, and a second notifier that formats a different message for it.

Hint

Reuse the same OrderShippedEventArgs shape (or a small sibling record) and the same EventHandler<T> pattern. Notice how naturally a second, independent notifier can subscribe to a second, independent event — that's the point of an event-based design over a single hardcoded call.

Challenge 3 — Per-customer channel preferencesMedium

Let a customer opt out of specific channels (say, no SMS), so the dispatcher only sends through the channels they've allowed.

Hint

Add an overload of NotifyAsync that accepts a Func<INotificationChannel, bool> predicate (or a simple IReadOnlySet<string> of allowed channel names), and filter channels with LINQ's Where before the Select/WhenAll — a second delegate parameter, just like the message formatter.

Challenge 4 — Retry a failed channel onceMedium

If a channel throws, retry it exactly once (with a short delay) before logging the failure and moving on.

Hint

Wrap the existing try/catch inside a small loop that runs at most twice: attempt, catch, await Task.Delay(...), attempt again, and only log an error if the second attempt also fails. This is a simplified version of the retry-with-backoff pattern from the async and resilience material.

Challenge 5 — Weak event subscriptionHard

Explain (in a comment, or out loud) why forgetting to call Unsubscribe on a long-lived OrderService could leak memory if many short-lived OrderShippedNotifier instances were created and discarded over an app's lifetime — then fix it by having OrderShippedNotifier implement IDisposable.

Hint

A subscribed event handler is a reference from the publisher (OrderService) to the subscriber (OrderShippedNotifier). If OrderService outlives the notifier but the subscription is never removed, the garbage collector can't reclaim the notifier — it's still reachable through the event's invocation list, even though nothing else references it. Implement Dispose() to call Unsubscribe, and always pair a long-lived publisher's += with a corresponding -= when the subscriber's job is done.

Key Takeaway

You've composed interfaces, events, and delegates into a real, decoupled system — the same shape used by countless production notification and messaging systems. Next: doing real work in the background, off the request thread.


dotnetmadeeasy.com — Learn C# and .NET, the right way.