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.
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.
OrderService that raises a genuine C# event when an order ships — it should have zero knowledge that a notification system exists at all.OrderService.Three separate ideas, each doing one job, composed together:
INotificationChannel defines what any channel must be able to doNotificationDispatcher has a list of channelsOrderService raises OrderShippedFunc<Order, string> decides the message wordingOnOrderShipped(order) internally — no idea who, if anyone, is listeningOrderShipped?.Invoke(this, new OrderShippedEventArgs(order))+= receives the event and calls the dispatcherINotificationChannel, sending the formatted message through eachpublic 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.
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.
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.
OrderService, with no idea notifications existpublic 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.
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.
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.
// ── 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.
Challenge 1 — Add a Slack channelEasy
Add a fourth SlackChannel implementing INotificationChannel, and register it — without changing NotificationDispatcher at all.
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.
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.
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.
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.
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.
OrderService) announce that something happened without ever referencing whoever is listening — the decoupling is structural, not just a naming convention.Func<T, TResult> delegate parameter is a clean way to make one piece of behavior (message formatting) swappable without touching the surrounding code.IEnumerable<T> is what makes "add a channel, register it, done" actually true in practice.+= with a corresponding -=.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.