Back to CourseExpert6 min read
C Sharp /

Design Patterns in C#

Implement the most useful design patterns in idiomatic C#, including Singleton, Factory, Builder, Strategy, Observer, Decorator, Repository and Command, and learn which language features make some patterns unnecessary.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: April 27, 2026
Last updated: September 5, 2026
 Topic

What a pattern is

A design pattern is a named solution to a problem that keeps coming up. The value is partly the solution and partly the name. Saying “use a strategy here” communicates a whole structure in one word.

Patterns are grouped into three families: creational (how objects are made), structural (how they are composed) and behavioural (how they interact). This topic shows the ones you will actually meet in C# codebases, written the way modern C# writes them.

Singleton

One instance, shared everywhere. The C# idiom uses Lazy<T>, which is thread safe without a lock:

sealed class AppConfig
{
    private static readonly Lazy<AppConfig> instance = new(() => new AppConfig());
    public static AppConfig Instance => instance.Value;

    public string ConnectionString { get; } = "Server=...";

    private AppConfig() { }   // nobody else can create one
}

Console.WriteLine(AppConfig.Instance.ConnectionString);

Use it sparingly. Singletons are global state, which makes tests hard. With dependency injection you register a service as a singleton instead, and the container handles the single instance without the class knowing.

Factory Method

Centralise object creation so callers do not depend on concrete types:

interface INotification { void Send(string message); }
class EmailNotification : INotification { public void Send(string m) => Console.WriteLine($"Email: {m}"); }
class SmsNotification : INotification { public void Send(string m) => Console.WriteLine($"SMS: {m}"); }

static class NotificationFactory
{
    public static INotification Create(string channel) => channel switch
    {
        "email" => new EmailNotification(),
        "sms" => new SmsNotification(),
        _ => throw new ArgumentException($"Unknown channel {channel}")
    };
}

INotification n = NotificationFactory.Create("sms");
n.Send("Your code is 1234");

The switch lives in exactly one place. Adding a channel is a new class and one new arm.

Builder

For objects with many optional settings, a chain of methods reads better than a constructor with twelve parameters:

class HttpRequestBuilder
{
    private string url = "";
    private string method = "GET";
    private readonly Dictionary<string, string> headers = new();
    private string? body;

    public HttpRequestBuilder To(string url) { this.url = url; return this; }
    public HttpRequestBuilder WithMethod(string method) { this.method = method; return this; }
    public HttpRequestBuilder WithHeader(string k, string v) { headers[k] = v; return this; }
    public HttpRequestBuilder WithBody(string body) { this.body = body; return this; }

    public HttpRequestMessage Build()
    {
        var request = new HttpRequestMessage(new HttpMethod(method), url);
        foreach (var (k, v) in headers) request.Headers.Add(k, v);
        if (body != null) request.Content = new StringContent(body);
        return request;
    }
}

var request = new HttpRequestBuilder()
    .To("https://api.example.com/orders")
    .WithMethod("POST")
    .WithHeader("Authorization", "Bearer token")
    .WithBody("{ }")
    .Build();

Each method returns this, which is what makes the chain work. For simple cases, C# object initialisers and records with init properties often make a builder unnecessary.

Strategy

Swap an algorithm at run time by putting it behind an interface. This is the open closed principle in action:

interface IShippingStrategy { decimal Calculate(decimal weight); }
class StandardShipping : IShippingStrategy { public decimal Calculate(decimal w) => 5 + w * 0.5m; }
class ExpressShipping : IShippingStrategy { public decimal Calculate(decimal w) => 15 + w * 1.0m; }

class Checkout
{
    private readonly IShippingStrategy shipping;
    public Checkout(IShippingStrategy shipping) => this.shipping = shipping;
    public decimal Total(decimal goods, decimal weight) => goods + shipping.Calculate(weight);
}

var checkout = new Checkout(new ExpressShipping());

In C#, a Func<decimal, decimal> is often enough instead of a one method interface. Use the interface when the strategy needs state or several methods.

Observer

One object notifies many others when something happens. C# has this built in as events:

class StockTicker
{
    public event EventHandler<decimal>? PriceChanged;

    public void Update(decimal price) => PriceChanged?.Invoke(this, price);
}

var ticker = new StockTicker();
ticker.PriceChanged += (s, p) => Console.WriteLine($"Chart: {p}");
ticker.PriceChanged += (s, p) => { if (p > 100) Console.WriteLine("Alert!"); };
ticker.Update(105);

You rarely implement the classic IObserver interface by hand. IObservable<T> exists for the Reactive Extensions library when you need composition over streams of events.

Decorator

Add behaviour to an object by wrapping it in another object with the same interface:

interface IDataSource { string Read(); }

class FileSource : IDataSource
{
    public string Read() => File.ReadAllText("data.txt");
}

class CachingSource : IDataSource
{
    private readonly IDataSource inner;
    private string? cache;

    public CachingSource(IDataSource inner) => this.inner = inner;

    public string Read() => cache ??= inner.Read();
}

class LoggingSource : IDataSource
{
    private readonly IDataSource inner;
    public LoggingSource(IDataSource inner) => this.inner = inner;

    public string Read()
    {
        Console.WriteLine("Reading...");
        return inner.Read();
    }
}

IDataSource source = new LoggingSource(new CachingSource(new FileSource()));

Each decorator does one thing and passes through to the next. Stack them in any order. ASP.NET Core middleware and HttpClient handlers are decorator chains.

Repository

Hide data access behind a collection like interface so business code never sees SQL:

interface ICustomerRepository
{
    Customer? GetById(int id);
    IEnumerable<Customer> Find(Func<Customer, bool> predicate);
    void Add(Customer customer);
}

class InMemoryCustomerRepository : ICustomerRepository
{
    private readonly List<Customer> items = new();
    public Customer? GetById(int id) => items.FirstOrDefault(c => c.Id == id);
    public IEnumerable<Customer> Find(Func<Customer, bool> p) => items.Where(p);
    public void Add(Customer c) => items.Add(c);
}

Tests use the in memory version. Production uses one backed by Entity Framework or Dapper. Business code cannot tell the difference.

Command

Turn an action into an object so it can be queued, logged or undone:

interface ICommand
{
    void Execute();
    void Undo();
}

class AddTextCommand : ICommand
{
    private readonly StringBuilder doc;
    private readonly string text;

    public AddTextCommand(StringBuilder doc, string text) => (this.doc, this.text) = (doc, text);

    public void Execute() => doc.Append(text);
    public void Undo() => doc.Length -= text.Length;
}

var document = new StringBuilder();
var history = new Stack<ICommand>();

void Run(ICommand c) { c.Execute(); history.Push(c); }

Run(new AddTextCommand(document, "Hello"));
Run(new AddTextCommand(document, " World"));
history.Pop().Undo();
Console.WriteLine(document);   // Hello

Patterns C# makes unnecessary

Some classic patterns exist to work around missing language features. C# has the features:

  • Iterator: yield return and IEnumerable<T>.
  • Observer: events.
  • Strategy and Command for simple cases: delegates and lambdas.
  • Null Object: ?. and ?? operators, or an empty implementation.
  • Visitor for closed hierarchies: pattern matching on types.
  • Prototype: records with with expressions.

Knowing the pattern still helps you recognise the intent in other code. Reaching for the language feature keeps your code shorter.

Quick recap

  • Patterns are named, reusable solutions, and the names improve communication.
  • Singleton with Lazy<T>, Factory with a switch, Builder with a method chain.
  • Strategy swaps algorithms behind an interface, Decorator wraps to add behaviour.
  • Observer is built in as events, Command turns actions into undoable objects.
  • Repository hides data access so business logic and tests stay independent of the database.
  • Prefer language features such as lambdas, yield, events and pattern matching when they do the job.
Program.cs

// Design patterns in idiomatic C#
using System.Text;
// Singleton with Lazy<T>: thread safe, created on first use
sealed class AppConfig
{
    private static readonly Lazy<AppConfig> instance = new(() => new AppConfig());
    public static AppConfig Instance => instance.Value;
    public string Region { get; } = "ap-south-1";
    private AppConfig() { }
}
// Strategy: swap the algorithm behind an interface
interface IShipping { decimal Cost(decimal weight); }
class Standard : IShipping { public decimal Cost(decimal w) => 5 + w * 0.5m; }
class Express : IShipping { public decimal Cost(decimal w) => 15 + w; }
// Factory: creation logic in one place
static class ShippingFactory
{
    public static IShipping Create(string kind) => kind switch
    {
        "express" => new Express(),
        _ => new Standard()
    };
}
// Decorator: wrap to add behaviour without changing the wrapped class
class LoggedShipping : IShipping
{
    private readonly IShipping inner;
    public LoggedShipping(IShipping inner) => this.inner = inner;
    public decimal Cost(decimal w)
    {
        decimal c = inner.Cost(w);
        Console.WriteLine($"  [{inner.GetType().Name}] {w}kg -> {c}");
        return c;
    }
}
// Builder: readable construction of a complex object
class OrderBuilder
{
    private readonly List<string> items = new();
    private string shipping = "standard";
    public OrderBuilder Add(string item) { items.Add(item); return this; }
    public OrderBuilder ShipBy(string kind) { shipping = kind; return this; }
    public (List<string> Items, IShipping Shipping) Build()
        => (items, new LoggedShipping(ShippingFactory.Create(shipping)));
}
// Command: actions as undoable objects
interface ICommand { void Execute(); void Undo(); }
class AppendCommand : ICommand
{
    private readonly StringBuilder doc; private readonly string text;
    public AppendCommand(StringBuilder d, string t) => (doc, text) = (d, t);
    public void Execute() => doc.Append(text);
    public void Undo() => doc.Length -= text.Length;
}
// Observer: events are the built in form
class Ticker
{
    public event EventHandler<decimal>? PriceChanged;
    public void Update(decimal p) => PriceChanged?.Invoke(this, p);
}
Console.WriteLine($"Region: {AppConfig.Instance.Region}");
var order = new OrderBuilder().Add("Keyboard").Add("Mouse").ShipBy("express").Build();
Console.WriteLine($"Total shipping: {order.Shipping.Cost(2.5m)}");
var doc = new StringBuilder();
var history = new Stack<ICommand>();
void Run(ICommand c) { c.Execute(); history.Push(c); }
Run(new AppendCommand(doc, "Hello"));
Run(new AppendCommand(doc, " World"));
history.Pop().Undo();
Console.WriteLine(doc);                              // Hello
var ticker = new Ticker();
ticker.PriceChanged += (_, p) => Console.WriteLine($"  chart drew {p}");
ticker.PriceChanged += (_, p) => { if (p > 100) Console.WriteLine("  alert!"); };
ticker.Update(105);

Was this explanation helpful?

Your feedback helps us improve the content for everyone.