CEGsoft ยท C# Design Patterns

SmartAlert

A Hands-On Design Patterns Tutorial

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
Prerequisites
  • .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 โ†—
How to use this tutorial
  • 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:

Program.cs โ€” final result
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.

Folder layout
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

Define an interface for creating an object, but let subclasses decide which class to instantiate. Factory Method lets a class defer instantiation to subclasses.

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

Open: Factory/NotifierFactory.cs
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

๐Ÿ“„ Add these lines to Program.cs
var factory = new NotifierFactory();
๐Ÿ’ญReflectionStep 1
What would happen if you needed to add a new 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

Convert the interface of a class into another interface that clients expect. Adapter lets classes work together that could not otherwise because of incompatible interfaces.

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:

ThirdPartySmsService.cs โ€” provided, do not 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

Open: Adapters/SmsAdapter.cs
Adapters/SmsAdapter.cs
public 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";
}
๐Ÿ’ญReflectionStep 2
The calling code in 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

Define the skeleton of an algorithm in the superclass but let subclasses override specific steps of the algorithm without changing its structure.

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.

๐Ÿ“ŒNote on FormatMessage: the base class has a 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

1 of 4 โ€” Notifiers/NotifierBase.cs
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}");
}
2 of 4 โ€” Notifiers/EmailNotifier.cs
Notifiers/EmailNotifier.cs
public class EmailNotifier : NotifierBase
{
    protected override void Dispatch(string message, string recipient)
        => Console.WriteLine($"[EMAIL] To: {recipient}\n{message}");
}
3 of 4 โ€” Notifiers/SmsNotifier.cs
Notifiers/SmsNotifier.cs
public class SmsNotifier : NotifierBase
{
    protected override void Dispatch(string message, string recipient)
        => Console.WriteLine($"[SMS] To: {recipient} | {message}");
}
4 of 4 โ€” Notifiers/LogNotifier.cs
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

๐Ÿ“„ Add these lines 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);
๐Ÿ’ญReflectionStep 3
The 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

Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.

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

1 of 3 โ€” Decorators/NotifierDecorator.cs
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);
}
2 of 3 โ€” Decorators/LoggingDecorator.cs
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.");
    }
}
3 of 3 โ€” Decorators/RetryDecorator.cs
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

๐Ÿ“„ Add these lines 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]"));
โœ“ Expected output
[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?

๐Ÿ’ญReflectionStep 4
Both 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

Define a family of algorithms, put each of them into a separate class, and make their objects interchangeable.

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

1 of 6 โ€” Formatters/PlainTextFormatter.cs
Formatters/PlainTextFormatter.cs
public class PlainTextFormatter : IFormatter
{
    public string Format(Alert alert)
        => $"[{alert.Severity.ToString().ToUpper()}] {alert.Message} -> {alert.Recipient}";
}
2 of 6 โ€” Formatters/HtmlFormatter.cs
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>";
}
3 of 6 โ€” Formatters/JsonFormatter.cs
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:

4 of 6 โ€” Update Notifiers/NotifierBase.cs
NotifierBase.cs โ€” add these, replace FormatMessage
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:

5 of 6 โ€” Update EmailNotifier, SmsNotifier, LogNotifier
Add to each notifier subclass
// 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:

6 of 6 โ€” Update Factory/NotifierFactory.cs
NotifierFactory.cs โ€” pass formatter to each notifier
"email" => new EmailNotifier(new PlainTextFormatter()),
"sms"   => new SmsAdapter(new ThirdPartySmsService()),
"log"   => new LogNotifier(new PlainTextFormatter()),
โœ…Build check: the project should compile cleanly after this step. If you see errors about missing constructor arguments on EmailNotifier or LogNotifier, make sure you added the formatter constructor to each subclass and updated the factory.
๐Ÿ’ญReflectionStep 5
Strategy and Template Method both deal with varying behaviour. In this app, Template Method controls the sending sequence, while Strategy controls message formatting. Could you have used Template Method for formatting instead? What would the trade-off be?

Intent

Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.

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

Open: Dispatcher/AlertDispatcher.cs
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

๐Ÿ“„ Add the final block to 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();
๐Ÿ’ญReflectionStep 6
The .NET event system (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.

๐Ÿ“‹ My Notes

Your answers to the reflection questions โ€” for reference during Friday's discussion

Questions to bring on Friday

1
Which pattern felt the most natural to apply? Which felt forced?
2
Where in your current CEGsoft work (Practice, ExpertTax) could you imagine applying each of these patterns?
See you Friday ๐Ÿ‘‹Bring your questions and your code.