Skip to main content

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
ℹ️ What You'll Learn
  • 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

PatternPurposeProblem SolvedExampleReal Usage
SingletonOne instance only, shared across appNeed global state, shared resourceDatabase connection pool, loggerLogging framework, configuration
FactoryCreate objects without knowing exact classDecouple object creation from usageDocumentFactory.Create(type)EF Core DbContext creation
RepositoryAbstract data access, hide DB detailsDecouple business logic from data layerIStudentRepository interfaceASP.NET Core service layer
ObserverNotify multiple objects of state changeLoose coupling between objectsEvent handler pattern with delegatesButton.Click, property changed notifications
StrategySwap algorithms at runtimeMultiple ways to do same thingPayment processors (Cash, Online, Check)Logging strategies, sorting algorithms
DecoratorAdd behavior to object without modifyingExtend functionality without inheritanceMiddleware in ASP.NET CoreAuthentication, compression filters
AdapterMake incompatible interfaces work togetherBridge different interfacesConvert old API to new interfaceLegacy code integration
Chain of ResponsibilityPass request along chain of handlersMultiple handlers might process requestASP.NET Core middleware pipelineApproval 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

  1. Implement Singleton for AppConfiguration (thread-safe with Lazy<T>)
  2. Create Factory for different exam types
  3. Build Repository<Student> with CRUD operations
  4. Add Observer pattern to Fee payment notifications
  5. Use Strategy pattern for different grading algorithms

ℹ️ Video Tutorial

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

  1. Singleton: Use Lazy<T> for thread safety - Automatic lazy initialization
  2. Singleton: Make constructor private - Prevent external instantiation
  3. Factory: Throw for unknown types - Fail fast instead of returning null
  4. Repository: Return interfaces, not concrete types - Allow swapping implementations
  5. Repository: Async all the way - Use async Task, not Task.FromResult
  6. Observer: Always unsubscribe from events - Prevent memory leaks in long-lived objects
  7. Observer: Use weak events for long-lived publishers - Prevent memory leaks from subscribers
  8. Strategy: Keep strategy stateless - No instance variables, all behavior from parameters
  9. Strategy: Use dependency injection - Inject strategy, don't hardcode
  10. Don't overuse patterns - Not every class needs a pattern; simple code is better
  11. Document why pattern chosen - Help future developers understand rationale
  12. Test pattern implementations - Pattern code is critical, ensure high coverage

🎯 Q1: What is the Singleton pattern and when should I use it?

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.

🎯 Q2: What does Factory pattern solve?

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.

🎯 Q3: Why is Repository pattern so common in .NET?

Repository = abstracts data access. Business logic never talks to database directly.

Benefits:

  1. Swap database (SQL Server -> PostgreSQL) - change only repository
  2. Testing - use in-memory repository, avoid test database
  3. Caching - add to repository, whole app benefits
  4. 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.

🎯 Q4: What's the difference between Strategy and Factory patterns?

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.

🎯 Q5: How does Observer pattern relate to events in C#?

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.

🎯 Q6: What's the most common mistake with Singleton pattern?

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 AI to Learn Faster

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.

Next Article

Ref, Out, In Keywords ->

nexcoding.in