<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. C#
  3. Master level
All lessons in this course

Lesson 54 of 60 18 minMaster

Design patterns in C#

Proven solutions to common design problems: Strategy, Factory, Decorator, Observer, Builder, Adapter and Singleton, written in modern C# with real examples.

A design pattern is a named, proven shape for solving a problem that keeps coming up. Knowing the names lets a team say “put a decorator around the repository” instead of explaining it for ten minutes. Most patterns come from the 1994 “Gang of Four” book; here are the ones you will meet most in C#.

Interactive

Patterns in action

Tension check of a member, A = 5000 mm², fy = 250 MPa. The calculation is a swappable object.

ITensionCheck check = new Is800Check();
var report = new MemberReport(check); // MemberReport never changes
report.Run(beam);
IS 800:2007: A·fy / γm0, γm0 = 1.10 → design strength 1136.4 kN
PatternKindProblem it solves
Strategybehaviouralswap an algorithm at run time
Factorycreationaldecide which class to create from data
Buildercreationalcreate a complex object step by step
Decoratorstructuraladd behaviour around an object without changing it
Adapterstructuralmake an existing class fit the interface you need
Observerbehaviouralnotify many listeners when something happens
Singletoncreationalexactly one instance for the whole app

Strategy

Strategy.cs
public interface ITensionCheck{    string Name { get; }    double DesignStrengthN(double areaMm2, double fyMpa);} public class Is800Check : ITensionCheck{    public string Name => "IS 800";    public double DesignStrengthN(double a, double fy) => a * fy / 1.10;} public class Eurocode3Check : ITensionCheck{    public string Name => "EN 1993";    public double DesignStrengthN(double a, double fy) => a * fy / 1.00;} public class MemberReport(ITensionCheck check){    public string Run(double a, double fy) => $"{check.Name}: {check.DesignStrengthN(a, fy) / 1000:F1} kN";} Console.WriteLine(new MemberReport(new Is800Check()).Run(5000, 250));Console.WriteLine(new MemberReport(new Eurocode3Check()).Run(5000, 250));
Output
IS 800: 1136.4 kN
EN 1993: 1250.0 kN

Factory

Factory.cs
public abstract record Section(string Name);public record ISection(string Name) : Section(Name);public record HollowRectSection(string Name) : Section(Name); public static class SectionFactory{    public static Section Create(string name) => name.ToUpperInvariant() switch    {        var n when n.StartsWith("IPE") || n.StartsWith("HEA") => new ISection(n),        var n when n.StartsWith("RHS") || n.StartsWith("SHS") => new HollowRectSection(n),        _ => throw new ArgumentException($"Unknown section \"{name}\"")    };} Section s = SectionFactory.Create("ipe300");Console.WriteLine(s);
Output
ISection { Name = IPE300 }

Callers depend only on Section. All the “which class?” knowledge lives in one place.

Builder

Builder.cs
var report = new ReportBuilder()    .WithTitle("Level 2 steel")    .AddSection("Beams", beams)    .AddSection("Columns", columns)    .WithLogo("logo.png")    .Build(); public class ReportBuilder{    private readonly Report _r = new();    public ReportBuilder WithTitle(string t) { _r.Title = t; return this; }   // return this → chaining    public ReportBuilder AddSection(string name, IEnumerable<Beam> rows) { _r.Sections.Add(new(name, rows.ToList())); return this; }    public ReportBuilder WithLogo(string path) { _r.LogoPath = path; return this; }    public Report Build() => string.IsNullOrEmpty(_r.Title) ? throw new InvalidOperationException("Title required") : _r;}

You already use builders: StringBuilder, WebApplication.CreateBuilder(), and UriBuilder all follow this idea.

Decorator

Decorator.cs
public interface IBeamRepository { Beam? Get(string mark); } public class CachingRepository(IBeamRepository inner) : IBeamRepository{    private readonly Dictionary<string, Beam?> _cache = new();     public Beam? Get(string mark)    {        if (_cache.TryGetValue(mark, out var hit)) return hit;   // fast path        var beam = inner.Get(mark);                              // delegate to the wrapped object        _cache[mark] = beam;        return beam;    }} IBeamRepository repo = new LoggingRepository(new CachingRepository(new SqlBeamRepository(conn)));

Each decorator implements the same interface as the object it wraps, so wrappers stack in any order and callers never know. Stream in .NET works this way: new GZipStream(new BufferedStream(fileStream), …).

Adapter

Adapter.cs
// A third-party library we can't changepublic class VendorIfcReader { public VendorElement[] ReadAll(string file) => /* … */ []; } // Our app wants IModelSourcepublic class IfcModelSource(VendorIfcReader reader, string file) : IModelSource{    public IReadOnlyList<Beam> LoadBeams() =>        reader.ReadAll(file)              .Where(e => e.Category == "IfcBeam")              .Select(e => new Beam(e.Tag, e.Length / 1000.0, e.MassPerLength))              .ToList();}

Observer and Singleton

You already know Observer: C# events are the built-in version (see the events lesson). Singleton means “one instance for the whole app”:

Singleton.cs
public sealed class UnitSettings{    public static UnitSettings Instance { get; } = new();   // created once, thread-safe    private UnitSettings() { }                              // nobody else can call new    public string Length { get; set; } = "mm";}

Using patterns well

  • Start with plain code. Introduce a pattern when the problem it solves actually appears.
  • Name classes after the pattern when it helps: SectionFactory, CachingRepository, ReportBuilder.
  • Patterns combine: a factory can return strategies; decorators wrap repositories created by DI.
Quick check

You need to add timing logs around every call to an existing IModelSource without editing it. Which pattern fits?

Quick check

A vendor library returns VendorElement[] but your code needs IModelSource. Which pattern connects them?

Practice

Write a TimingRepository decorator for IBeamRepository that measures each Get call with Stopwatch and prints Get(B12) took N ms, then returns the inner result.

Show the solution
Solution.cs
public class TimingRepository(IBeamRepository inner) : IBeamRepository{    public Beam? Get(string mark)    {        var sw = Stopwatch.StartNew();        try        {            return inner.Get(mark);        }        finally        {            Console.WriteLine($"Get({mark}) took {sw.ElapsedMilliseconds} ms");        }    }} IBeamRepository repo = new TimingRepository(new SqlBeamRepository(conn));repo.Get("B12");
Output
Get(B12) took 14 ms

Remember

  • Strategy swaps algorithms; Factory chooses classes; Builder assembles complex objects.
  • Decorator wraps an object with extra behaviour behind the same interface; Adapter converts interfaces.
  • C# events are the Observer pattern; prefer DI singletons to static Singleton classes.
  • Use a pattern when its problem appears, not before.
0 of 60 lessons completed