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
- What a design pattern is and why it matters
- Creational patterns: Singleton and Factory Method
- Structural patterns: Adapter and Decorator
- Behavioral patterns: Strategy and Observer
- Summary table: when to use each pattern
- Example: Factory Method to create
LibraryItems - Example: Strategy for penalty calculation policies
- 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.
- 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 objectThe 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 BookThe 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.
- 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 BorgesNewLabelDecorator 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.
- 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 2Without 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.
- 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) |
- Example: Factory Method to create
LibraryItems
LibraryItemsPicking 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 302If 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.
- 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/elsethat 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
-
Add a third case to
ItemFactory.Create(section 6) for an"audiobook"type that creates anAudioBook : LibraryItemclass (you may assume it already exists, with a constructorAudioBook(string title, string author, int durationMinutes)), interpretingadditionalDataas the duration in minutes. -
Implement a third
IPenaltyPolicypolicy calledNoPenalty, which always returns0mregardless 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.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
