Implementing Design Patterns in .NET

Introduction

If you've ever struggled with rigid code that breaks when requirements change, or found yourself rewriting the same logic across multiple classes, design patterns offer solutions. These proven architectural approaches solve common design problems while making your code more maintainable and flexible.

Design patterns give you a shared vocabulary with other developers. When you say "use a Factory here" or "implement the Strategy pattern," your team immediately understands the structure and intent. This communication efficiency alone justifies learning patterns, but the real value comes from applying them to build better software.

You'll learn how to implement Factory, Strategy, Observer, and Repository patterns in .NET. Each section shows when to use the pattern, how to implement it correctly, and common mistakes to avoid. These patterns form the foundation for more advanced architectural decisions.

Factory Pattern for Object Creation

The Factory pattern centralizes object creation logic, separating how objects are constructed from where they're used. This is valuable when construction is complex, requires configuration, or should vary based on runtime conditions.

Instead of scattering new keywords throughout your code, factories provide a single place to create objects. When construction logic changes, you modify the factory without touching client code. This follows the open-closed principle where code is open for extension but closed for modification.

FactoryPattern.cs
using System;

public interface IPaymentProcessor
{
    void ProcessPayment(decimal amount);
}

public class CreditCardProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing ${amount} via credit card");
    }
}

public class PayPalProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing ${amount} via PayPal");
    }
}

public class CryptoCurrencyProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing ${amount} via cryptocurrency");
    }
}

public class PaymentProcessorFactory
{
    public static IPaymentProcessor Create(string paymentMethod)
    {
        return paymentMethod.ToLower() switch
        {
            "creditcard" => new CreditCardProcessor(),
            "paypal" => new PayPalProcessor(),
            "crypto" => new CryptoCurrencyProcessor(),
            _ => throw new ArgumentException(
                $"Unknown payment method: {paymentMethod}")
        };
    }
}

// Usage
var processor = PaymentProcessorFactory.Create("creditcard");
processor.ProcessPayment(99.99m);

var paypalProcessor = PaymentProcessorFactory.Create("paypal");
paypalProcessor.ProcessPayment(49.99m);

The factory hides concrete implementations from client code. Adding a new payment processor means creating a new class and updating the factory switch statement. Client code using the factory doesn't need to change. This encapsulation makes your system more flexible and easier to extend.

Strategy Pattern for Interchangeable Algorithms

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. This eliminates complex conditional logic and lets you add new algorithms without modifying existing code.

Each strategy implements a common interface. The context class holds a reference to a strategy and delegates work to it. You can swap strategies at runtime, making the system highly flexible and testable.

StrategyPattern.cs
using System;
using System.Collections.Generic;
using System.Linq;

public interface ISortStrategy
{
    List<int> Sort(List<int> data);
}

public class BubbleSortStrategy : ISortStrategy
{
    public List<int> Sort(List<int> data)
    {
        var result = new List<int>(data);
        for (int i = 0; i < result.Count - 1; i++)
        {
            for (int j = 0; j < result.Count - i - 1; j++)
            {
                if (result[j] > result[j + 1])
                {
                    (result[j], result[j + 1]) = (result[j + 1], result[j]);
                }
            }
        }
        Console.WriteLine("Sorted using Bubble Sort");
        return result;
    }
}

public class QuickSortStrategy : ISortStrategy
{
    public List<int> Sort(List<int> data)
    {
        var result = data.OrderBy(x => x).ToList();
        Console.WriteLine("Sorted using Quick Sort");
        return result;
    }
}

public class DataProcessor
{
    private ISortStrategy sortStrategy;

    public DataProcessor(ISortStrategy strategy)
    {
        sortStrategy = strategy;
    }

    public void SetSortStrategy(ISortStrategy strategy)
    {
        sortStrategy = strategy;
    }

    public List<int> ProcessData(List<int> data)
    {
        Console.WriteLine("Processing data...");
        return sortStrategy.Sort(data);
    }
}

// Usage
var data = new List<int> { 5, 2, 8, 1, 9 };
var processor = new DataProcessor(new BubbleSortStrategy());

var sorted1 = processor.ProcessData(data);
Console.WriteLine($"Result: {string.Join(", ", sorted1)}");

// Switch strategy at runtime
processor.SetSortStrategy(new QuickSortStrategy());
var sorted2 = processor.ProcessData(data);
Console.WriteLine($"Result: {string.Join(", ", sorted2)}");

The DataProcessor doesn't know which sorting algorithm it's using. It depends on the ISortStrategy interface, not concrete implementations. This makes testing easy because you can inject mock strategies, and adding new algorithms doesn't require changing DataProcessor.

Observer Pattern for Event Notification

The Observer pattern establishes a one-to-many relationship where multiple objects automatically receive notifications when another object changes state. This decouples the subject from observers, allowing dynamic subscription and notification.

In .NET, events and delegates provide language-level support for the Observer pattern. This implementation is cleaner and more efficient than manually maintaining observer lists.

ObserverPattern.cs
using System;

public class StockPriceChangedEventArgs : EventArgs
{
    public string Symbol { get; }
    public decimal OldPrice { get; }
    public decimal NewPrice { get; }

    public StockPriceChangedEventArgs(string symbol, decimal oldPrice,
        decimal newPrice)
    {
        Symbol = symbol;
        OldPrice = oldPrice;
        NewPrice = newPrice;
    }
}

public class Stock
{
    private decimal price;

    public string Symbol { get; }
    public decimal Price
    {
        get => price;
        set
        {
            if (price != value)
            {
                var oldPrice = price;
                price = value;
                OnPriceChanged(new StockPriceChangedEventArgs(
                    Symbol, oldPrice, value));
            }
        }
    }

    public event EventHandler<StockPriceChangedEventArgs>? PriceChanged;

    public Stock(string symbol, decimal initialPrice)
    {
        Symbol = symbol;
        price = initialPrice;
    }

    protected virtual void OnPriceChanged(StockPriceChangedEventArgs e)
    {
        PriceChanged?.Invoke(this, e);
    }
}

public class StockAlert
{
    private readonly decimal threshold;

    public StockAlert(decimal threshold)
    {
        this.threshold = threshold;
    }

    public void Subscribe(Stock stock)
    {
        stock.PriceChanged += OnStockPriceChanged;
    }

    private void OnStockPriceChanged(object? sender,
        StockPriceChangedEventArgs e)
    {
        var change = ((e.NewPrice - e.OldPrice) / e.OldPrice) * 100;
        if (Math.Abs(change) >= threshold)
        {
            Console.WriteLine($"ALERT: {e.Symbol} changed by {change:F2}% " +
                            $"({e.OldPrice:C} -> {e.NewPrice:C})");
        }
    }
}

// Usage
var stock = new Stock("AAPL", 150.00m);
var alert = new StockAlert(5.0m); // Alert on 5% changes

alert.Subscribe(stock);

stock.Price = 160.00m; // Triggers alert
stock.Price = 161.00m; // No alert (less than 5% change)

The Stock class doesn't know about StockAlert. It simply raises events when price changes. Multiple observers can subscribe without modifying Stock. This loose coupling makes the system extensible and testable. The thread-safe event invocation with the null-conditional operator prevents errors when no observers are subscribed.

Repository Pattern for Data Access

The Repository pattern provides an abstraction layer between business logic and data access. It centralizes data access logic, improves testability by allowing mock repositories, and lets you change data sources without affecting business logic.

Repositories expose collection-like interfaces for accessing domain objects. They hide the details of database queries, caching, and data mapping from the rest of your application.

RepositoryPattern.cs
using System;
using System.Collections.Generic;
using System.Linq;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public decimal Price { get; set; }
    public string Category { get; set; } = "";
}

public interface IProductRepository
{
    Product? GetById(int id);
    IEnumerable<Product> GetAll();
    IEnumerable<Product> GetByCategory(string category);
    void Add(Product product);
    void Update(Product product);
    void Delete(int id);
}

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

    public Product? GetById(int id)
    {
        return products.FirstOrDefault(p => p.Id == id);
    }

    public IEnumerable<Product> GetAll()
    {
        return products.ToList();
    }

    public IEnumerable<Product> GetByCategory(string category)
    {
        return products.Where(p => p.Category == category).ToList();
    }

    public void Add(Product product)
    {
        product.Id = nextId++;
        products.Add(product);
        Console.WriteLine($"Added product: {product.Name} (ID: {product.Id})");
    }

    public void Update(Product product)
    {
        var existing = GetById(product.Id);
        if (existing != null)
        {
            existing.Name = product.Name;
            existing.Price = product.Price;
            existing.Category = product.Category;
            Console.WriteLine($"Updated product: {product.Name}");
        }
    }

    public void Delete(int id)
    {
        var product = GetById(id);
        if (product != null)
        {
            products.Remove(product);
            Console.WriteLine($"Deleted product: {product.Name}");
        }
    }
}

public class ProductService
{
    private readonly IProductRepository repository;

    public ProductService(IProductRepository repository)
    {
        this.repository = repository;
    }

    public void DisplayProducts()
    {
        var products = repository.GetAll();
        Console.WriteLine("\nAll Products:");
        foreach (var p in products)
        {
            Console.WriteLine($"  {p.Id}: {p.Name} - {p.Price:C} ({p.Category})");
        }
    }

    public void ShowElectronics()
    {
        var electronics = repository.GetByCategory("Electronics");
        Console.WriteLine("\nElectronics:");
        foreach (var p in electronics)
        {
            Console.WriteLine($"  {p.Name} - {p.Price:C}");
        }
    }
}

The ProductService depends on IProductRepository, not the concrete implementation. You can swap InMemoryProductRepository for a database-backed implementation without changing ProductService. During testing, inject a mock repository to test business logic without hitting a database.

Try It Yourself

Here's a complete example demonstrating multiple patterns working together in a simple e-commerce application.

Program.cs
using System;

// Create repository
var repository = new InMemoryProductRepository();

// Add products
repository.Add(new Product
{
    Name = "Laptop",
    Price = 999.99m,
    Category = "Electronics"
});
repository.Add(new Product
{
    Name = "Mouse",
    Price = 29.99m,
    Category = "Electronics"
});
repository.Add(new Product
{
    Name = "Desk",
    Price = 299.99m,
    Category = "Furniture"
});

// Use service with repository
var service = new ProductService(repository);
service.DisplayProducts();
service.ShowElectronics();

// Demonstrate Factory pattern
Console.WriteLine("\n--- Payment Processing ---");
var payment = PaymentProcessorFactory.Create("creditcard");
payment.ProcessPayment(99.99m);

// Demonstrate Observer pattern
Console.WriteLine("\n--- Stock Alerts ---");
var stock = new Stock("MSFT", 100.00m);
var alert = new StockAlert(3.0m);
alert.Subscribe(stock);

stock.Price = 105.00m; // Triggers alert
stock.Price = 106.00m; // No alert
Project.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output:

Added product: Laptop (ID: 1)
Added product: Mouse (ID: 2)
Added product: Desk (ID: 3)

All Products:
  1: Laptop - $999.99 (Electronics)
  2: Mouse - $29.99 (Electronics)
  3: Desk - $299.99 (Furniture)

Electronics:
  Laptop - $999.99
  Mouse - $29.99

--- Payment Processing ---
Processing $99.99 via credit card

--- Stock Alerts ---
ALERT: MSFT changed by 5.00% ($100.00 -> $105.00)

Run with dotnet run. This example shows how patterns work together. The Repository pattern isolates data access, Factory creates payment processors, and Observer monitors stock prices. Each pattern solves a specific problem while remaining independent.

Choosing the Right Pattern

Use Factory when: Object creation is complex, involves configuration, or varies based on runtime conditions. Factories centralize construction logic and decouple client code from concrete types. Avoid factories for simple objects that need no special initialization.

Use Strategy when: You have multiple algorithms for the same task and need to switch between them. This eliminates conditional logic and makes adding algorithms easy. Don't use Strategy for single-algorithm scenarios where the overhead isn't justified.

Use Observer when: Objects need to react to state changes in other objects. Events in .NET provide excellent Observer support. Be careful about memory leaks from event subscriptions that aren't cleaned up properly.

Use Repository when: You need to abstract data access, support multiple data sources, or improve testability. Repositories work well in complex domains with rich business logic. For simple CRUD applications, the abstraction might be overkill.

Frequently Asked Questions (FAQ)

What are design patterns and why use them?

Design patterns are proven solutions to common software design problems. They provide a shared vocabulary for developers and offer tested approaches to recurring challenges. Using patterns makes code more maintainable, testable, and easier for teams to understand. They encapsulate best practices learned from years of software development experience.

When should I use the Factory pattern?

Use the Factory pattern when object creation logic is complex, involves multiple steps, or depends on runtime conditions. It centralizes object creation, making it easier to change how objects are constructed without modifying client code. Factories are particularly useful when you need to create different implementations of an interface based on configuration or input.

How does the Strategy pattern improve code flexibility?

The Strategy pattern encapsulates algorithms in separate classes, allowing you to switch between them at runtime. This eliminates large conditional statements and makes adding new algorithms easy without modifying existing code. Each strategy implements a common interface, making them interchangeable and testable in isolation.

What's the difference between Observer and event-driven programming?

The Observer pattern is the conceptual foundation for event-driven programming. In .NET, events and delegates implement the Observer pattern with language-level support. Events provide a strongly-typed, efficient implementation of the pattern with built-in memory management and thread safety considerations.

Should I always use the Repository pattern with databases?

Not always. The Repository pattern adds value in complex applications where you need to abstract database access, support multiple data sources, or improve testability by mocking data access. For simple CRUD applications, direct use of Entity Framework or Dapper might be sufficient. Evaluate whether the abstraction layer justifies the additional code.

Can design patterns hurt performance?

Some patterns add indirection and object allocations that have minor performance costs. However, these costs are negligible in most applications and the benefits in maintainability usually outweigh performance concerns. Profile your application to identify actual bottlenecks rather than prematurely optimizing away useful abstractions.

Back to Articles