Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Guided course · Architecture and Design in .NET

    Design Patterns in C#: The .NET Interview Guide: back to the course

    Module 1 · Foundations: Patterns, SOLID and Judgment · Lesson 2 of 20

    SOLID in Modern C#: The Five Principles as the Foundation for Every Pattern, With a Real Refactor

    Learning outcome

    You can state each SOLID principle precisely, spot its violation in real C# code, refactor a "god" service into small collaborators with constructor injection, explain the Liskov substitution trap in IList<T> versus IReadOnlyList<T>, and connect each principle to the patterns that implement it (Strategy and Decorator for Open/Closed, Adapter for Dependency Inversion, and so on).

    Intuition

    SOLID is not five rules to obey; it is five ways to keep the cost of change low. Each principle answers one question:

    • Single Responsibility: how many reasons does this class have to change?
    • Open/Closed: can I add a variant without editing working code?
    • Liskov Substitution: can every implementation be used wherever its abstraction is expected, without surprises?
    • Interface Segregation: does each client depend only on what it uses?
    • Dependency Inversion: does the policy depend on abstractions that it owns, rather than on infrastructure details?

    Most design patterns are concrete recipes for one or more of these principles. If you understand SOLID, patterns stop being a list to memorise.

    flowchart LR
        SRP["S: one reason to change"] --> Facade["Facade, Mediator, Command handlers"]
        OCP["O: extend without editing"] --> Strat["Strategy, Decorator, Chain"]
        LSP["L: substitutable subtypes"] --> Tmpl["Template Method, State"]
        ISP["I: small role interfaces"] --> Adp["Adapter, Proxy"]
        DIP["D: depend on abstractions"] --> Fac["Factory, DI, Adapter"]

    Deep dive

    • Single Responsibility (SRP). Robert C. Martin's wording is "a module should be responsible to one, and only one, actor". It is about reasons to change, not about "doing one thing". Pricing rules change when marketing asks; storage changes when the DBA asks; notifications change when the product team asks. One class serving three actors gets three kinds of change requests and three kinds of merge conflicts.
    • Open/Closed (OCP). "Open for extension, closed for modification." In C# this usually means: the code that varies sits behind an interface, and new behaviour arrives as a new class registered in DI. Do not over-apply it: you cannot make code closed against every change, only against the kinds of change you predict or have already seen.
    • Liskov Substitution (LSP). A subtype must honour the contract of its base type: preconditions cannot be strengthened, postconditions cannot be weakened, invariants must hold, and no new exceptions should surprise callers. The BCL has a capability-based contract: ReadOnlyCollection<T> implements IList<T>, reports IsReadOnly, and throws the documented NotSupportedException from Add. This is not a violation of that documented contract; callers cannot assume every IList<T> is writable. The modern fix is the narrower interface IReadOnlyList<T> (added in .NET Framework 4.5).
    • Interface Segregation (ISP). Clients should not be forced to depend on methods they do not use. Prefer IOrderReader and IOrderWriter (or one interface per use case) over a 30-method IOrderRepository. In the BCL, IReadOnlyCollection<T>, IReadOnlyList<T> and IReadOnlyDictionary<TKey,TValue> are examples of segregated interfaces.
    • Dependency Inversion (DIP). "High-level modules should not depend on low-level modules; both should depend on abstractions." The important detail interviewers probe: the abstraction is owned by the high-level policy. IOrderNotifier belongs to the ordering domain; an SMTP adapter implements it. If the interface mirrors the SMTP library's API, you have only moved the dependency, not inverted it.
    • DIP is not DI. Dependency Injection is a technique (pass dependencies in, usually via the constructor). Inversion of Control is the broader idea (a framework calls you). Dependency Inversion is a design principle about the direction of source-code dependencies. You can do DI and still violate DIP.
    • Modern C# helps:
      • primary constructors (C# 12) remove constructor-injection boilerplate;
      • records give value semantics for messages and DTOs;
      • ArgumentOutOfRangeException.ThrowIfNegativeOrZero (.NET 8) keeps guard clauses short;
      • collection expressions ([a, b]) keep composition roots readable.

    Before: the problem in code

    One method validates, prices, stores and notifies. It breaks SRP (three actors), OCP (a new customer tier edits the method) and DIP (it writes to a file and the console directly, so it cannot be tested without side effects).

    C#
    namespace Before;
    
    // One class that validates, prices, saves and emails. Every new rule edits this file.
    public sealed class OrderService
    {
        public decimal PlaceOrder(string customerType, decimal subtotal, string email)
        {
            if (subtotal <= 0) throw new ArgumentOutOfRangeException(nameof(subtotal));
    
            decimal discount;
            if (customerType == "gold") discount = subtotal * 0.10m;
            else if (customerType == "staff") discount = subtotal * 0.30m;
            else discount = 0m;                       // a new tier means editing this method (OCP)
    
            var total = subtotal - discount;
            File.AppendAllText("orders.csv", $"{email},{total}\n");   // hard-wired storage (DIP)
            Console.WriteLine($"SMTP to {email}: total {total}");       // hard-wired notification (SRP)
            return total;
        }
    }

    Code example

    The refactor gives each reason to change its own type. OrderService keeps only the policy (the order of steps) and depends on three small interfaces it owns.

    C#
    // SOLID refactor of Before.OrderService: each reason to change gets its own type,
    // and the high-level policy depends only on abstractions.
    var repo = new InMemoryOrderRepository();
    var notifier = new RecordingNotifier();
    var service = new OrderService(
        [new GoldDiscount(), new StaffDiscount()],   // OCP: add a tier by adding a class
        repo, notifier);
    
    Console.WriteLine($"{service.PlaceOrder(new Order("a@x.test", "gold", 200m)):0.00}");
    Console.WriteLine($"{service.PlaceOrder(new Order("b@x.test", "staff", 200m)):0.00}");
    Console.WriteLine($"{service.PlaceOrder(new Order("c@x.test", "regular", 200m)):0.00}");
    Console.WriteLine($"saved={repo.Count} notified={notifier.Sent.Count}");
    
    // IList<T> permits read-only implementations; callers must not assume Add is supported.
    IList<int> list = new List<int> { 1, 2 }.AsReadOnly();
    try { list.Add(3); }
    catch (NotSupportedException) { Console.WriteLine("LSP: ReadOnlyCollection<T> as IList<T> throws on Add"); }
    IReadOnlyList<int> honest = [1, 2];
    Console.WriteLine($"ISP: IReadOnlyList<int> exposes no Add; Count={honest.Count}");
    
    public sealed record Order(string Email, string CustomerType, decimal Subtotal);
    
    // ISP: small, role-specific interfaces.
    public interface IDiscountRule { bool AppliesTo(Order o); decimal Discount(Order o); }
    public interface IOrderRepository { void Save(Order o, decimal total); }
    public interface IOrderNotifier { void OrderPlaced(Order o, decimal total); }
    
    public sealed class GoldDiscount : IDiscountRule
    {
        public bool AppliesTo(Order o) => o.CustomerType == "gold";
        public decimal Discount(Order o) => o.Subtotal * 0.10m;
    }
    public sealed class StaffDiscount : IDiscountRule
    {
        public bool AppliesTo(Order o) => o.CustomerType == "staff";
        public decimal Discount(Order o) => o.Subtotal * 0.30m;
    }
    
    // SRP: OrderService only coordinates; DIP: it receives abstractions.
    public sealed class OrderService(IEnumerable<IDiscountRule> rules, IOrderRepository repo, IOrderNotifier notifier)
    {
        private readonly IReadOnlyList<IDiscountRule> _rules = rules.ToList();
    
        public decimal PlaceOrder(Order order)
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(order.Subtotal);
            var discount = _rules.FirstOrDefault(r => r.AppliesTo(order))?.Discount(order) ?? 0m;
            var total = order.Subtotal - discount;
            repo.Save(order, total);
            notifier.OrderPlaced(order, total);
            return total;
        }
    }
    
    public sealed class InMemoryOrderRepository : IOrderRepository
    {
        private readonly List<(Order, decimal)> _rows = [];
        public int Count => _rows.Count;
        public void Save(Order o, decimal total) => _rows.Add((o, total));
    }
    public sealed class RecordingNotifier : IOrderNotifier
    {
        public List<string> Sent { get; } = [];
        public void OrderPlaced(Order o, decimal total) => Sent.Add($"{o.Email}:{total}");
    }

    Output (.NET 8, Release)

    Text
    180.00
    140.00
    200.00
    saved=3 notified=3
    LSP: ReadOnlyCollection<T> as IList<T> throws on Add
    ISP: IReadOnlyList<int> exposes no Add; Count=2

    Code walkthrough

    • GoldDiscount takes 10% of 200, so the first order totals 180.00; StaffDiscount takes 30%, giving 140.00; the regular customer matches no rule and pays 200.00.
    • Adding a "platinum" tier is a new IDiscountRule class plus one line in the composition root. OrderService is not edited: that is OCP.
    • InMemoryOrderRepository and RecordingNotifier replace the file and the SMTP call. All 3 orders were saved and notified without touching the disk: that is DIP paying off in tests.
    • The LSP demo calls Add through an IList<int> that is really a ReadOnlyCollection<int> and gets NotSupportedException. IReadOnlyList<int> has no Add, so the compiler prevents the mistake; its Count is 2.

    Real-world .NET usage

    • ASP.NET Core is built on DIP: your controllers depend on ILogger<T>, IOptions<T> and your own interfaces; the host wires concrete types at startup.
    • IEnumerable<IDiscountRule> injection is the standard way to get "all registered implementations": the same mechanism ASP.NET Core uses for IStartupFilter, health checks and authorization handlers.
    • Minimal APIs and vertical-slice architectures apply SRP per use case: one handler per request rather than one service per entity.

    Trade-offs

    PrincipleOver-applied looks likeHealthy middle ground
    SRPOne class per method, impossible to navigateOne class per actor or use case
    OCPPlugin architecture for code that never variesInterfaces where variation is real or already happened twice
    LSPDeep inheritance trees with overrides that throwPrefer composition; narrow interfaces
    ISPDozens of one-method interfacesInterfaces shaped by what each client calls
    DIPAn interface for every class, including DTOsAbstractions at boundaries (I/O, time, external services)

    Failure modes

    • Interfaces that mirror the implementation one-to-one (IOrderService with every public method of OrderService), created only by habit.
    • "SRP" interpreted as "one method per class", which multiplies types without reducing coupling.
    • Overrides that throw NotImplementedException: an LSP violation that the compiler cannot catch.
    • Depending on DateTime.Now, File, HttpClient or static singletons inside business logic, so tests become slow or flaky. Inject TimeProvider (.NET 8) for time.
    • Leaky abstractions: an IRepository returning IQueryable<T> exposes the ORM to every caller.

    Interview angle

    A strong answer to "Explain SOLID" gives one sentence per principle and one concrete C# example, then shows judgment: "I apply OCP where change is predictable, like discount rules; I do not wrap a DTO in an interface." Expect follow-ups:

    • "Does an unsupported operation necessarily violate LSP?" → No. IList<T> permits read-only implementations and Stream permits CanSeek == false; the documented capability checks and exceptions are part of their contracts. A violation would break a guarantee the abstraction actually makes.
    • "What is the difference between DIP and DI?" → principle about dependency direction versus a technique for supplying dependencies.
    • "Can you over-apply SOLID?" → yes; show the trade-off table above.

    Practice questions

    1. What does "one reason to change" mean in practice?

    Answer: One actor or stakeholder group whose requests change the class. If marketing changes discount rules and operations changes storage, those belong in different classes so their changes do not collide.

    1. How does the Strategy pattern relate to the Open/Closed Principle?

    Answer: Strategy puts each variant behind an interface; the context depends on the interface, so a new variant is a new class and the context stays closed to modification. With DI, registration adds the variant.

    1. Why is Square : Rectangle (with settable width and height) an LSP violation?

    Answer: Callers of Rectangle expect width and height to vary independently (setting width keeps height). A Square must change both, so code that is correct for Rectangle breaks for Square. Model them as separate immutable types or as records without inheritance.

    1. Who should own the interface in Dependency Inversion?

    Answer: The high-level policy (the domain or application layer). The infrastructure implements it through an adapter. That is what makes the dependency arrow point inwards.

    1. Your team wraps every class in an interface. Is that DIP?

    Answer: Not necessarily. DIP is about depending on abstractions at volatile boundaries. Interfaces for stable, dependency-free classes add indirection without benefit; mocks of them also make tests fragile.

    1. How would you make code that uses DateTime.UtcNow testable in .NET 8?

    Answer: Inject TimeProvider and call GetUtcNow(). Production registers TimeProvider.System; tests use a fake (for example FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing) and advance time explicitly.

    Revision checklist

    • SRP = one actor; OCP = add by new code; LSP = no surprises for callers; ISP = small client-shaped interfaces; DIP = policy owns the abstraction.
    • DIP ≠ DI ≠ IoC.
    • IReadOnlyList<T> over IList<T> when callers must not mutate.
    • Abstractions at boundaries: I/O, time, external services.

    Analogy

    Everyday picture

    A professional kitchen

    The head chef (policy) decides the order of the meal; the pastry station, the grill and the dishwasher (details) each have one job. A new dessert does not require rebuilding the grill (OCP). Any trained pastry chef can cover the station (LSP). The chef asks the grill for "medium-rare", not for a gas pressure value (DIP).

    Quick reference

    SOLID smells and fixes

    • Method with many if (type == ...) branches → Strategy (OCP).
    • Class edited by three teams → split by actor (SRP).
    • Override throws NotSupportedException → narrower interface or composition (LSP, ISP).
    • new SqlConnection(...) in business logic → inject an abstraction the domain owns (DIP).
    • DateTime.UtcNow in logic → TimeProvider.

    Practice

    Practice test: Design Patterns in C#: Foundations and SOLID — Lesson Practice8 questions · 15 minutes · 1 mark each, no negative marking
    Take the test
    Sign in to mark lessons done and keep your place in the course.Sign in