Skip to main content

.NET Design Patterns

Introduction

Design patterns are proven solutions to commonly occurring problems in software design. They represent best practices evolved over time by experienced software developers. In .NET development, understanding and applying these patterns can significantly improve your code's quality, maintainability, and scalability.

This guide introduces you to essential design patterns in the context of .NET development. We'll explore various pattern categories, provide C# implementation examples, and discuss when and why to use each pattern.

What Are Design Patterns?

Design patterns are reusable templates that solve common design problems. They provide a common vocabulary for developers to communicate effectively about software design. Design patterns are typically categorized into three groups:

  1. Creational Patterns: Handle object creation mechanisms
  2. Structural Patterns: Deal with object composition and relationships
  3. Behavioral Patterns: Focus on communication between objects

Let's explore key patterns from each category with practical .NET examples.

Creational Design Patterns

Singleton Pattern

The Singleton pattern ensures a class has only one instance and provides a global point of access to it.

Implementation

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

private Logger()
{
// Private constructor prevents direct instantiation
}

public static Logger Instance => _instance.Value;

public void Log(string message)
{
Console.WriteLine($"{DateTime.Now}: {message}");
}
}

Usage Example

csharp
// Access the singleton instance
Logger.Instance.Log("Application started");
Logger.Instance.Log("Processing data...");

// Both calls use the same instance

Output

5/15/2023 9:45:32 AM: Application started
5/15/2023 9:45:32 AM: Processing data...

When to Use

  • When exactly one instance of a class is needed
  • When the instance must be accessible globally
  • For managing shared resources (database connections, configuration)

Factory Method Pattern

The Factory Method pattern defines an interface for creating objects but lets subclasses decide which classes to instantiate.

Implementation

csharp
// Product interface
public interface IDocument
{
void Open();
void Save();
}

// Concrete products
public class PdfDocument : IDocument
{
public void Open() => Console.WriteLine("Opening PDF document");
public void Save() => Console.WriteLine("Saving PDF document");
}

public class WordDocument : IDocument
{
public void Open() => Console.WriteLine("Opening Word document");
public void Save() => Console.WriteLine("Saving Word document");
}

// Creator abstract class
public abstract class DocumentCreator
{
public abstract IDocument CreateDocument();

// The "factory method"
public void OpenDocument()
{
IDocument document = CreateDocument();
document.Open();
}
}

// Concrete creators
public class PdfDocumentCreator : DocumentCreator
{
public override IDocument CreateDocument() => new PdfDocument();
}

public class WordDocumentCreator : DocumentCreator
{
public override IDocument CreateDocument() => new WordDocument();
}

Usage Example

csharp
DocumentCreator creator;

// Create and open a PDF document
creator = new PdfDocumentCreator();
creator.OpenDocument();

// Create and open a Word document
creator = new WordDocumentCreator();
creator.OpenDocument();

Output

Opening PDF document
Opening Word document

When to Use

  • When a class can't anticipate the type of objects it must create
  • When a class wants its subclasses to specify the objects it creates
  • When you need to delegate responsibility to one of several helper subclasses

Structural Design Patterns

Adapter Pattern

The Adapter pattern allows objects with incompatible interfaces to collaborate.

Implementation

csharp
// The interface our client expects to use
public interface ITarget
{
string GetRequest();
}

// The class with the incompatible interface
public class LegacyService
{
public string GetSpecificRequest()
{
return "Legacy data format";
}
}

// The adapter makes the LegacyService compatible with the ITarget interface
public class Adapter : ITarget
{
private readonly LegacyService _legacyService;

public Adapter(LegacyService legacyService)
{
_legacyService = legacyService;
}

public string GetRequest()
{
string legacyData = _legacyService.GetSpecificRequest();
return $"Adapter: Converted \"{legacyData}\" to compatible format";
}
}

Usage Example

csharp
// We want to use LegacyService, but our client code expects ITarget
LegacyService legacyService = new LegacyService();

// Wrap the incompatible service with an adapter
ITarget adapter = new Adapter(legacyService);

// Now we can use the adapter through the target interface
Console.WriteLine(adapter.GetRequest());

Output

Adapter: Converted "Legacy data format" to compatible format

When to Use

  • When you need to use an existing class with an incompatible interface
  • When you want to reuse existing subclasses that lack certain common functionality

Decorator Pattern

The Decorator pattern lets you attach new behaviors to objects by placing them inside wrapper objects that contain the behaviors.

Implementation

csharp
// Component interface
public interface INotifier
{
void Send(string message);
}

// Concrete component
public class EmailNotifier : INotifier
{
public void Send(string message)
{
Console.WriteLine($"Sending email: {message}");
}
}

// Base decorator
public abstract class NotifierDecorator : INotifier
{
protected INotifier _wrappedNotifier;

public NotifierDecorator(INotifier notifier)
{
_wrappedNotifier = notifier;
}

public virtual void Send(string message)
{
_wrappedNotifier.Send(message);
}
}

// Concrete decorators
public class SlackDecorator : NotifierDecorator
{
public SlackDecorator(INotifier notifier) : base(notifier) { }

public override void Send(string message)
{
base.Send(message);
Console.WriteLine($"Sending Slack message: {message}");
}
}

public class SMSDecorator : NotifierDecorator
{
public SMSDecorator(INotifier notifier) : base(notifier) { }

public override void Send(string message)
{
base.Send(message);
Console.WriteLine($"Sending SMS: {message}");
}
}

Usage Example

csharp
// Create base notifier
INotifier notifier = new EmailNotifier();

// Add Slack notifications
notifier = new SlackDecorator(notifier);

// Add SMS notifications
notifier = new SMSDecorator(notifier);

// Send notification through all channels
notifier.Send("System alert!");

Output

Sending email: System alert!
Sending Slack message: System alert!
Sending SMS: System alert!

When to Use

  • When you need to add responsibilities to objects dynamically without affecting other objects
  • When extending functionality by subclassing is impractical
  • When you want to add behaviors that can be withdrawn later

Behavioral Design Patterns

Observer Pattern

The Observer pattern lets you define a subscription mechanism to notify multiple objects about events that happen to the object they're observing.

Implementation

csharp
// Interface for observers
public interface IObserver
{
void Update(string message);
}

// Concrete observers
public class EmailSubscriber : IObserver
{
private readonly string _name;

public EmailSubscriber(string name)
{
_name = name;
}

public void Update(string message)
{
Console.WriteLine($"Email to {_name}: {message}");
}
}

public class MobileAppSubscriber : IObserver
{
private readonly string _userId;

public MobileAppSubscriber(string userId)
{
_userId = userId;
}

public void Update(string message)
{
Console.WriteLine($"Mobile notification to {_userId}: {message}");
}
}

// Subject being observed
public class NewsPublisher
{
private readonly List<IObserver> _subscribers = new List<IObserver>();

public void Subscribe(IObserver observer)
{
_subscribers.Add(observer);
}

public void Unsubscribe(IObserver observer)
{
_subscribers.Remove(observer);
}

public void NotifySubscribers(string news)
{
foreach (var subscriber in _subscribers)
{
subscriber.Update(news);
}
}

public void PublishNews(string news)
{
Console.WriteLine($"Breaking news: {news}");
NotifySubscribers(news);
}
}

Usage Example

csharp
// Create publisher and subscribers
NewsPublisher publisher = new NewsPublisher();

EmailSubscriber john = new EmailSubscriber("John");
EmailSubscriber lisa = new EmailSubscriber("Lisa");
MobileAppSubscriber mobileUser = new MobileAppSubscriber("user123");

// Add subscribers
publisher.Subscribe(john);
publisher.Subscribe(lisa);
publisher.Subscribe(mobileUser);

// Publish news
publisher.PublishNews("New .NET version released!");

// Remove a subscriber
publisher.Unsubscribe(lisa);

// Publish another news item
publisher.PublishNews("C# adds new features!");

Output

Breaking news: New .NET version released!
Email to John: New .NET version released!
Email to Lisa: New .NET version released!
Mobile notification to user123: New .NET version released!
Breaking news: C# adds new features!
Email to John: C# adds new features!
Mobile notification to user123: C# adds new features!

When to Use

  • When changes to one object require changing others
  • When some objects need to observe others
  • To reduce coupling between objects that interact with each other

Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.

Implementation

csharp
// Strategy interface
public interface IPaymentStrategy
{
void ProcessPayment(decimal amount);
}

// Concrete strategies
public class CreditCardPayment : IPaymentStrategy
{
private readonly string _cardNumber;

public CreditCardPayment(string cardNumber)
{
_cardNumber = cardNumber;
}

public void ProcessPayment(decimal amount)
{
// In a real app, we'd process the payment through a payment gateway
Console.WriteLine($"Processing ${amount} credit card payment with card {_cardNumber.Substring(_cardNumber.Length - 4)}");
}
}

public class PayPalPayment : IPaymentStrategy
{
private readonly string _email;

public PayPalPayment(string email)
{
_email = email;
}

public void ProcessPayment(decimal amount)
{
Console.WriteLine($"Processing ${amount} PayPal payment with account {_email}");
}
}

// Context that uses a strategy
public class ShoppingCart
{
private IPaymentStrategy _paymentStrategy;
private readonly List<string> _items = new List<string>();

public void SetPaymentStrategy(IPaymentStrategy paymentStrategy)
{
_paymentStrategy = paymentStrategy;
}

public void AddItem(string item)
{
_items.Add(item);
}

public void Checkout()
{
decimal total = CalculateTotal();
Console.WriteLine($"Checking out cart with {_items.Count} items, total: ${total}");

if (_paymentStrategy == null)
{
throw new InvalidOperationException("Payment strategy not set");
}

_paymentStrategy.ProcessPayment(total);
}

private decimal CalculateTotal()
{
// Simplified: each item costs $10
return _items.Count * 10m;
}
}

Usage Example

csharp
ShoppingCart cart = new ShoppingCart();

// Add items to cart
cart.AddItem("Laptop");
cart.AddItem("Mouse");
cart.AddItem("Keyboard");

// Use credit card payment
cart.SetPaymentStrategy(new CreditCardPayment("4111111111111111"));
cart.Checkout();

// Create new cart
cart = new ShoppingCart();
cart.AddItem("Book");
cart.AddItem("Pen");

// Use PayPal payment
cart.SetPaymentStrategy(new PayPalPayment("[email protected]"));
cart.Checkout();

Output

Checking out cart with 3 items, total: $30
Processing $30 credit card payment with card 1111
Checking out cart with 2 items, total: $20
Processing $20 PayPal payment with account [email protected]

When to Use

  • When you want to define a family of algorithms or behaviors
  • When you need different variants of an algorithm
  • When you want to avoid conditional statements for algorithm selection

Real-World Example: Repository Pattern

The Repository pattern is widely used in .NET applications to abstract the data layer, making your application more maintainable and testable.

Implementation

csharp
// Domain model
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}

// Repository interface
public interface ICustomerRepository
{
Customer GetById(int id);
List<Customer> GetAll();
void Add(Customer customer);
void Update(Customer customer);
void Delete(int id);
}

// Concrete repository implementation
public class CustomerRepository : ICustomerRepository
{
private readonly List<Customer> _customers = new List<Customer>();

public Customer GetById(int id)
{
return _customers.FirstOrDefault(c => c.Id == id);
}

public List<Customer> GetAll()
{
return _customers.ToList();
}

public void Add(Customer customer)
{
if (_customers.Any(c => c.Id == customer.Id))
{
throw new ArgumentException("Customer with this ID already exists");
}

_customers.Add(customer);
}

public void Update(Customer customer)
{
int index = _customers.FindIndex(c => c.Id == customer.Id);
if (index >= 0)
{
_customers[index] = customer;
}
else
{
throw new ArgumentException("Customer not found");
}
}

public void Delete(int id)
{
_customers.RemoveAll(c => c.Id == id);
}
}

// Service that uses the repository
public class CustomerService
{
private readonly ICustomerRepository _customerRepository;

public CustomerService(ICustomerRepository customerRepository)
{
_customerRepository = customerRepository;
}

public void RegisterCustomer(string name, string email)
{
// Validate input
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(email))
{
throw new ArgumentException("Name and email are required");
}

// Create new customer with unique ID
var customer = new Customer
{
Id = GenerateUniqueId(),
Name = name,
Email = email
};

// Add to repository
_customerRepository.Add(customer);
Console.WriteLine($"Customer {name} registered successfully with ID {customer.Id}");
}

public void DisplayAllCustomers()
{
var customers = _customerRepository.GetAll();

if (customers.Count == 0)
{
Console.WriteLine("No customers found");
return;
}

Console.WriteLine("Customer list:");
foreach (var customer in customers)
{
Console.WriteLine($"ID: {customer.Id}, Name: {customer.Name}, Email: {customer.Email}");
}
}

private int GenerateUniqueId()
{
// Simple ID generation for demo purposes
Random random = new Random();
return random.Next(1000, 9999);
}
}

Usage Example

csharp
// Set up the repository and service
ICustomerRepository repository = new CustomerRepository();
CustomerService service = new CustomerService(repository);

// Register some customers
service.RegisterCustomer("John Smith", "[email protected]");
service.RegisterCustomer("Jane Doe", "[email protected]");
service.RegisterCustomer("Bob Johnson", "[email protected]");

// Display all customers
service.DisplayAllCustomers();

Output

Customer John Smith registered successfully with ID 1234
Customer Jane Doe registered successfully with ID 5678
Customer Bob Johnson registered successfully with ID 9012
Customer list:
ID: 1234, Name: John Smith, Email: [email protected]
ID: 5678, Name: Jane Doe, Email: [email protected]
ID: 9012, Name: Bob Johnson, Email: [email protected]

Benefits of Using the Repository Pattern

  1. Separation of Concerns: Isolates data access logic from business logic
  2. Improved Testability: Makes unit testing easier through abstraction
  3. Code Reusability: Repository logic can be reused across the application
  4. Flexibility: Changing the underlying data store doesn't affect business logic

Summary

Design patterns are essential tools for writing clean, maintainable, and scalable .NET applications. In this guide, we explored several key patterns:

  • Creational Patterns: Singleton and Factory Method
  • Structural Patterns: Adapter and Decorator
  • Behavioral Patterns: Observer and Strategy
  • Real-World Example: Repository Pattern

By understanding and applying these patterns in your .NET projects, you can:

  1. Solve common design problems efficiently
  2. Write more maintainable code
  3. Communicate more effectively with other developers
  4. Increase the flexibility and reusability of your code

Remember that patterns should be applied judiciously. Not every problem requires a design pattern, and applying patterns unnecessarily can lead to over-engineered code. Choose patterns based on your specific needs rather than trying to fit patterns everywhere.

Additional Resources

  1. Books:

    • "Design Patterns: Elements of Reusable Object-Oriented Software" by Gang of Four
    • "Head First Design Patterns" by Eric Freeman and Elisabeth Robson
  2. Online Resources:

Exercises

  1. Implement a Dependency Injection container that uses the Factory pattern.
  2. Create a logging system that uses the Decorator pattern to add formatting options.
  3. Implement an authentication system using the Strategy pattern for different authentication methods.
  4. Build a simple event system using the Observer pattern.
  5. Create a caching mechanism using the Proxy pattern.

By practicing with these exercises, you'll gain hands-on experience with design patterns in .NET development.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)