36. Design Patterns in C#
Level: Advanced
Goal: Learn proven solutions to common coding problems - used in production systems daily.
Design patterns are like recipes for solving recurring problems.
Examples:
- Singleton: Only one database connection in the app
- Repository: Hide database details from business logic
- Factory: Create student or teacher object based on type
- Observer: Notify multiple listeners when fees are paid
- Strategy: Use different grading algorithms per subject
- Singleton pattern - one instance across application
- Factory pattern - create objects without specifying exact class
- Repository pattern - abstract data access layer
- Observer pattern - notification/event system
- Strategy pattern - swap algorithms at runtime
- When to use each pattern
- Anti-patterns to avoid
- Real .NET examples (EF Core, ASP.NET Core)
Design Patterns Reference
| Pattern | Purpose | Problem Solved | Example | Real Usage |
|---|---|---|---|---|
| Singleton | One instance only, shared across app | Need global state, shared resource | Database connection pool, logger | Logging framework, configuration |
| Factory | Create objects without knowing exact class | Decouple object creation from usage | DocumentFactory.Create(type) | EF Core DbContext creation |
| Repository | Abstract data access, hide DB details | Decouple business logic from data layer | IStudentRepository interface | ASP.NET Core service layer |
| Observer | Notify multiple objects of state change | Loose coupling between objects | Event handler pattern with delegates | Button.Click, property changed notifications |
| Strategy | Swap algorithms at runtime | Multiple ways to do same thing | Payment processors (Cash, Online, Check) | Logging strategies, sorting algorithms |
| Decorator | Add behavior to object without modifying | Extend functionality without inheritance | Middleware in ASP.NET Core | Authentication, compression filters |
| Adapter | Make incompatible interfaces work together | Bridge different interfaces | Convert old API to new interface | Legacy code integration |
| Chain of Responsibility | Pass request along chain of handlers | Multiple handlers might process request | ASP.NET Core middleware pipeline | Approval workflows, validation chains |
Quick Definitions
- Design pattern - Reusable solution to common code problem
- Singleton - One instance of class, shared everywhere
- Factory - Method that creates objects (hides complexity)
- Repository - Abstract data access (database queries)
- Observer - Notify multiple listeners when state changes
- Strategy - Swap algorithms at runtime
- Dependency injection - Pass dependencies to constructor (loose coupling)
- Thread-safe - Safe for multiple threads simultaneously
- Lazy initialization - Create object only when first needed
- Abstraction - Hide implementation details behind interface
1. Singleton - One Instance Only
// Thread-safe Singleton using Lazy<T>
public sealed class SchoolConfiguration
{
private static readonly Lazy<SchoolConfiguration> _instance
= new(() => new SchoolConfiguration());
public static SchoolConfiguration Instance => _instance.Value;
public string SchoolName { get; private set; }
public string BoardName { get; private set; }
public int CurrentAcademicYear { get; private set; }
public decimal AnnualFees { get; private set; }
private SchoolConfiguration()
{
SchoolName = "NexCoding Academy";
BoardName = "CBSE";
CurrentAcademicYear = DateTime.Now.Year;
AnnualFees = 60000m;
}
}
// Usage - always same instance
var config = SchoolConfiguration.Instance;
var config2 = SchoolConfiguration.Instance;
Console.WriteLine(ReferenceEquals(config, config2)); // True
Console.WriteLine(config.SchoolName);
2. Factory - Create Objects Without Specifying Exact Class
public abstract class DocumentGenerator
{
public abstract string Generate(Student student);
public abstract string GetDocumentName();
}
public class ReportCardGenerator : DocumentGenerator
{
public override string Generate(Student s)
{
return $"REPORT CARD\nName: {s.Name}\nClass: {s.ClassName}\nGrade: {s.GetGrade()}";
}
public override string GetDocumentName()
{
return "Report Card";
}
}
public class TransferCertificateGenerator : DocumentGenerator
{
public override string Generate(Student s)
{
return $"TRANSFER CERTIFICATE\nThis certifies that {s.Name} of Class {s.ClassName} is leaving.";
}
public override string GetDocumentName()
{
return "Transfer Certificate";
}
}
public class BonafideGenerator : DocumentGenerator
{
public override string Generate(Student s)
{
return $"BONAFIDE CERTIFICATE\n{s.Name} is a bonafide student of Class {s.ClassName}.";
}
public override string GetDocumentName()
{
return "Bonafide Certificate";
}
}
// Factory
public static class DocumentFactory
{
public static DocumentGenerator Create(string documentType)
{
string type = documentType.ToLower();
switch (type)
{
case "report":
return new ReportCardGenerator();
case "transfer":
return new TransferCertificateGenerator();
case "bonafide":
return new BonafideGenerator();
default:
throw new ArgumentException($"Unknown document type: {documentType}");
}
}
}
// Usage
var generator = DocumentFactory.Create("report");
string doc = generator.Generate(student);
Console.WriteLine($"Generated: {generator.GetDocumentName()}");
Console.WriteLine(doc);
3. Repository - Abstract Data Access
public interface IStudentRepository
{
Task<Student?> GetByIdAsync(int id);
Task<Student?> GetByRollAsync(string roll);
Task<List<Student>> GetAllAsync();
Task<List<Student>> GetByClassAsync(string className);
Task<Student> AddAsync(Student student);
Task<Student> UpdateAsync(Student student);
Task<bool> DeleteAsync(int id);
Task<bool> ExistsAsync(int id);
}
// In-memory implementation (used in tests or before DB is set up)
public class InMemoryStudentRepository : IStudentRepository
{
private readonly List<Student> _store = new();
private int _nextId = 1;
public Task<Student?> GetByIdAsync(int id)
{
Student? result = _store.FirstOrDefault(s => s.Id == id);
return Task.FromResult(result);
}
public Task<Student?> GetByRollAsync(string roll)
{
Student? result = _store.FirstOrDefault(s => s.RollNumber == roll);
return Task.FromResult(result);
}
public Task<List<Student>> GetAllAsync()
{
List<Student> result = _store.ToList();
return Task.FromResult(result);
}
public Task<List<Student>> GetByClassAsync(string className)
{
List<Student> result = _store.Where(s => s.ClassName == className).ToList();
return Task.FromResult(result);
}
public Task<Student> AddAsync(Student student)
{
student.Id = _nextId++;
student.RollNumber = $"NCA-{DateTime.Now.Year}-{student.Id:D4}";
_store.Add(student);
return Task.FromResult(student);
}
public Task<Student> UpdateAsync(Student student)
{
int i = _store.FindIndex(s => s.Id == student.Id);
if (i >= 0) _store[i] = student;
return Task.FromResult(student);
}
public Task<bool> DeleteAsync(int id)
{
bool result = _store.RemoveAll(s => s.Id == id) > 0;
return Task.FromResult(result);
}
public Task<bool> ExistsAsync(int id)
{
bool result = _store.Any(s => s.Id == id);
return Task.FromResult(result);
}
}
// Service uses interface - doesn't care about implementation
public class StudentService
{
private readonly IStudentRepository _repo;
public StudentService(IStudentRepository repo)
{
_repo = repo;
}
public async Task<Student> EnrollAsync(string name, string className)
{
Student student = new Student { Name = name, ClassName = className };
return await _repo.AddAsync(student);
}
}
4. Observer - Notification System
Observer pattern = Events in C#. One object notifies many objects of state change.
public class FeeService
{
// Event - multiple subscribers can listen
public event EventHandler<FeeReceivedEventArgs>? FeeReceived;
public void RecordFeePayment(Student student, decimal amount)
{
student.FeesPaid += amount;
// Notify all subscribers
FeeReceived?.Invoke(this, new FeeReceivedEventArgs
{
StudentName = student.Name,
AmountPaid = amount
});
}
}
public class SmsService
{
private readonly FeeService _feeService;
public SmsService(FeeService feeService)
{
_feeService = feeService;
_feeService.FeeReceived += SendReceiptSms; // Subscribe
}
private void SendReceiptSms(object? sender, FeeReceivedEventArgs e)
{
Console.WriteLine($"SMS: Receipt sent to {e.StudentName} for Rs.{e.AmountPaid}");
}
}
// Usage
var feeService = new FeeService();
var smsService = new SmsService(feeService); // Subscriber
feeService.RecordFeePayment(student, 10000m); // SMS sent automatically
See Article 17 - Delegates and Events for full Observer pattern.
5. Strategy - Swap Algorithms at Runtime
Strategy pattern = Multiple ways to do same task. Pick one at runtime.
// Strategy interface
public interface IPaymentProcessor
{
Task<bool> ProcessAsync(Student student, decimal amount);
}
// Concrete strategies
public class CashPaymentProcessor : IPaymentProcessor
{
public Task<bool> ProcessAsync(Student student, decimal amount)
{
Console.WriteLine($"Cash payment of Rs.{amount} received from {student.Name}");
return Task.FromResult(true);
}
}
public class OnlinePaymentProcessor : IPaymentProcessor
{
public async Task<bool> ProcessAsync(Student student, decimal amount)
{
Console.WriteLine($"Processing online payment of Rs.{amount} for {student.Name}...");
await Task.Delay(2000); // Simulate API call
Console.WriteLine("Payment successful");
return true;
}
}
public class ChequePaymentProcessor : IPaymentProcessor
{
public Task<bool> ProcessAsync(Student student, decimal amount)
{
Console.WriteLine($"Cheque payment of Rs.{amount} deposited, will clear in 3 days");
return Task.FromResult(true);
}
}
// Context - uses strategy
public class FeePaymentService
{
private readonly IPaymentProcessor _processor;
public FeePaymentService(IPaymentProcessor processor)
{
_processor = processor; // Inject strategy
}
public async Task<bool> ProcessAsync(Student student, decimal amount)
{
bool result = await _processor.ProcessAsync(student, amount);
return result;
}
}
// Usage - swap strategies at runtime
var cashProcessor = new CashPaymentProcessor();
var onlineProcessor = new OnlinePaymentProcessor();
var feeService = new FeePaymentService(cashProcessor);
await feeService.ProcessAsync(student, 10000m);
// Switch strategy
feeService = new FeePaymentService(onlineProcessor);
await feeService.ProcessAsync(student, 10000m);
When You'll Use This in SMS
SMS uses design patterns throughout:
// Singleton - one database context per app
public sealed class SchoolDbContext : DbContext
{
private static readonly Lazy<SchoolDbContext> Instance =
new(() => new SchoolDbContext());
public static SchoolDbContext Current => Instance.Value;
}
// Factory - create different payment processors
public class PaymentProcessorFactory
{
public static IPaymentProcessor CreateProcessor(PaymentMode mode)
=> mode switch
{
PaymentMode.Cash => new CashPaymentProcessor(),
PaymentMode.Online => new OnlinePaymentProcessor(),
_ => throw new ArgumentException(nameof(mode))
};
}
// Repository - abstract database queries
public class StudentRepository : IRepository<Student>
{
public async Task<Student> GetByIdAsync(int id)
=> await _context.Students.FindAsync(id);
public async Task SaveAsync(Student entity)
=> await _context.SaveChangesAsync();
}
// Observer - notify when attendance is marked
_attendanceService.OnAttendanceMarked += (student, date) =>
{
_notificationService.SendToParent(student.ParentPhone);
};
Real impact: Without patterns = spaghetti code, hard to test, duplicate logic. With patterns = clean architecture, testable, reusable components.
Try This Now
- Implement Singleton for AppConfiguration (thread-safe with
Lazy<T>) - Create Factory for different exam types
- Build
Repository<Student>with CRUD operations - Add Observer pattern to Fee payment notifications
- Use Strategy pattern for different grading algorithms
Design patterns explained: Singleton, Factory, Repository, Observer, Strategy with SMS examples, when to use each, interview preparation. Video coming soon. Subscribe to NexCoding YouTube for updates.
Common Mistakes
Wrong Singleton created with static constructor (not thread-safe):
public class BadSingleton
{
private static BadSingleton? _instance;
public static BadSingleton Instance
{
get
{
if (_instance == null)
_instance = new BadSingleton(); // Race condition!
return _instance;
}
}
}
OK Use Lazy<T> for thread-safe singleton:
public class GoodSingleton
{
private static readonly Lazy<GoodSingleton> _instance
= new(() => new GoodSingleton());
public static GoodSingleton Instance => _instance.Value;
}
Wrong Factory method not handling new types (returning null):
public static DocumentGenerator Create(string type) => type switch
{
"report" => new ReportGenerator(),
"transfer" => new TransferGenerator(),
_ => null // Wrong Returns null, caller must check
};
OK Throw exception for unknown types:
public static DocumentGenerator Create(string type) => type switch
{
"report" => new ReportGenerator(),
"transfer" => new TransferGenerator(),
_ => throw new ArgumentException($"Unknown type: {type}")
};
Wrong Repository method returns concrete type (tight coupling):
public List<Student> GetAll() // Returns List, not IEnumerable
{
return _db.Students.ToList();
}
OK Return interface, allow implementation flexibility:
public IEnumerable<Student> GetAll() // Returns interface
{
return _db.Students;
}
Wrong Event subscriber never unsubscribes (memory leak):
public class Service
{
public Service(FeeService feeService)
{
feeService.FeeReceived += OnFeeReceived;
// Never unsubscribed - memory leak if Service destroyed
}
private void OnFeeReceived(object? s, EventArgs e) { }
}
OK Unsubscribe in Dispose:
public class Service : IDisposable
{
private readonly FeeService _feeService;
public Service(FeeService feeService)
{
_feeService = feeService;
_feeService.FeeReceived += OnFeeReceived;
}
public void Dispose()
{
_feeService.FeeReceived -= OnFeeReceived;
}
private void OnFeeReceived(object? s, EventArgs e) { }
}
Wrong Strategy object with state (multiple requests corrupt it):
public class StatefulProcessor : IPaymentProcessor
{
private decimal _lastAmount; // Shared state
public async Task<bool> ProcessAsync(Student s, decimal amount)
{
_lastAmount = amount; // Wrong Race condition with concurrent calls
await Task.Delay(100);
Console.WriteLine($"Processed {_lastAmount}"); // Wrong amount!
return true;
}
}
OK Stateless strategy:
public class StatelessProcessor : IPaymentProcessor
{
public async Task<bool> ProcessAsync(Student s, decimal amount)
{
await Task.Delay(100);
Console.WriteLine($"Processed {amount}"); // Safe
return true;
}
}
Best Practices
- Singleton: Use
Lazy<T>for thread safety - Automatic lazy initialization - Singleton: Make constructor private - Prevent external instantiation
- Factory: Throw for unknown types - Fail fast instead of returning null
- Repository: Return interfaces, not concrete types - Allow swapping implementations
- Repository: Async all the way - Use
async Task, notTask.FromResult - Observer: Always unsubscribe from events - Prevent memory leaks in long-lived objects
- Observer: Use weak events for long-lived publishers - Prevent memory leaks from subscribers
- Strategy: Keep strategy stateless - No instance variables, all behavior from parameters
- Strategy: Use dependency injection - Inject strategy, don't hardcode
- Don't overuse patterns - Not every class needs a pattern; simple code is better
- Document why pattern chosen - Help future developers understand rationale
- Test pattern implementations - Pattern code is critical, ensure high coverage
Singleton = only one instance exists across entire application lifetime.
Used for: shared resource (database connection, logger, configuration), global state that must be consistent.
var config1 = SchoolConfiguration.Instance;
var config2 = SchoolConfiguration.Instance;
Console.WriteLine(ReferenceEquals(config1, config2)); // True - same instance
But be careful: Singletons make testing hard (global state). Prefer dependency injection.
Use when: truly need global state. Avoid if possible.
Factory = create objects without knowing exact class at compile time.
Problem: Tight coupling to concrete classes.
// Without factory - coupled to concrete class
var doc = new ReportCardGenerator().Generate(student);
// With factory - decoupled
var doc = DocumentFactory.Create("report").Generate(student);
If you need new document type, factory centralizes change. Add one case statement, everywhere else unchanged.
Repository = abstracts data access. Business logic never talks to database directly.
Benefits:
- Swap database (SQL Server -> PostgreSQL) - change only repository
- Testing - use in-memory repository, avoid test database
- Caching - add to repository, whole app benefits
- Audit - add logging in repository, track all DB access
public interface IStudentRepository
{
Task<Student> GetByIdAsync(int id); // Business logic doesn't know it's SQL
Task<Student> AddAsync(Student student);
}
// In production - SQL database
public class SqlStudentRepository : IStudentRepository { }
// In tests - in-memory
public class InMemoryStudentRepository : IStudentRepository { }
ASP.NET Core relies on this pattern - inject IStudentRepository, get whatever implementation.
Factory: Create different objects of same type.
Strategy: Use different algorithms to do same task.
// Factory - create different documents
DocumentFactory.Create("report") // ReportGenerator
DocumentFactory.Create("transfer") // TransferGenerator
// Strategy - different payment algorithms
new FeePaymentService(new CashPaymentProcessor());
new FeePaymentService(new OnlinePaymentProcessor());
Factory picks which object to instantiate. Strategy picks how to execute.
Observer = notify multiple objects when something happens.
C# events = built-in observer pattern.
// Event = observer pattern
public event EventHandler<FeeReceivedEventArgs> FeeReceived;
// FeeService is publisher
// SmsService, EmailService, AuditLog are subscribers
feeService.FeeReceived += smsService.Send;
feeService.FeeReceived += emailService.Send;
feeService.FeeReceived += auditLog.Log;
feeService.RecordPayment(student, 10000m); // All subscribers notified
Every subscriber gets notified. Loose coupling - publisher doesn't know subscribers.
Using static field without Lazy<T>. Not thread-safe:
public static SchoolConfiguration Instance
{
get
{
if (_instance == null)
_instance = new SchoolConfiguration(); // Two threads both create instance!
return _instance;
}
}
Two threads race to check _instance == null, both pass, both create instances. Result: multiple instances.
Solution: Lazy<T> handles locking automatically.
private static readonly Lazy<SchoolConfiguration> _instance
= new(() => new SchoolConfiguration());
public static SchoolConfiguration Instance => _instance.Value; // Thread-safe
Or: Use static constructor (also thread-safe by CLR guarantee).
Use ChatGPT, Claude, or Copilot to go deeper on C# design patterns Singleton Factory Repository. Try these prompts:
"What is the Singleton pattern in C# and when should I use it?""How is the Factory pattern different from just using new keyword?""Why is the Repository pattern so common in .NET backend projects?""What design patterns does ASP.NET Core itself use internally?"
💡 Tip: After reading this article, paste your own code into AI and ask "What could go wrong here and why?" — fastest way to find edge cases and deepen understanding.