SmartAlert
You will build a notification dispatching system from scratch, introducing one design pattern at a time. By the end you will have a single working application that demonstrates 6 GoF patterns working together naturally.
| # | Pattern | Category | Intent |
|---|---|---|---|
| 1 | Factory Method | Creational | Creates notifiers without hardcoding which class to instantiate |
| 2 | Adapter | Structural | Wraps an incompatible third-party SMS library behind your interface |
| 3 | Template Method | Behavioral | Defines the sending skeleton; subclasses fill in the steps |
| 4 | Decorator | Structural | Adds retry and logging behaviour on top of any notifier at runtime |
| 5 | Strategy | Behavioral | Swaps message formatting algorithms (plain text, HTML, JSON) |
| 6 | Observer | Behavioral | Lets components subscribe to alert events without tight coupling |
- .NET 8 SDK or later installed
- Visual Studio 2022 / VS Code / Rider
- You have watched the PluralSight C# Design Patterns course
- Download the starter solution and open it in your IDE: SmartAlert.zip โ
- Each step introduces one pattern, explains the intent, then tells you exactly what to write
- Every class in the starter solution is an empty shell โ you build everything from scratch
- Each step includes a ๐ Add to Program.cs block โ add those lines as you go, building it up step by step
- Don't skip ahead. Each step builds on the previous one
- Reflection notes save automatically and will be waiting for you on Friday
The App: SmartAlert
SmartAlert is a notification dispatching system. Given an alert โ a message with a severity level and a recipient โ the system routes it to the appropriate channel (Email, SMS, or Log), formats it, and sends it.
Here is what the final Program.cs will look like when
you're done. You'll build it one block at a time following each step:
var factory = new NotifierFactory(); // Step 1 var email = factory.CreateNotifier("email"); // Step 3 var sms = factory.CreateNotifier("sms"); // Step 3 var log = factory.CreateNotifier("log"); // Step 3 var decorated = new RetryDecorator(new LoggingDecorator(email), maxRetries: 3); // Step 4 var dispatcher = new AlertDispatcher(); // Step 6 dispatcher.Subscribe(decorated); dispatcher.Subscribe(sms); dispatcher.Subscribe(log); dispatcher.Dispatch(new Alert("Disk space low", Severity.Warning, "[email protected]")); Console.WriteLine("\nPress any key for next alert..."); Console.ReadKey(); dispatcher.Dispatch(new Alert("Service is down", Severity.Critical, "[email protected]")); Console.WriteLine("\nPress any key for next alert..."); Console.ReadKey(); dispatcher.Dispatch(new Alert("Backup completed", Severity.Info, "[email protected]")); Console.WriteLine("\nDone! All alerts dispatched."); Console.WriteLine("\nPress any key to exit..."); Console.ReadKey();
Project Structure
Every file marked build in Step N is an empty shell. Files marked provided are complete โ do not modify them.
SmartAlert/ Models/ Alert.cs // provided Severity.cs // provided Notifiers/ INotifier.cs // provided NotifierBase.cs // build in Step 3 EmailNotifier.cs // build in Step 3 SmsNotifier.cs // build in Step 3 LogNotifier.cs // build in Step 3 Adapters/ ThirdPartySmsService.cs // provided โ do not modify SmsAdapter.cs // build in Step 2 Factory/ NotifierFactory.cs // build in Step 1 Decorators/ NotifierDecorator.cs // build in Step 4 LoggingDecorator.cs // build in Step 4 RetryDecorator.cs // build in Step 4 Formatters/ IFormatter.cs // provided PlainTextFormatter.cs // build in Step 5 HtmlFormatter.cs // build in Step 5 JsonFormatter.cs // build in Step 5 Dispatcher/ AlertDispatcher.cs // build in Step 6 Program.cs // add lines as you complete each step
Intent
Context
If every caller writes new EmailNotifier() directly,
adding a new channel means hunting down every one of those spots. The
Factory Method centralises that decision โ callers ask for a notifier
by name, the factory decides which class to create.
What to Build
Factory/NotifierFactory.cs
public class NotifierFactory { public INotifier CreateNotifier(string channel) { return channel.ToLower() switch { "email" => new EmailNotifier(), "sms" => new SmsAdapter(new ThirdPartySmsService()), "log" => new LogNotifier(), _ => throw new ArgumentException($"Unknown channel: {channel}") }; } }
EmailNotifier, SmsAdapter, and
LogNotifier don't exist yet โ you will build them in
Steps 2 and 3. The factory will not compile until those steps are
complete. That's expected.
Add to Program.cs
var factory = new NotifierFactory();
INotifier class, like a
PushNotifier or a SlackNotifier?
Which files would you need to change, and which would you not? Does
that feel right?
Intent
Context
Your app expects every notifier to implement
INotifier.Send(Alert alert). But the SMS library you've
been given has a completely different interface that you cannot
modify:
public class ThirdPartySmsService { public void SendTextMessage(string phoneNumber, string messageBody) { ... } }
The Adapter wraps ThirdPartySmsService and translates the
INotifier.Send call into what the library expects.
What to Build
Adapters/SmsAdapter.cspublic class SmsAdapter : INotifier { private readonly ThirdPartySmsService _sms; public SmsAdapter(ThirdPartySmsService sms) { _sms = sms; } public void Send(Alert alert) { var phone = ResolvePhone(alert.Recipient); var message = $"[{alert.Severity}] {alert.Message}"; _sms.SendTextMessage(phone, message); } private string ResolvePhone(string recipient) => "+1-555-0100"; }
Program.cs uses
INotifier throughout. Does it know or care that SMS is
actually going through an adapter? Why is that a good thing?
Intent
Context
Every notifier follows the same sequence: validate โ format โ dispatch โ log. Without Template Method you'd copy that sequence into every notifier class and they'd inevitably drift apart. The base class defines the sequence once; subclasses only override what differs.
virtual FormatMessage with a simple hardcoded default
for now. You will replace it in Step 5 when Strategy is introduced.
Don't worry about it yet.
What to Build
Notifiers/NotifierBase.cs
public abstract class NotifierBase : INotifier { // Template Method โ the invariant skeleton public void Send(Alert alert) { Validate(alert); var message = FormatMessage(alert); Dispatch(message, alert.Recipient); LogOutcome(alert); } protected virtual void Validate(Alert alert) { if (string.IsNullOrWhiteSpace(alert.Recipient)) throw new ArgumentException("Recipient is required"); if (string.IsNullOrWhiteSpace(alert.Message)) throw new ArgumentException("Message is required"); } // Placeholder โ will be replaced by Strategy in Step 5 protected virtual string FormatMessage(Alert alert) => $"[{alert.Severity}] {alert.Message}"; protected abstract void Dispatch(string message, string recipient); protected virtual void LogOutcome(Alert alert) => Console.WriteLine($"[SENT] {GetType().Name} -> {alert.Recipient}"); }
Notifiers/EmailNotifier.cs
public class EmailNotifier : NotifierBase { protected override void Dispatch(string message, string recipient) => Console.WriteLine($"[EMAIL] To: {recipient}\n{message}"); }
Notifiers/SmsNotifier.cs
public class SmsNotifier : NotifierBase { protected override void Dispatch(string message, string recipient) => Console.WriteLine($"[SMS] To: {recipient} | {message}"); }
Notifiers/LogNotifier.cs
public class LogNotifier : NotifierBase { protected override void Dispatch(string message, string recipient) => Console.WriteLine($"[LOG] {DateTime.UtcNow:u} | {message}"); }
Add to Program.cs
var email = factory.CreateNotifier("email"); var sms = factory.CreateNotifier("sms"); var log = factory.CreateNotifier("log"); // Quick check โ should print: EmailNotifier, SmsAdapter, LogNotifier Console.WriteLine(email.GetType().Name); Console.WriteLine(sms.GetType().Name); Console.WriteLine(log.GetType().Name);
Validate and LogOutcome steps are
virtual, not abstract. What is the
difference between the two here? When would you override
Validate in a subclass?
Intent
Context
You need logging and retry on top of any notifier. Think of it like
ASP.NET middleware โ each layer wraps the next, does something before
or after, then passes control along. Because each Decorator also
implements INotifier, they can be stacked freely and the
caller never knows the difference.
What to Build
Decorators/NotifierDecorator.cs
public abstract class NotifierDecorator : INotifier { protected readonly INotifier _inner; protected NotifierDecorator(INotifier inner) { _inner = inner; } public virtual void Send(Alert alert) => _inner.Send(alert); }
Decorators/LoggingDecorator.cs
public class LoggingDecorator : NotifierDecorator { public LoggingDecorator(INotifier inner) : base(inner) { } public override void Send(Alert alert) { Console.WriteLine($"[LOG] Sending via {_inner.GetType().Name}: {alert.Message}"); base.Send(alert); Console.WriteLine($"[LOG] Send complete."); } }
Decorators/RetryDecorator.cs
public class RetryDecorator : NotifierDecorator { private readonly int _maxRetries; public RetryDecorator(INotifier inner, int maxRetries = 3) : base(inner) => _maxRetries = maxRetries; public override void Send(Alert alert) { for (int attempt = 1; attempt <= _maxRetries; attempt++) { try { base.Send(alert); return; } catch (Exception ex) when (attempt < _maxRetries) { Console.WriteLine($"[RETRY] Attempt {attempt} failed: {ex.Message}. Retrying..."); } } } }
Add to Program.cs
var decorated = new RetryDecorator(new LoggingDecorator(email), maxRetries: 3); // Run a quick test to see the decorator chain in action decorated.Send(new Alert("Test alert", Severity.Info, "[email protected]"));
[LOG] Sending via EmailNotifier: Test alert [EMAIL] To: [email protected] [Info] Test alert [SENT] EmailNotifier -> [email protected] [LOG] Send complete.
Now try flipping the order โ put LoggingDecorator outside
and RetryDecorator inside. Does the output change?
RetryDecorator and
LoggingDecorator implement INotifier. What
would happen if you wrapped a decorator inside another decorator?
Does the type system prevent you โ or allow it?
Intent
Context
Different consumers need messages formatted differently โ plain text for the ops terminal, HTML for a dashboard, JSON for a message bus. The formatting algorithm should be swappable without touching the notifiers themselves.
What to Build
Formatters/PlainTextFormatter.cs
public class PlainTextFormatter : IFormatter { public string Format(Alert alert) => $"[{alert.Severity.ToString().ToUpper()}] {alert.Message} -> {alert.Recipient}"; }
Formatters/HtmlFormatter.cs
public class HtmlFormatter : IFormatter { public string Format(Alert alert) => $"<div class='alert {alert.Severity.ToString().ToLower()}'>" + $"<strong>{alert.Severity}</strong>: {alert.Message}</div>"; }
Formatters/JsonFormatter.cs
public class JsonFormatter : IFormatter { public string Format(Alert alert) => JsonSerializer.Serialize(new { severity = alert.Severity.ToString(), message = alert.Message, recipient = alert.Recipient }); }
Now go back to NotifierBase and wire in the Strategy. Add
the formatter field and constructor, then replace the hardcoded
FormatMessage:
Notifiers/NotifierBase.cs
protected readonly IFormatter _formatter; protected NotifierBase(IFormatter formatter) { _formatter = formatter; } // Replace the hardcoded version from Step 3 with this: protected virtual string FormatMessage(Alert alert) => _formatter.Format(alert);
Adding a constructor to NotifierBase means all three
subclasses need a matching constructor. Update each one:
EmailNotifier, SmsNotifier,
LogNotifier
// EmailNotifier public EmailNotifier(IFormatter formatter) : base(formatter) { } // SmsNotifier public SmsNotifier(IFormatter formatter) : base(formatter) { } // LogNotifier public LogNotifier(IFormatter formatter) : base(formatter) { }
Finally, update NotifierFactory to pass a formatter when
creating each notifier:
Factory/NotifierFactory.cs
"email" => new EmailNotifier(new PlainTextFormatter()), "sms" => new SmsAdapter(new ThirdPartySmsService()), "log" => new LogNotifier(new PlainTextFormatter()),
EmailNotifier or LogNotifier,
make sure you added the formatter constructor to each subclass and
updated the factory.
Intent
Context
Right now, the caller manually decides which notifiers to call. With Observer, components subscribe once โ when an alert fires, the dispatcher notifies all of them automatically without knowing anything specific about them.
What to Build
Dispatcher/AlertDispatcher.cs
public class AlertDispatcher { private readonly List<INotifier> _subscribers = new(); public void Subscribe(INotifier notifier) => _subscribers.Add(notifier); public void Unsubscribe(INotifier notifier) => _subscribers.Remove(notifier); public void Dispatch(Alert alert) { Console.WriteLine($"\n=== [{alert.Severity.ToString().ToUpper()}] {alert.Message} ==="); foreach (var subscriber in _subscribers) subscriber.Send(alert); } }
Complete Program.cs
var dispatcher = new AlertDispatcher(); dispatcher.Subscribe(decorated); dispatcher.Subscribe(sms); dispatcher.Subscribe(log); dispatcher.Dispatch(new Alert("Disk space low", Severity.Warning, "[email protected]")); Console.WriteLine("\nPress any key for next alert..."); Console.ReadKey(); dispatcher.Dispatch(new Alert("Service is down", Severity.Critical, "[email protected]")); Console.WriteLine("\nPress any key for next alert..."); Console.ReadKey(); dispatcher.Dispatch(new Alert("Backup completed", Severity.Info, "[email protected]")); Console.WriteLine("\nDone! All alerts dispatched."); Console.WriteLine("\nPress any key to exit..."); Console.ReadKey();
event /
EventHandler) is actually an implementation of the
Observer pattern. Can you spot the similarities between what you
built in AlertDispatcher and how you've used events in
C# before?
Wrap-Up
You've built a complete application where all 6 patterns earn their place. Your reflection notes are saved below โ they'll be here when Friday comes.