Skip to content

Design Patterns in C# — Complete Guide

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Design Patterns in C#. We cover key concepts, practical examples, and best practices to help you master this topic.

Hook

Design patterns are reusable solutions to common software design problems. C# and .NET provide language features that make many patterns simpler to implement. Understanding design patterns gives you a shared vocabulary with other developers and proven approaches to building maintainable applications.

Learning Path

graph LR
  A[Design Patterns] --> B[Creational]
  A --> C[Structural]
  A --> D[Behavioral]
  B --> E[Singleton Factory]
  C --> F[Adapter Decorator]
  D --> G[Strategy Observer]
  style A fill:#4a90d9,color:#fff
  style B fill:#4a90d9,color:#fff
  style C fill:#4a90d9,color:#fff
  style D fill:#4a90d9,color:#fff
  style E fill:#4a90d9,color:#fff
  style F fill:#4a90d9,color:#fff
  style G fill:#4a90d9,color:#fff

Singleton

Ensures a class has only one instance with a global access point.

public sealed class Logger
{
    private static readonly Lazy<Logger> _instance =
        new(() => new Logger());

    private Logger() { }

    public static Logger Instance => _instance.Value;

    public void Log(string message) =>
        Console.WriteLine($"[{DateTime.UtcNow:O}] {message}");
}

// Usage
Logger.Instance.Log("Application started");

// Modern alternative: dependency injection as singleton
// services.AddSingleton<Logger>();

Factory Method

Creates objects without specifying the exact class.

public interface IPaymentProcessor
{
    Task<bool> ProcessPayment(decimal amount);
}

public class CreditCardProcessor : IPaymentProcessor
{
    public async Task<bool> ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing ${amount} via credit card");
        return await Task.FromResult(true);
    }
}

public class PayPalProcessor : IPaymentProcessor
{
    public async Task<bool> ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing ${amount} via PayPal");
        return await Task.FromResult(true);
    }
}

public class PaymentProcessorFactory
{
    public IPaymentProcessor Create(PaymentMethod method) =>
        method switch
        {
            PaymentMethod.CreditCard => new CreditCardProcessor(),
            PaymentMethod.PayPal => new PayPalProcessor(),
            _ => throw new ArgumentException($"Unknown method: {method}")
        };
}

public enum PaymentMethod { CreditCard, PayPal }

// Usage
var factory = new PaymentProcessorFactory();
var processor = factory.Create(PaymentMethod.PayPal);
await processor.ProcessPayment(99.99m);

Strategy

Encapsulates interchangeable algorithms behind a common interface.

public interface ISortStrategy
{
    void Sort(int[] data);
}

public class BubbleSort : ISortStrategy
{
    public void Sort(int[] data)
    {
        Console.WriteLine("Using bubble sort");
        for (int i = 0; i < data.Length - 1; i++)
            for (int j = 0; j < data.Length - i - 1; j++)
                if (data[j] > data[j + 1])
                    (data[j], data[j + 1]) = (data[j + 1], data[j]);
    }
}

public class QuickSort : ISortStrategy
{
    public void Sort(int[] data)
    {
        Console.WriteLine("Using quick sort");
        Array.Sort(data); // Built-in quicksort
    }
}

public class SortContext
{
    private readonly ISortStrategy _strategy;

    public SortContext(ISortStrategy strategy) => _strategy = strategy;

    public void ExecuteSort(int[] data) => _strategy.Sort(data);
}

// Usage
var data = new[] { 5, 3, 8, 1, 9, 2 };
var context = new SortContext(new QuickSort());
context.ExecuteSort(data);
Console.WriteLine(string.Join(", ", data));

Observer

Defines a one-to-many dependency between objects.

public class StockMarket
{
    private readonly List<IObserver<StockPrice>> _observers = new();
    private readonly Random _rng = new();

    public IDisposable Subscribe(IObserver<StockPrice> observer)
    {
        _observers.Add(observer);
        return new Unsubscriber(_observers, observer);
    }

    public void Notify(StockPrice price)
    {
        foreach (var observer in _observers)
            observer.OnNext(price);
    }

    public void StartTrading()
    {
        var symbols = new[] { "AAPL", "GOOGL", "MSFT" };
        foreach (var symbol in symbols)
        {
            var price = Math.Round((decimal)(_rng.NextDouble() * 1000 + 100), 2);
            Notify(new StockPrice(symbol, price));
        }
    }

    private class Unsubscriber : IDisposable
    {
        private readonly List<IObserver<StockPrice>> _observers;
        private readonly IObserver<StockPrice> _observer;

        public Unsubscriber(List<IObserver<StockPrice>> observers,
            IObserver<StockPrice> observer)
        {
            _observers = observers;
            _observer = observer;
        }

        public void Dispose() => _observers.Remove(_observer);
    }
}

public record StockPrice(string Symbol, decimal Price);

public class Trader : IObserver<StockPrice>
{
    private readonly string _name;

    public Trader(string name) => _name = name;

    public void OnNext(StockPrice price) =>
        Console.WriteLine($"{_name} sees {price.Symbol}: ${price.Price}");

    public void OnError(Exception error) =>
        Console.WriteLine($"{_name} error: {error.Message}");

    public void OnCompleted() =>
        Console.WriteLine($"{_name}: trading day ended");
}

// Usage
var market = new StockMarket();
var alice = new Trader("Alice");
var bob = new Trader("Bob");

using var sub1 = market.Subscribe(alice);
using var sub2 = market.Subscribe(bob);

market.StartTrading();

Adapter

Allows incompatible interfaces to work together.

// Legacy interface
public interface IXmlDataProvider
{
    string GetXmlData();
}

public class LegacyXmlService : IXmlDataProvider
{
    public string GetXmlData() => "<data><item>legacy</item></data>";
}

// Modern interface
public interface IJsonDataProvider
{
    Task<string> GetJsonDataAsync();
}

// Adapter
public class XmlToJsonAdapter : IJsonDataProvider
{
    private readonly IXmlDataProvider _xmlProvider;

    public XmlToJsonAdapter(IXmlDataProvider xmlProvider)
    {
        _xmlProvider = xmlProvider;
    }

    public async Task<string> GetJsonDataAsync()
    {
        string xml = _xmlProvider.GetXmlData();
        // Convert XML to JSON (simplified)
        return await Task.FromResult("{\"data\": {\"item\": \"legacy\"}}");
    }
}

// Usage
var legacy = new LegacyXmlService();
IJsonDataProvider adapter = new XmlToJsonAdapter(legacy);
string json = await adapter.GetJsonDataAsync();

Repository

Abstracts data access behind a collection-like interface.

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
    Task<List<Product>> GetAllAsync();
    Task<Product> AddAsync(Product product);
    Task UpdateAsync(Product product);
    Task DeleteAsync(int id);
}

public class InMemoryProductRepository : IProductRepository
{
    private readonly List<Product> _products = new();
    private int _nextId = 1;

    public Task<Product?> GetByIdAsync(int id) =>
        Task.FromResult(_products.FirstOrDefault(p => p.Id == id));

    public Task<List<Product>> GetAllAsync() =>
        Task.FromResult(_products.ToList());

    public Task<Product> AddAsync(Product product)
    {
        product.Id = _nextId++;
        _products.Add(product);
        return Task.FromResult(product);
    }

    public Task UpdateAsync(Product product) => Task.CompletedTask;

    public Task DeleteAsync(int id)
    {
        _products.RemoveAll(p => p.Id == id);
        return Task.CompletedTask;
    }
}

public class EfProductRepository : IProductRepository
{
    private readonly AppDbContext _context;

    public EfProductRepository(AppDbContext context) => _context = context;

    public async Task<Product?> GetByIdAsync(int id) =>
        await _context.Products.FindAsync(id);

    public async Task<List<Product>> GetAllAsync() =>
        await _context.Products.ToListAsync();

    public async Task<Product> AddAsync(Product product)
    {
        _context.Products.Add(product);
        await _context.SaveChangesAsync();
        return product;
    }

    public async Task UpdateAsync(Product product)
    {
        _context.Products.Update(product);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var product = await _context.Products.FindAsync(id);
        if (product != null)
        {
            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
        }
    }
}

Common Mistakes

  1. Overusing Singleton: Singletons introduce global state and make testing difficult. Prefer DI-registered singletons.

  2. Implementing patterns by the book: Adapt patterns to your specific needs. Strict adherence without considering context leads to over-engineering.

  3. Using Factory where a simple constructor suffices: Only use factories when object creation is complex, conditional, or must be deferred.

  4. Tight coupling in Strategy: The strategy pattern requires the client to know which strategy to use. Combine with Factory for better encapsulation.

  5. Observer pattern without unsubscription: Failing to unsubscribe observers causes memory leaks in long-running applications.

Practice Questions

  1. Implement the Decorator pattern to add logging, caching, and retry capabilities to an IProductRepository.

  2. Create a Builder pattern for constructing complex Order objects with optional components.

  3. Implement the Chain of Responsibility pattern for a request validation pipeline.

  4. Challenge: Build a complete application using Command, Mediator, and CQRS patterns with MediatR library.

FAQ

Are design patterns still relevant with modern C# features?

Yes. Many patterns are now simpler to implement (e.g., records for DTOs, Func for Strategy). The patterns themselves remain valuable.

What is the most important pattern for C# developers?

Dependency Injection is the most impactful pattern for modern .NET applications. It is built into ASP.NET Core and enables testability and loose coupling.

Should I use static classes instead of Singleton?

Static classes work for stateless utility methods. Singletons (or DI singletons) are better for stateful services that need interfaces for testing.

How do I choose between patterns?

Focus on the problem, not the pattern. Start simple and introduce patterns when you encounter specific design problems.

What is the difference between Factory and Builder?

Factory creates an object in one step. Builder constructs complex objects step by step, allowing different representations.

Mini Project: Notification System with Patterns

Combine Strategy and Observer for a flexible notification system.

using System;
using System.Collections.Concurrent;

// Strategy pattern for notification channels
public interface INotificationChannel
{
    Task SendAsync(string recipient, string message);
}

public class EmailChannel : INotificationChannel
{
    public async Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"EMAIL to {recipient}: {message}");
        await Task.CompletedTask;
    }
}

public class SmsChannel : INotificationChannel
{
    public async Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"SMS to {recipient}: {message}");
        await Task.CompletedTask;
    }
}

// Observer pattern for subscribers
public interface INotificationObserver
{
    Task OnNotificationAsync(string type, string message);
}

public class UserNotificationObserver : INotificationObserver
{
    private readonly string _userId;
    private readonly List<INotificationChannel> _channels;

    public UserNotificationObserver(string userId, List<INotificationChannel> channels)
    {
        _userId = userId;
        _channels = channels;
    }

    public async Task OnNotificationAsync(string type, string message)
    {
        foreach (var channel in _channels)
            await channel.SendAsync(_userId, $"[{type}] {message}");
    }
}

// Notification system
public class NotificationSystem
{
    private readonly ConcurrentDictionary<string, List<INotificationObserver>> _subscribers = new();

    public void Subscribe(string notificationType, INotificationObserver observer)
    {
        _subscribers.AddOrUpdate(
            notificationType,
            _ => new List<INotificationObserver> { observer },
            (_, list) => { list.Add(observer); return list; });
    }

    public async Task PublishAsync(string notificationType, string message)
    {
        if (_subscribers.TryGetValue(notificationType, out var observers))
        {
            var tasks = observers.Select(o => o.OnNotificationAsync(notificationType, message));
            await Task.WhenAll(tasks);
        }
    }
}

// Usage
var system = new NotificationSystem();

var alice = new UserNotificationObserver("alice@example.com", new()
{
    new EmailChannel(),
    new SmsChannel()
});

var bob = new UserNotificationObserver("bob@example.com", new()
{
    new EmailChannel()
});

system.Subscribe("order.shipped", alice);
system.Subscribe("order.shipped", bob);
system.Subscribe("payment.received", alice);

await system.PublishAsync("order.shipped", "Your order has been shipped!");
await system.PublishAsync("payment.received", "Payment of $49.99 received.");

Output:

EMAIL to alice@example.com: [order.shipped] Your order has been shipped!
SMS to alice@example.com: [order.shipped] Your order has been shipped!
EMAIL to bob@example.com: [order.shipped] Your order has been shipped!
EMAIL to alice@example.com: [payment.received] Payment of $49.99 received.
SMS to alice@example.com: [payment.received] Payment of $49.99 received.

Design patterns give C# developers a shared vocabulary and proven solutions to common problems. Combined with modern .NET features like Dependency Injection, records, and Functional Programming, you can build applications that are flexible, testable, and maintainable.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro