Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

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

ConceptKey PointCommon Mistake
Abstract vs InterfaceAbstract = shared code, Interface = contractUsing inheritance when composition fits better
Single ResponsibilityOne reason to changeGod classes that do everything
Open/ClosedExtend via new classes, not modifyingGiant switch statements
Liskov SubstitutionSubtypes must be substitutableSquare inheriting Rectangle
Interface SegregationSmall, focused interfacesFat interfaces with NotImplementedException
Dependency InversionDepend on abstractionsnew-ing up dependencies
DI LifetimesTransient, Scoped, SingletonCaptive dependencies

Next up: Modern C# Features - pattern matching, records, and Span/Memory for zero-allocation parsing.


Part 2 of the C# Interview Prep series.

← Previous

Modern C# Features: Pattern Matching, Records, and Spans

Next →

C# Fundamentals That Trip People Up