Object-Oriented Design in C#: SOLID and Beyond
February 5, 2025 · 8 min read
C#, .NET, SOLID, Interview Prep
Introduction
OOP questions are staples of C# interviews. But interviewers aren't looking for textbook definitions - they want to see that you can apply these concepts to real code. This post covers the patterns and principles that separate senior developers from juniors.
Abstract Classes vs Interfaces — The Real Difference
TL;DR
Interfaces define contracts (what). Abstract classes define partial implementations (what + some how). C# 8+ blurred this with default interface methods.
The Real Explanation
// Interface - pure contract
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessAsync(Payment payment);
Task<RefundResult> RefundAsync(string transactionId);
// C# 8+ default implementation
bool SupportsRecurring => false;
}
// Abstract class - shared implementation
public abstract class PaymentProcessorBase : IPaymentProcessor
{
protected readonly ILogger _logger;
protected PaymentProcessorBase(ILogger logger)
{
_logger = logger;
}
public abstract Task<PaymentResult> ProcessAsync(Payment payment);
public abstract Task<RefundResult> RefundAsync(string transactionId);
// Shared implementation
protected async Task<T> WithRetry<T>(Func<Task<T>> operation, int maxRetries = 3)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
return await operation();
}
catch (TransientException) when (i < maxRetries - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)));
}
}
throw new MaxRetriesExceededException();
}
}
// Concrete implementation
public class StripePaymentProcessor : PaymentProcessorBase
{
public StripePaymentProcessor(ILogger<StripePaymentProcessor> logger)
: base(logger) { }
public override async Task<PaymentResult> ProcessAsync(Payment payment)
{
return await WithRetry(async () =>
{
_logger.LogInformation("Processing via Stripe");
// Stripe-specific implementation
});
}
}
This pattern has served me well across multiple payment integrations - the base class handles the boring retry/logging logic while each processor focuses on its specific API.
The Gotcha
// Diamond problem with default interface methods
public interface IA
{
void Method() => Console.WriteLine("A");
}
public interface IB : IA
{
void IA.Method() => Console.WriteLine("B");
}
public interface IC : IA
{
void IA.Method() => Console.WriteLine("C");
}
// Which one wins?
public class MyClass : IB, IC
{
// Compiler error! Must explicitly implement
void IA.Method() => Console.WriteLine("MyClass");
}
Interview Tip
Don't just recite the differences. Give a real example like the payment processor pattern. Mention default interface methods but caution that they're best used sparingly (backwards compatibility, not design).
SOLID Principles — With Actual C# Examples
TL;DR
SOLID isn't academic nonsense - it's how you write code that doesn't become a nightmare to maintain.
S — Single Responsibility
// Bad: User class does too much
public class User
{
public string Email { get; set; }
public void Save() { /* database logic */ }
public void SendWelcomeEmail() { /* email logic */ }
public bool ValidatePassword(string pwd) { /* validation logic */ }
}
// Good: Separate concerns
public class User
{
public string Email { get; set; }
public string PasswordHash { get; set; }
}
public class UserRepository
{
public Task SaveAsync(User user) { /* database logic */ }
}
public class UserEmailService
{
public Task SendWelcomeEmailAsync(User user) { /* email logic */ }
}
public class PasswordValidator
{
public bool Validate(string password) { /* validation logic */ }
}
At one company, I inherited a Customer class that was 3,000 lines with 50+ methods. It handled persistence, validation, email notifications, PDF generation, and even some reporting. Every change broke something else. Splitting it into focused classes was a two-month project but reduced our bug rate by 60%.
O — Open/Closed
// Bad: Modifying existing code for new payment types
public decimal CalculateDiscount(string paymentType, decimal amount)
{
switch (paymentType)
{
case "CreditCard": return amount * 0.02m;
case "PayPal": return amount * 0.03m;
case "Crypto": return amount * 0.01m; // Had to modify!
default: return 0;
}
}
// Good: Open for extension, closed for modification
public interface IDiscountStrategy
{
decimal Calculate(decimal amount);
}
public class CreditCardDiscount : IDiscountStrategy
{
public decimal Calculate(decimal amount) => amount * 0.02m;
}
public class CryptoDiscount : IDiscountStrategy
{
public decimal Calculate(decimal amount) => amount * 0.01m;
}
L — Liskov Substitution
// Bad: Square is not substitutable for Rectangle
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
public class Square : Rectangle
{
public override int Width
{
set { base.Width = base.Height = value; }
}
}
// Code that breaks:
Rectangle rect = new Square();
rect.Width = 5;
rect.Height = 10;
Console.WriteLine(rect.Area); // Expected 50, got 100!
// Good: Don't force inheritance where it doesn't fit
public interface IShape
{
int Area { get; }
}
public class Rectangle : IShape
{
public int Width { get; set; }
public int Height { get; set; }
public int Area => Width * Height;
}
public class Square : IShape
{
public int Side { get; set; }
public int Area => Side * Side;
}
I — Interface Segregation
// Bad: Fat interface
public interface IWorker
{
void Work();
void Eat();
void Sleep();
}
public class Robot : IWorker
{
public void Work() { /* ok */ }
public void Eat() { throw new NotImplementedException(); }
public void Sleep() { throw new NotImplementedException(); }
}
// Good: Segregated interfaces
public interface IWorkable { void Work(); }
public interface IFeedable { void Eat(); }
public class Human : IWorkable, IFeedable
{
public void Work() { }
public void Eat() { }
}
public class Robot : IWorkable
{
public void Work() { }
}
D — Dependency Inversion
// Bad: High-level depends on low-level
public class OrderService
{
private readonly SqlOrderRepository _repository = new();
public void CreateOrder(Order order)
{
_repository.Save(order); // Tightly coupled to SQL
}
}
// Good: Both depend on abstraction
public interface IOrderRepository
{
Task SaveAsync(Order order);
}
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
public async Task CreateOrderAsync(Order order)
{
await _repository.SaveAsync(order);
}
}
// Now you can swap implementations
services.AddScoped<IOrderRepository, SqlOrderRepository>(); // Production
services.AddScoped<IOrderRepository, InMemoryOrderRepository>(); // Testing
Interview Tip
Don't recite definitions. Give one concrete example per principle and explain why it matters. The Liskov Square/Rectangle example is classic but shows you understand the subtlety.
Dependency Injection — Beyond the Basics
TL;DR
Master the three lifetimes, know when constructor injection becomes unwieldy, and understand the service locator anti-pattern.
The Real Explanation
// Three lifetimes
services.AddTransient<IEmailSender, SmtpEmailSender>(); // New instance every time
services.AddScoped<IUserContext, HttpUserContext>(); // One per request
services.AddSingleton<ICache, RedisCache>(); // One for app lifetime
// Registration patterns
services.AddTransient<IService, Service>();
services.AddTransient<Service>();
services.AddTransient(typeof(IRepository<>), typeof(Repository<>)); // Open generics
The Gotcha
This is a subtle bug that I've seen in production multiple times:
// Captive dependency - singleton holds scoped
public class MySingleton // Lives forever
{
private readonly IScopedService _scoped; // Should die per request!
public MySingleton(IScopedService scoped)
{
_scoped = scoped; // This scoped instance never gets disposed
}
}
// Fix: Inject IServiceScopeFactory for singletons
public class MySingleton
{
private readonly IServiceScopeFactory _scopeFactory;
public MySingleton(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task DoWorkAsync()
{
using var scope = _scopeFactory.CreateScope();
var scoped = scope.ServiceProvider.GetRequiredService<IScopedService>();
await scoped.DoSomethingAsync();
}
}
Constructor Explosion
When you have 10+ constructor parameters, something is wrong. It's usually a sign that the class violates SRP.
// When you have 10+ constructor parameters, smell test failed
public class OrderService
{
public OrderService(
IOrderRepository repo,
ICustomerRepository customers,
IProductRepository products,
IInventoryService inventory,
IPricingService pricing,
ITaxCalculator tax,
IShippingCalculator shipping,
IEmailSender email,
ILogger<OrderService> logger,
IOptions<OrderSettings> settings) // Too many!
{ }
}
// Solutions:
// 1. Split the class (SRP violation)
// 2. Facade for related services
// 3. Use IOptions pattern for configuration
Interview Tip
The captive dependency issue is a great advanced topic. Also know that IServiceProvider injection (service locator) is usually an anti-pattern except in factory scenarios.
Summary
| Concept | Key Point | Common Mistake |
|---|---|---|
| Abstract vs Interface | Abstract = shared code, Interface = contract | Using inheritance when composition fits better |
| Single Responsibility | One reason to change | God classes that do everything |
| Open/Closed | Extend via new classes, not modifying | Giant switch statements |
| Liskov Substitution | Subtypes must be substitutable | Square inheriting Rectangle |
| Interface Segregation | Small, focused interfaces | Fat interfaces with NotImplementedException |
| Dependency Inversion | Depend on abstractions | new-ing up dependencies |
| DI Lifetimes | Transient, Scoped, Singleton | Captive dependencies |
Next up: Modern C# Features - pattern matching, records, and Span/Memory for zero-allocation parsing.
Part 2 of the C# Interview Prep series.