The previous lesson polished BiblioTech's code style: names, comments, documentation, nullability. This lesson takes a step beyond style, toward design: how to organize the relationships between classes to solve problems that keep coming up, again and again, in any object-oriented project. A design pattern is precisely that — a proven solution to one of those recurring problems, not code you copy literally, but a way of thinking about the structure of the classes involved. You'll see three classic families of patterns and apply two of them directly to BiblioTech's domain: Factory Method to create library items, and Strategy to calculate late-return penalties. You'll also discover that one of the best-known behavioral patterns — Observer — has already been at work in BiblioTech for several modules, without anyone having named it until now.

Contents

  1. What a design pattern is and why it matters
  2. Creational patterns: Singleton and Factory Method
  3. Structural patterns: Adapter and Decorator
  4. Behavioral patterns: Strategy and Observer
  5. Summary table: when to use each pattern
  6. Example: Factory Method to create LibraryItems
  7. Example: Strategy for penalty calculation policies

  1. What a design pattern is and why it matters

A design pattern isn't a library you install, nor new C# syntax: it's a conceptual template, documented and named, for solving a design problem that recurs across otherwise very different projects. The idea was popularized by the book Design Patterns (1994, the so-called "Gang of Four"), which cataloged twenty-three patterns grouped into three families:

Family What it solves Examples in this lesson
Creational How objects get created, hiding or making the construction process more flexible Singleton, Factory Method
Structural How classes and objects combine to form larger structures Adapter, Decorator
Behavioral How objects communicate and share responsibilities with each other Strategy, Observer

Knowing these patterns matters for two practical reasons: first, they give you shared vocabulary — saying "this is an Observer" instantly communicates a whole structure to another developer, without having to explain it from scratch; second, they help you avoid reinventing, worse, a solution that's already proven and has well-known trade-offs. This isn't about forcing a pattern into every class you write — a pattern misapplied where it isn't needed adds unnecessary complexity — but about recognizing when the problem in front of you already has a named design solution.

  1. Creational patterns: Singleton and Factory Method

Singleton

Singleton guarantees that a class has a single instance throughout the entire application, and offers a global access point to it:

class BiblioTechConfiguration
{
    private static BiblioTechConfiguration? _instance;

    public string ConnectionString { get; }

    private BiblioTechConfiguration()
    {
        ConnectionString = "Data Source=bibliotech.db";
    }

    public static BiblioTechConfiguration Instance
    {
        get
        {
            _instance ??= new BiblioTechConfiguration(); // creates the instance only the first time
            return _instance;
        }
    }
}
Console.WriteLine(BiblioTechConfiguration.Instance.ConnectionString);
// Any part of the program that accesses "Instance" always gets the same object

The private constructor prevents creating instances with new from outside the class; the only access route is the static Instance property, which creates the object the first time it's requested (??=, the null-coalescing assignment operator) and always returns that same reference afterward. Singleton is useful for shared global configuration, but it's worth using sparingly: overusing it makes unit testing harder (Lesson 4 of this module), because it introduces hidden global state that's difficult to replace with a test version.

Factory Method

Factory Method encapsulates the logic of which concrete class to create inside a dedicated method, instead of scattering calls to new ConcreteType(...) throughout the program. Whoever requests an object doesn't need to know the exact class it will receive, only the base type or common interface:

static class ItemFactory
{
    public static LibraryItem Create(string type, string title, string author)
    {
        return type switch
        {
            "book" => new Book(title, author, isbn: "NO-ISBN"),
            "magazine" => new Magazine(title, author, issueNumber: 1),
            _ => throw new ArgumentException($"Unknown item type: '{type}'")
        };
    }
}
LibraryItem item = ItemFactory.Create("book", "Hopscotch", "Julio Cortazar");
Console.WriteLine(item.Describe()); // the calling code doesn't know (or need to know) it's a Book

The code calling ItemFactory.Create doesn't write new Book(...) or new Magazine(...) directly: it delegates that decision to the factory, based on a simple string. This centralizes the "which concrete class matches each type" logic in a single place, instead of repeating it — with the risk of it drifting out of sync — at every point in the program that needs to create an item. Section 6 expands on this example in more detail.

  1. Structural patterns: Adapter and Decorator

Adapter

Adapter adapts the interface of an existing class (often one that can't be modified) so it fits the interface the rest of the code expects. Imagine an external library catalog service that exposes its data with field names different from BiblioTech's:

class LegacyExternalCatalog
{
    public string GetBookTitle() => "One Hundred Years of Solitude";
    public string GetBookAuthor() => "Gabriel Garcia Marquez";
}

class ExternalCatalogAdapter : LibraryItem
{
    private readonly LegacyExternalCatalog _source;

    public ExternalCatalogAdapter(LegacyExternalCatalog source)
        : base(source.GetBookTitle(), source.GetBookAuthor())
    {
        _source = source;
    }

    public override string Describe() => $"(External) {Title}, by {Author}";
}

ExternalCatalogAdapter inherits from LibraryItem and, internally, translates calls to GetBookTitle()/GetBookAuthor() on LegacyExternalCatalog into the constructor LibraryItem already expects. The rest of BiblioTech can treat an item coming from that external service exactly like one of its own Books or Magazines, without ever knowing LegacyExternalCatalog's original interface.

Decorator

Decorator adds responsibilities to an object by wrapping it, without modifying its class or using inheritance for every possible combination of added behaviors:

abstract class ItemDecorator : LibraryItem
{
    protected readonly LibraryItem Item;

    protected ItemDecorator(LibraryItem item)
        : base(item.Title, item.Author)
    {
        Item = item;
    }
}

class NewLabelDecorator : ItemDecorator
{
    public NewLabelDecorator(LibraryItem item) : base(item) { }

    public override string Describe() => $"[NEW] {Item.Describe()}";
}
LibraryItem book = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
LibraryItem featuredBook = new NewLabelDecorator(book);

Console.WriteLine(featuredBook.Describe()); // [NEW] Book: Ficciones, by Jorge Luis Borges

NewLabelDecorator wraps any LibraryItem and adds the [NEW] prefix to its description, without touching either Book or Magazine, or needing a separate NewBook/NewMagazine class for every combination. Several decorators could be chained together (for example, one that adds [FEATURED] on top of this one) to combine behaviors flexibly.

  1. Behavioral patterns: Strategy and Observer

Strategy

Strategy encapsulates an interchangeable algorithm behind a common interface, so the concrete algorithm can be swapped at runtime without touching the code that uses it. Section 7 develops this in depth with Loan's penalty policies.

Observer: LoanRegistered is already an Observer

Observer defines a one-to-many dependency between objects: when one object (the subject) changes state, all of its observers are notified automatically, with no need for the subject to know them in advance. Revisit Library's LoanRegistered event, introduced in the Delegates and Events lesson from Module 4:

class Library
{
    public event Action<Loan> LoanRegistered; // the observed "subject"

    public void RegisterLoan(Loan loan)
    {
        Loans.Add(loan);
        LoanRegistered?.Invoke(loan); // notifies every subscribed "observer"
    }

    public List<Loan> Loans { get; } = new List<Loan>();
}
library.LoanRegistered += loan =>
    Console.WriteLine($"[Log] Loan of '{loan.Book.Title}' registered.");   // observer 1

library.LoanRegistered += loan =>
    Console.WriteLine($"'{loan.Book.Title}' was lent to {loan.Member.Name}"); // observer 2

Without anyone having named it until now, this structure is the Observer pattern: Library is the subject, each method subscribed with += is an observer, and event is the specific mechanism C# offers to implement this pattern safely (recall the Delegates and Events lesson: event prevents invoking or replacing the list of subscribers from outside the class). It's common to find you've already used a design pattern without knowing it — recognizing it now, by name, helps you reason about it using the C# community's shared vocabulary, and to recognize the same structure the next time it shows up.

  1. Summary table: when to use each pattern

Pattern Family Solves Already seen/used in BiblioTech
Singleton Creational Guaranteeing a single global instance BiblioTechConfiguration (section 2)
Factory Method Creational Centralizing the logic of which concrete class to create ItemFactory (sections 2 and 6)
Adapter Structural Adapting an existing incompatible interface to the one expected ExternalCatalogAdapter (section 3)
Decorator Structural Adding responsibilities to an object with no inheritance or modification NewLabelDecorator (section 3)
Strategy Behavioral Swapping an algorithm at runtime Loan's penalty policies (section 7)
Observer Behavioral Notifying several interested parties of a state change Library.LoanRegistered (Module 4)

  1. Example: Factory Method to create LibraryItems

Picking ItemFactory back up from section 2, here's a more complete version reflecting how it would be used in BiblioTech when importing items from an external source (for example, a CSV file or a JSON response, Module 5) where the item type arrives as text:

static class ItemFactory
{
    /// <summary>
    /// Creates the appropriate library item from a textual type.
    /// </summary>
    /// <param name="type">"book" or "magazine", case-insensitive.</param>
    /// <param name="title">The item's title.</param>
    /// <param name="author">The item's author.</param>
    /// <param name="additionalData">The ISBN if it's a book, or the issue number (as text) if it's a magazine.</param>
    /// <returns>An instance of <see cref="Book"/> or <see cref="Magazine"/> depending on the given type.</returns>
    public static LibraryItem Create(string type, string title, string author, string additionalData)
    {
        return type.ToLower() switch
        {
            "book" => new Book(title, author, isbn: additionalData),
            "magazine" => new Magazine(title, author, issueNumber: int.Parse(additionalData)),
            _ => throw new ArgumentException($"Unknown item type: '{type}'")
        };
    }
}
List<(string Type, string Title, string Author, string Data)> importedItems = new()
{
    ("book", "Hopscotch", "Julio Cortazar", "978-84-376-0495-4"),
    ("magazine", "National Geographic", "Various authors", "302")
};

foreach (var (type, title, author, data) in importedItems)
{
    LibraryItem item = ItemFactory.Create(type, title, author, data);
    Console.WriteLine(item.Describe());
}
// Book: Hopscotch, by Julio Cortazar (ISBN 978-84-376-0495-4)
// Magazine: National Geographic, issue number 302

If BiblioTech added a third item type in the future (for example, AudioBook), the only change needed would be adding a new case to ItemFactory.Create's switch; no other point in the program that already calls the factory would need to change.

  1. Example: Strategy for penalty calculation policies

BiblioTech needs to calculate a penalty when a loan is returned late. Instead of writing that logic directly inside Loan (which would force the class to change every time the penalty policy changed), Strategy extracts it into an interchangeable interface:

interface IPenaltyPolicy
{
    decimal CalculatePenalty(int daysLate);
}

class FixedPenalty : IPenaltyPolicy
{
    public decimal CalculatePenalty(int daysLate) => daysLate > 0 ? 2.00m : 0m;
}

class ProgressivePenalty : IPenaltyPolicy
{
    public decimal CalculatePenalty(int daysLate) => daysLate > 0 ? daysLate * 0.50m : 0m;
}

FixedPenalty always charges the same amount if there's a delay, no matter how many days; ProgressivePenalty charges more the more days late it is. Both implement the same IPenaltyPolicy interface, so they're interchangeable with each other. Loan receives the policy to apply without knowing the details of either implementation:

class Loan
{
    public Book Book { get; }
    public Member Member { get; }
    public DateTime LoanDate { get; }
    public DateTime? ReturnDate { get; private set; }

    public Loan(Book book, Member member)
    {
        Book = book;
        Member = member;
        LoanDate = DateTime.Now;
    }

    public void RegisterReturn()
    {
        ReturnDate = DateTime.Now;
    }

    public decimal CalculatePenalty(IPenaltyPolicy policy, int allowedDays = 14)
    {
        if (ReturnDate is null)
        {
            return 0m; // not returned yet, there's no penalty to calculate yet
        }

        int elapsedDays = (ReturnDate.Value - LoanDate).Days;
        int daysLate = Math.Max(0, elapsedDays - allowedDays);

        return policy.CalculatePenalty(daysLate);
    }
}
Loan loan1 = new Loan(book1, member1);
loan1.RegisterReturn();

decimal fixedPenalty = loan1.CalculatePenalty(new FixedPenalty());
decimal progressivePenalty = loan1.CalculatePenalty(new ProgressivePenalty());

Console.WriteLine($"Fixed penalty: {fixedPenalty:C}, progressive penalty: {progressivePenalty:C}");

CalculatePenalty receives the policy as a parameter (IPenaltyPolicy policy): Loan doesn't know, or need to know, whether FixedPenalty, ProgressivePenalty, or a third policy added in the future (for example, one that waives the penalty for members with a certain tenure) is being applied. Switching policies — even at runtime, deciding which one to use based on some condition — requires no modification to Loan at all.

Common Mistakes and Tips

  • Forcing a pattern where it isn't needed: applying Strategy for an if/else that will never change, or Decorator for a single variation that never combines with another, adds complexity with no real benefit. Patterns solve problems of variation and growth; if that problem doesn't exist, plain, simple code is the better option.
  • Confusing Adapter with Decorator: both "wrap" an object, but for different purposes: Adapter translates an incompatible interface into the expected one; Decorator adds behavior to an interface that was already compatible from the start.
  • Overusing Singleton for "anything global": Singleton makes unit testing harder (Lesson 4) because it introduces shared state that's difficult to replace with a test version. Lesson 3 of this module (Dependency Injection) offers, for most cases, a more flexible and more testable alternative.
  • Tip: don't memorize all twenty-three patterns from the original catalog; it's enough to recognize, when a familiar problem shows up (creating objects based on a variable type, notifying several interested parties, swapping an algorithm), which pattern already solves exactly that problem.

Exercises

  1. Add a third case to ItemFactory.Create (section 6) for an "audiobook" type that creates an AudioBook : LibraryItem class (you may assume it already exists, with a constructor AudioBook(string title, string author, int durationMinutes)), interpreting additionalData as the duration in minutes.

  2. Implement a third IPenaltyPolicy policy called NoPenalty, which always returns 0m regardless of the days late, and explain in one sentence what BiblioTech scenario it would make sense in.

Solutions

public static LibraryItem Create(string type, string title, string author, string additionalData)
{
    return type.ToLower() switch
    {
        "book" => new Book(title, author, isbn: additionalData),
        "magazine" => new Magazine(title, author, issueNumber: int.Parse(additionalData)),
        "audiobook" => new AudioBook(title, author, durationMinutes: int.Parse(additionalData)),
        _ => throw new ArgumentException($"Unknown item type: '{type}'")
    };
}
class NoPenalty : IPenaltyPolicy
{
    public decimal CalculatePenalty(int daysLate) => 0m;
}

It would make sense, for example, for members with some kind of exemption (library staff, or a temporary promotion), where you want to reuse all of Loan.CalculatePenalty's logic without applying any real financial penalty.

Conclusion

In this lesson you've learned what a design pattern is and why it gives you shared vocabulary and proven solutions, seen Singleton and Factory Method (creational), Adapter and Decorator (structural), and Strategy and Observer (behavioral) — discovering that the latter had already been at work in Library.LoanRegistered since Module 4 — and applied Factory Method to centralize item creation and Strategy to make Loan's penalty policies interchangeable. The next lesson picks up in depth a mechanism that already appeared mentioned in passing across several of Module 7's interfaces (ASP.NET Core, Blazor, MAUI): dependency injection, which turns out, at its core, to be a systematic way of applying the same principle that makes Strategy possible — programming against interfaces, not concrete implementations — to the whole BiblioTech project.

© Copyright 2026. All rights reserved