Design patterns in .NET and C# are proven solutions for recurring problems in software development. This guide covers essential patterns like Repository, Factory, Singleton and others, with practical examples you can implement immediately. Learn how to structure cleaner, maintainable, and scalable code using .NET community best practices.
Have you ever wondered why some .NET developers can write code that seems like magic, while others struggle with confusing architectures and endless bugs? The answer lies in design patterns in .NET and C#, secrets that separate beginner developers from true software architects.
If you’re tired of spaghetti code, hard-to-maintain projects, and wasted hours debugging problems that could have been avoided, this guide will completely change the way you program in C#.
Why Design Patterns Are Crucial in the .NET Ecosystem
The .NET framework was built with design patterns in its DNA. From Entity Framework to ASP.NET Core, Microsoft has incorporated these practices into its core tools. Mastering these patterns isn’t just about writing “pretty” code, it’s about creating robust, testable, and scalable systems.
The Proven Benefits of Design Patterns
- 70% reduction in maintenance time according to Microsoft studies
- Code that’s 3x more readable for new team members
- Facilitates unit testing and TDD implementation
- Accelerates development of new features
Repository Pattern: The Pattern Every .NET Dev Should Mastering
The Repository Pattern is probably the most widely used pattern in modern .NET applications. It creates an abstraction layer between your business logic and data access.
Practical Implementation of the Repository Pattern
// Repository interface
public interface IUserRepository
{
Task<User> GetByIdAsync(int id);
Task<IEnumerable<User>> GetAllAsync();
Task<User> CreateAsync(User user);
Task UpdateAsync(User user);
Task DeleteAsync(int id);
}
// Concrete implementation
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public UserRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<User> GetByIdAsync(int id)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Id == id);
}
public async Task<IEnumerable<User>> GetAllAsync()
{
return await _context.Users
.ToListAsync();
}
public async Task<User> CreateAsync(User user)
{
_context.Users.Add(user);
await _context.SaveChangesAsync();
return user;
}
// Other methods...
}
Advantages of the Repository Pattern in .NET
- Testability: Easy creation of mocks for unit testing
- Flexibility: Switch ORMs without impacting business logic
- Organization: Centralized data access code
- Reusability: Common methods available throughout the application
Factory Pattern: Creating Objects Intelligently
The Factory Pattern solves the problem of creating complex objects, especially when the instantiation logic depends on specific conditions.
Factory Pattern in Practice
// Enum for notification types
public enum NotificationType
{
Email,
SMS,
Push
}
// Common interface
public interface INotificationService
{
Task SendAsync(string message, string recipient);
}
// Specific implementations
public class EmailNotificationService : INotificationService
{
public async Task SendAsync(string message, string recipient)
{
// Email sending logic
Console.WriteLine($"Email sent to {recipient}: {message}");
}
}
public class SmsNotificationService : INotificationService
{
public async Task SendAsync(string message, string recipient)
{
// SMS sending logic
Console.WriteLine($"SMS sent to {recipient}: {message}");
}
}
// Factory
public class NotificationFactory
{
public static INotificationService Create(NotificationType type)
{
return type switch
{
NotificationType.Email => new EmailNotificationService(),
NotificationType.SMS => new SmsNotificationService(),
NotificationType.Push => new PushNotificationService(),
_ => throw new ArgumentException("Invalid notification type")
};
}
}
// Usage
var emailService = NotificationFactory.Create(NotificationType.Email);
await emailService.SendAsync("Hi!", "user@email.com");
Singleton Pattern: When One Instance Is Enough
The Singleton Pattern ensures that a class has only one instance throughout the entire application lifecycle. In .NET, this is especially useful for configuration services, caching, and logging.
Thread-Safe Singleton Implementation
public sealed class ConfigurationManager
{
private static readonly object _lock = new object();
private static ConfigurationManager _instance;
private readonly Dictionary<string, string> _settings;
private ConfigurationManager()
{
_settings = new Dictionary<string, string>();
LoadConfiguration();
}
public static ConfigurationManager Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
_instance = new ConfigurationManager();
}
}
return _instance;
}
}
public string GetSetting(string key)
{
return _settings.TryGetValue(key, out string value) ? value : null;
}
private void LoadConfiguration()
{
// Configuration loading logic
_settings.Add("DatabaseConnection", "Server=localhost;Database=MyApp");
_settings.Add("ApiKey", "your-api-key-here");
}
}
// Usage
var config = ConfigurationManager.Instance;
string connectionString = config.GetSetting("DatabaseConnection");
Singleton with Dependency Injection
In .NET Core/5+, prefer registering as Singleton in the container:
// Startup.cs or Program.cs
services.AddSingleton<IConfigurationManager, ConfigurationManager>();
Observer Pattern: Elegant Notifications in C#
The Observer Pattern allows objects to be automatically notified about state changes. In .NET, this is naturally implemented through events.
Implementation with Events
// Publisher
public class OrderProcessor
{
public event EventHandler<OrderProcessedEventArgs> OrderProcessed;
public async Task ProcessOrderAsync(Order order)
{
// Processing logic
await Task.Delay(1000); // Simulates processing
// Notifies observers
OnOrderProcessed(new OrderProcessedEventArgs(order));
}
protected virtual void OnOrderProcessed(OrderProcessedEventArgs e)
{
OrderProcessed?.Invoke(this, e);
}
}
// Event Args
public class OrderProcessedEventArgs : EventArgs
{
public Order Order { get; }
public OrderProcessedEventArgs(Order order)
{
Order = order;
}
}
// Observers
public class EmailNotifier
{
public void Subscribe(OrderProcessor processor)
{
processor.OrderProcessed += OnOrderProcessed;
}
private void OnOrderProcessed(object sender, OrderProcessedEventArgs e)
{
Console.WriteLine($"Email sent for order #{e.Order.Id}");
}
}
public class InventoryUpdater
{
public void Subscribe(OrderProcessor processor)
{
processor.OrderProcessed += OnOrderProcessed;
}
private void OnOrderProcessed(object sender, OrderProcessedEventArgs e)
{
Console.WriteLine($"Stock updated for order #{e.Order.Id}");
}
}
Strategy Pattern: Interchangeable Algorithms
The Strategy Pattern allows you to swap algorithms at runtime, making it perfect for scenarios where you have different ways to execute a task.
Practical Example: Discount System
// Strategy interface
public interface IDiscountStrategy
{
decimal ApplyDiscount(decimal originalPrice);
}
// Concrete strategies
public class StudentDiscountStrategy : IDiscountStrategy
{
public decimal ApplyDiscount(decimal originalPrice)
{
return originalPrice * 0.9m; // 10% discount
}
}
public class VipDiscountStrategy : IDiscountStrategy
{
public decimal ApplyDiscount(decimal originalPrice)
{
return originalPrice * 0.8m; // 20% discount
}
}
public class NoDiscountStrategy : IDiscountStrategy
{
public decimal ApplyDiscount(decimal originalPrice)
{
return originalPrice;
}
}
// Context
public class PriceCalculator
{
private IDiscountStrategy _discountStrategy;
public PriceCalculator(IDiscountStrategy discountStrategy)
{
_discountStrategy = discountStrategy;
}
public void SetStrategy(IDiscountStrategy strategy)
{
_discountStrategy = strategy;
}
public decimal CalculatePrice(decimal originalPrice)
{
return _discountStrategy.ApplyDiscount(originalPrice);
}
}
// Usage
var calculator = new PriceCalculator(new StudentDiscountStrategy());
decimal studentPrice = calculator.CalculatePrice(100m); // 90
calculator.SetStrategy(new VipDiscountStrategy());
decimal vipPrice = calculator.CalculatePrice(100m); // 80
Decorator Pattern: Extending Functionality
The Decorator Pattern allow you to add responsibilities to objects dynamically, without modifying their original structure.
Implementation for Logging System
// Base interface
public interface IDataService
{
Task<string> GetDataAsync(string id);
}
// Base implementation
public class BasicDataService : IDataService
{
public async Task<string> GetDataAsync(string id)
{
await Task.Delay(100); // Simulates operation
return $"Data for {id}";
}
}
// Base decorator
public abstract class DataServiceDecorator : IDataService
{
protected readonly IDataService _dataService;
protected DataServiceDecorator(IDataService dataService)
{
_dataService = dataService;
}
public virtual async Task<string> GetDataAsync(string id)
{
return await _dataService.GetDataAsync(id);
}
}
// Concrete decorators
public class LoggingDecorator : DataServiceDecorator
{
public LoggingDecorator(IDataService dataService) : base(dataService) { }
public override async Task<string> GetDataAsync(string id)
{
Console.WriteLine($"Fetching data for ID: {id}");
var result = await base.GetDataAsync(id);
Console.WriteLine($"Data retrieved: {result}");
return result;
}
}
public class CachingDecorator : DataServiceDecorator
{
private readonly Dictionary<string, string> _cache = new();
public CachingDecorator(IDataService dataService) : base(dataService) { }
public override async Task<string> GetDataAsync(string id)
{
if (_cache.ContainsKey(id))
{
Console.WriteLine($"Cache hit for ID: {id}");
return _cache[id];
}
var result = await base.GetDataAsync(id);
_cache[id] = result;
return result;
}
}
// Combined usage
IDataService service = new BasicDataService();
service = new CachingDecorator(service);
service = new LoggingDecorator(service);
await service.GetDataAsync("123"); // With log and cache
Command Pattern: Actions as Objects
The Command Pattern encapsulates requests as objects, allowing you to parameterize clients with different requests, queue operations, and implement undo/redo functionality.
// Command interface
public interface ICommand
{
Task ExecuteAsync();
Task UndoAsync();
}
// Receiver
public class Document
{
private StringBuilder _content = new StringBuilder();
public void AddText(string text)
{
_content.Append(text);
}
public void RemoveText(int length)
{
if (_content.Length >= length)
_content.Remove(_content.Length - length, length);
}
public string GetContent() => _content.ToString();
}
// Concrete command
public class AddTextCommand : ICommand
{
private readonly Document _document;
private readonly string _text;
public AddTextCommand(Document document, string text)
{
_document = document;
_text = text;
}
public async Task ExecuteAsync()
{
_document.AddText(_text);
await Task.CompletedTask;
}
public async Task UndoAsync()
{
_document.RemoveText(_text.Length);
await Task.CompletedTask;
}
}
// Invoker
public class DocumentEditor
{
private readonly Stack<ICommand> _commandHistory = new();
public async Task ExecuteCommandAsync(ICommand command)
{
await command.ExecuteAsync();
_commandHistory.Push(command);
}
public async Task UndoAsync()
{
if (_commandHistory.Count > 0)
{
var command = _commandHistory.Pop();
await command.UndoAsync();
}
}
}
Best Practices and Advanced Tips
1. Use Dependency Injection with Patterns
// Program.cs
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<INotificationService, EmailNotificationService>();
builder.Services.AddSingleton<IConfigurationManager, ConfigurationManager>();
2. Combine Patterns When Necessary
// Repository + Unit of Work + Factory
public class OrderService
{
private readonly IUnitOfWork _unitOfWork;
private readonly INotificationFactory _notificationFactory;
public OrderService(IUnitOfWork unitOfWork, INotificationFactory notificationFactory)
{
_unitOfWork = unitOfWork;
_notificationFactory = notificationFactory;
}
public async Task ProcessOrderAsync(Order order)
{
// Repository pattern through Unit of Work
await _unitOfWork.Orders.CreateAsync(order);
await _unitOfWork.SaveChangesAsync();
// Factory pattern to create notification service
var notificationService = _notificationFactory.Create(order.NotificationType);
await notificationService.SendAsync("Order processed!", order.CustomerEmail);
}
}
3. Implement Patterns with Generics
public interface IRepository<T> where T : class
{
Task<T> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<T> CreateAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(int id);
}
public class Repository<T> : IRepository<T> where T : class
{
protected readonly DbContext _context;
protected readonly DbSet<T> _dbSet;
public Repository(DbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
// Method implementation...
}
When NOT to Use Design Patterns
It’s important to know when not to apply patterns:
- Over-engineering: Don’t use complex patterns for simple problems
- Critical performance: Some patterns add overhead
- Inexperienced team: Introduce patterns gradually
- Small projects: KISS (Keep It Simple, Stupid) might be better
Tools and Resources for Mastering Patterns
Visual Studio Extensions
- CodeMaid: Organizes and cleans code automatically
- SonarLint: Detects code smells and suggests improvements
- ReSharper: Advanced analysis and refactoring
Useful Libraries
- MediatR: Imprements Mediator pattern
- AutoMapper: Simplifies object mapping
- Polly: Resilience patterns (Circuit Breaker, Retry)
Key Takeaways and Next Steps
Design patterns in .NET and C# aren’t just academic theory, they’re practical tools that solve real everyday problems. Start by implementing one pattern at a time:
- Week 1: Implement Repository pattern in an existing project
- Week 2: Add Factory pattern for service creation
- Week 3: Use Strategy pattern for variable algorithms
- Week 4: Combine patterns into a cohesive architecture
Remember: The goal isn’t to use every possible pattern, but to choose the most suitable ones for each situation. Clean, maintainable code is worth more than an overly complex architecture.
With these patterns in your arsenal, you’ll be ready to create robust, scalable, and maintainable .NET applications. The next step is to practice, choose a project and start refactoring using the patterns you learned today.
Reference Links:
- Microsoft Docs – Design Patterns – https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines
- Gang of Four Design Patterns – https://refactoring.guru/design-patterns
- Clean Architecture by Robert Martin – https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html


