TechInterviewNotes

‹ Notes

SOLID, Honestly Five principles, what they actually mean in C#, and the patterns each one produces.

Save

Updated 2 Aug 2026 · 15 min read

Five principles, what they actually mean in C#, and the patterns each one produces. Companion to "Design Patterns That Earn Their Keep."

Download PDFOpen PDF

SOLID, Honestly

Five principles, what they actually mean in C#, and the patterns each one produces.

Companion to "Design Patterns That Earn Their Keep."

The framing that makes SOLID useful

SOLID is usually taught as five rules to obey. That's the least useful version of it. Score a codebase against a checklist and you end up with 60 files, 12 interfaces with one implementation each, and three "go to implementation" jumps to find the code that actually runs.

The useful version: SOLID is five names for five specific ways code becomes expensive to change. Each principle describes a failure mode. And here's the part that connects this document to the last one — when you fix each failure, a specific design pattern falls out. Patterns aren't a separate catalogue you memorise. They're what SOLID looks like after you've applied it.

Two things to set expectations:

1\. SRP — Single Responsibility

What it doesn't mean

"A class should do one thing." This is the version everyone repeats and it's useless, because everything does one thing at some level of zoom. ProcessOrder does one thing. So does an entire ERP.

What it actually means

A module should have one reason to change — it should be answerable to one actor.

Actors are people or teams who can walk in and ask for a change. Finance. Design. Ops. Compliance. If two different people can ask you to modify the same class for two unrelated reasons, that class has two responsibilities.

The violation

public sealed class InvoiceService(
    IAsyncDocumentSession session,
    SmtpClient smtp,
    ILogger<InvoiceService> logger)
{
    public async Task ProcessAsync(InvoiceRequest request, CancellationToken ct)
    {
        // Actor 1: Finance owns tax rules
        var taxRate = request.Region == "IN" ? 0.18m : 0.20m;
        var total = request.Subtotal * (1 + taxRate);

        // Actor 2: Design owns the document layout
        var pdf = new PdfBuilder()
            .Header(request.CustomerName)
            .LineItems(request.Items)
            .Footer($"Total: {total:C}")
            .Build();

        // Actor 3: Ops owns delivery and retry policy
        var message = new MailMessage("billing@ontrack.io", request.CustomerEmail);
        message.Attachments.Add(new Attachment(new MemoryStream(pdf), "invoice.pdf"));
        await smtp.SendMailAsync(message, ct);

        // Actor 4: The data team owns the schema
        await session.StoreAsync(new InvoiceRecord(request.Id, total, DateTime.UtcNow), ct);
        await session.SaveChangesAsync(ct);
    }
}

Four actors, one class. Every one of them can break the other three. A GST rate change from Finance now requires re-testing PDF generation and email delivery.

The fix

public interface ITaxCalculator
{
    Money CalculateTotal(Money subtotal, Region region);
}

public interface IInvoiceRenderer
{
    Task<byte[]> RenderAsync(Invoice invoice, CancellationToken ct);
}

public interface IInvoiceDelivery
{
    Task<Result> SendAsync(Invoice invoice, byte[] document, CancellationToken ct);
}
public sealed class ProcessInvoiceHandler(
    ITaxCalculator taxCalculator,
    IInvoiceRenderer renderer,
    IInvoiceDelivery delivery,
    IInvoiceRepository repository,
    IUnitOfWork unitOfWork)
    : IRequestHandler<ProcessInvoiceCommand, Result>
{
    public async Task<Result> Handle(ProcessInvoiceCommand command, CancellationToken ct)
    {
        var total    = taxCalculator.CalculateTotal(command.Subtotal, command.Region);
        var invoice  = Invoice.Create(command.Id, command.Customer, total);
        var document = await renderer.RenderAsync(invoice, ct);

        var sent = await delivery.SendAsync(invoice, document, ct);
        if (sent.IsFailure)
            return sent;

        repository.Add(invoice);
        await unitOfWork.SaveChangesAsync(ct);

        return Result.Success();
    }
}

The handler now reads as the use case. Each collaborator has exactly one actor behind it.

Patterns SRP produces

Mediator / CQRS handlers — one handler per use case is SRP at the application layer. Decorator — moving logging, caching and retry out is SRP applied to cross-cutting concerns. Strategy — extracting the varying calculation. Facade — when several single-responsibility classes need one convenient entry point.

Where SRP goes wrong

Taken literally, you get anaemic classes and a Helpers folder. The failure signature is a feature that spans 14 files where each file has one method. The test is not "does this do one thing" — it's "would two different people ask me to change this for two different reasons." If the answer is no, leave it in one class.

2\. OCP — Open/Closed

What it means, practically

Adding a new case should mean adding a file, not editing a tested one.

Open for extension, closed for modification. The theory is elegant; the practical translation is the sentence above.

The violation

public decimal CalculateHaulageRate(Consignment consignment)
{
    switch (consignment.MaterialType)
    {
        case MaterialType.Ferrous:
            return consignment.NetWeightTonnes * 42m;

        case MaterialType.NonFerrous:
            return consignment.NetWeightTonnes * 68m
                 + (consignment.RequiresSealedContainer ? 150m : 0m);

        case MaterialType.Hazardous:
            return consignment.NetWeightTonnes * 95m
                 + 200m  // compliance surcharge
                 + (consignment.DistanceKm > 200 ? 350m : 0m);

        default:
            throw new NotSupportedException();
    }
}

Every new material type means opening this method, editing it, and re-testing every existing branch. The class is closed for extension and open for modification — exactly backwards.

The fix

public interface IHaulageRateRule
{
    bool AppliesTo(MaterialType materialType);
    Money Calculate(Consignment consignment);
}

internal sealed class HazardousRateRule(IOptions<RateOptions> options) : IHaulageRateRule
{
    public bool AppliesTo(MaterialType materialType) => materialType is MaterialType.Hazardous;

    public Money Calculate(Consignment consignment)
    {
        var basis = consignment.NetWeightTonnes * options.Value.HazardousPerTonne;
        var surcharge = options.Value.ComplianceSurcharge;
        var longHaul = consignment.DistanceKm > 200 ? options.Value.LongHaulPremium : 0m;

        return Money.Rupees(basis + surcharge + longHaul);
    }
}
public sealed class HaulageRateCalculator(IEnumerable<IHaulageRateRule> rules)
{
    public Result<Money> Calculate(Consignment consignment)
    {
        var rule = rules.FirstOrDefault(r => r.AppliesTo(consignment.MaterialType));

        return rule is null
            ? Result.Failure<Money>(RateErrors.NoRuleForMaterial(consignment.MaterialType))
            : Result.Success(rule.Calculate(consignment));
    }
}

New material type? New file, one registration line. HaulageRateCalculator never changes again.

Patterns OCP produces

Strategy (the direct output, as above), Decorator, Chain of Responsibility, Specification, Visitor, and Template Method.

The trap nobody warns you about

You cannot predict the axis of change. OCP requires choosing which dimension stays open — by material type? by region? by customer tier? Guess wrong and you've built a plugin architecture along an axis that never varied, while the axis that did vary is still a switch statement buried in a strategy.

The discipline: apply OCP the second time you edit that switch, not the first time you write it. The rule of three exists for exactly this. A switch expression that has been stable for two years is better code than a folder of six strategy classes.

3\. LSP — Liskov Substitution

The most abstract principle, the one you'll apply least consciously, and the one whose violations do the most damage.

The usable formulation

A subtype must not surprise code written against the base type.

Formally: preconditions can't be strengthened, postconditions can't be weakened, invariants must be preserved. Practically: if I hold an IFoo, anything I can safely do with one implementation I must be able to safely do with all of them.

The violation that ships in the BCL

IList<int> numbers = new[] { 1, 2, 3 };
numbers.Add(4);          // NotSupportedException — at runtime, in production

int[] implements IList<T> but can't honour half of it. ReadOnlyCollection<T> does the same. This is a real LSP violation in the framework, and it's why IReadOnlyList<T> was added later — a smaller, honest contract instead of a big dishonest one. Note that the fix was ISP: the interface was too fat to be implementable.

The violation you'll actually write

// The interface promises: after this returns successfully, the email has been sent.
public interface IEmailSender
{
    Task SendAsync(EmailMessage message, CancellationToken ct);
}

internal sealed class ThrottledEmailSender(IEmailSender inner, IRateLimiter limiter)
    : IEmailSender
{
    public async Task SendAsync(EmailMessage message, CancellationToken ct)
    {
        if (!limiter.TryAcquire())
            return;                    // silently drops the message — contract broken

        await inner.SendAsync(message, ct);
    }
}

This compiles, passes its own unit tests, and causes a support ticket six months later titled "customers not receiving invoices." The caller was told the mail was sent. It wasn't.

The honest version changes the contract so the outcome is visible:

public interface IEmailSender
{
    Task<Result<DeliveryOutcome>> SendAsync(EmailMessage message, CancellationToken ct);
}

public enum DeliveryOutcome { Sent, Queued, ThrottledAndDropped }

The one-line test

If you have to check the concrete type before calling through an interface, LSP is broken.
if (repository is CachedConsignmentRepository)     // <- the smell
    await ((CachedConsignmentRepository)repository).InvalidateAsync(id);

The moment that appears, your decorator is no longer substitutable and every consumer has to know about it.

Why LSP is the load-bearing principle

Decorator, Proxy, Adapter and Strategy all depend entirely on substitutability. They are only safe because LSP holds. Violate it, and your caching decorator becomes a landmine that behaves differently from the thing it wraps — which is the single most confusing category of production bug, because the code you're reading isn't the code that ran.

LSP produces no patterns of its own. It's the thing that makes the other patterns work.

4\. ISP — Interface Segregation

Clients shouldn't be forced to depend on methods they don't use.

The violation

public interface IUserService
{
    Task<User?> GetAsync(UserId id, CancellationToken ct);
    Task<string?> GetEmailAsync(UserId id, CancellationToken ct);
    Task<Result<UserId>> CreateAsync(CreateUserRequest request, CancellationToken ct);
    Task UpdateProfileAsync(UserId id, ProfileUpdate update, CancellationToken ct);
    Task DeactivateAsync(UserId id, CancellationToken ct);
    Task<bool> IsInRoleAsync(UserId id, string role, CancellationToken ct);
    Task ResetPasswordAsync(UserId id, CancellationToken ct);
    Task<IReadOnlyList<Session>> GetActiveSessionsAsync(UserId id, CancellationToken ct);
    Task RevokeSessionsAsync(UserId id, CancellationToken ct);
    // ... and six more
}

The notification module needs exactly one of these — GetEmailAsync. But it now depends on all fifteen. Every test that touches notifications has to mock a fifteen-method interface. Every change to password handling recompiles the notification module.

The fix: role interfaces named after the consumer

public interface IEmailLookup
{
    Task<string?> GetEmailAsync(UserId id, CancellationToken ct);
}

public interface IUserRoleChecker
{
    Task<bool> IsInRoleAsync(UserId id, string role, CancellationToken ct);
}

public interface IUserRegistration
{
    Task<Result<UserId>> CreateAsync(CreateUserRequest request, CancellationToken ct);
}

One class can still implement all three:

internal sealed class UserService : IEmailLookup, IUserRoleChecker, IUserRegistration
{
    // one implementation, several narrow faces
}

services.AddScoped<UserService>();
services.AddScoped<IEmailLookup>(sp => sp.GetRequiredService<UserService>());
services.AddScoped<IUserRoleChecker>(sp => sp.GetRequiredService<UserService>());
services.AddScoped<IUserRegistration>(sp => sp.GetRequiredService<UserService>());

The key insight: an interface belongs to the consumer, not the implementer. Name it after what the client needs, not after the class that happens to satisfy it. IEmailLookup is a better name than IUserServicePart2.

Patterns ISP produces

Adapter (narrowing a fat third-party surface to the two methods you use), Facade (the mirror image — one simple face over many subsystems), role interfaces, and CQRS — separating a read interface from a write interface is ISP applied at the architectural level.

Where ISP goes wrong

Atomising every interface down to one method. You end up with IUserGetter, IUserGetterById, IUserGetterByEmail, and a registration file nobody wants to read. Segregate along client boundaries, not method boundaries. If three consumers all use the same four methods, that's one interface with four methods.

5\. DIP — Dependency Inversion

The highest-value principle, and the one most commonly claimed and not actually implemented.

The half that everyone quotes

High-level modules should not depend on low-level modules. Both should depend on abstractions.

The half that matters

The abstraction is owned by the high-level module.

This is the clause people skip, and skipping it is why so many "Clean Architecture" solutions aren't. Putting IEmailSender in your Infrastructure project and injecting it is not dependency inversion. It's just an interface. The dependency arrow still points from your business logic toward infrastructure.

DIP means the interface lives with the code that needs it, and infrastructure reaches inward to satisfy it.

What it looks like in project references

Ontrack.Domain           →  (nothing — no project refs, no NuGet beyond the BCL)
Ontrack.Application      →  Domain
Ontrack.Infrastructure   →  Application, Domain
Ontrack.Api              →  Application, Infrastructure   (composition root only)

Note the direction: Infrastructure depends on Application, never the reverse. The Api project references Infrastructure for exactly one reason — to register implementations in Program.cs.

// Ontrack.Application/Abstractions/IPaymentGateway.cs
// Defined by the code that needs it. Speaks the domain's language.
public interface IPaymentGateway
{
    Task<Result<PaymentIntent>> CreateIntentAsync(
        Money amount, CustomerReference customer, CancellationToken ct);
}
// Ontrack.Infrastructure/Payments/RazorpayPaymentGateway.cs
// Implements someone else's interface. This class is replaceable; the contract isn't.
internal sealed class RazorpayPaymentGateway(HttpClient http, IOptions<RazorpayOptions> options)
    : IPaymentGateway
{
    public async Task<Result<PaymentIntent>> CreateIntentAsync(
        Money amount, CustomerReference customer, CancellationToken ct)
    {
        // translate domain concepts into Razorpay's vocabulary here, and nowhere else
    }
}

The test that can't be faked

Open your Domain.csproj. If it has NuGet packages in it, DIP is not holding. No Azure.*, no RavenDB.Client, no Microsoft.EntityFrameworkCore, no MediatR (contracts belong in Application). A domain project that compiles with nothing but the BCL is the compile-time proof that your business rules don't depend on your plumbing.

DI is not DIP

You can use the fanciest container in the world, register two hundred services, and still have every dependency arrow pointing outward from your business logic toward infrastructure. The container is a mechanism. Inversion is a direction. They're independent — and only one of them shows up in your project references.

Patterns DIP produces

Repository, Adapter / Anti-Corruption Layer, Factory, Strategy, the DI container itself, and at the architectural scale, Ports and Adapters (Hexagonal) and Clean Architecture — whose "dependency rule" is literally just DIP applied at project granularity.

The map: SOLID to patterns

PrincipleThe failure it namesPatterns it producesWhere .NET already does it
SRPOne class answers to several teamsMediator handlers, Decorator, Strategy, FacadeOne handler per use case; middleware components
OCPNew requirements force edits to tested codeStrategy, Decorator, Chain of Responsibility, Specification, VisitorIEnumerable<T> DI resolution; pipeline behaviours
LSPA subtype surprises code written against the baseNone — it's what makes Decorator, Proxy and Adapter safeIReadOnlyList<T> existing separately from IList<T>
ISPClients depend on methods they never callAdapter, Facade, role interfaces, CQRS read/write splitIOptions / IOptionsSnapshot / IOptionsMonitor split
DIPBusiness logic can't move or be tested without infrastructureRepository, Adapter/ACL, Factory, Ports & AdaptersThe container; Clean Architecture project layout

One class, five principles, five patterns

The clearest way to see the relationship is to refactor a single class through all five and watch the patterns appear.

Where we start

public sealed class ConsignmentBillingService
{
    private static readonly Dictionary<string, decimal> Cache = new();

    public async Task<byte[]> BillAsync(string consignmentId)
    {
        // Infrastructure, constructed inline — untestable
        using var store = new DocumentStore { Urls = ["https://raven.internal"] }.Initialize();
        using var session = store.OpenAsyncSession();
        var consignment = await session.LoadAsync<Consignment>(consignmentId);

        // Rate logic — a switch that grows with every material type
        decimal rate = consignment.MaterialType switch
        {
            "Ferrous"    => consignment.NetTonnes * 42m,
            "NonFerrous" => consignment.NetTonnes * 68m,
            "Hazardous"  => consignment.NetTonnes * 95m + 200m,
            _            => throw new NotSupportedException()
        };

        // Ad-hoc caching bolted into the middle of business logic
        Cache[consignmentId] = rate;

        Console.WriteLine($"Billed {consignmentId} at {rate}");   // "logging"

        // Document generation, in the same method
        return new PdfBuilder().LineItem(consignment.MaterialType, rate).Build();
    }
}

Step 1 — SRP: separate the actors

Four actors are hiding here: the data team (loading), finance (rates), design (the PDF), ops (logging and caching). Split them.

→ produces a Mediator handler that reads as the use case, plus separate collaborators.

Step 2 — DIP: invert the arrows

new DocumentStore(...) inside business logic means this method can never be unit-tested and the domain now depends on RavenDB. Define IConsignmentRepository and IInvoiceRenderer in the Application layer; implement them in Infrastructure.

→ produces Repository and Adapter.

// Application layer — owns the contract
public interface IConsignmentRepository
{
    Task<Consignment?> GetAsync(ConsignmentId id, CancellationToken ct);
}

// Infrastructure layer — satisfies it
internal sealed class RavenConsignmentRepository(IAsyncDocumentSession session)
    : IConsignmentRepository
{
    public Task<Consignment?> GetAsync(ConsignmentId id, CancellationToken ct)
        => session.LoadAsync<Consignment>(id.Value, ct)!;
}

Step 3 — OCP: close the switch

The rate switch is edited every time the business adds a material type. Extract each branch into a rule that self-selects.

→ produces Strategy (the IHaulageRateRule from section 2).

Step 4 — ISP: narrow the surface

The renderer needs consignment data but has no business calling Save. The billing endpoint needs BillAsync but not the repository. Give each consumer only what it uses.

→ produces role interfaces and a CQRS read/write split.

Step 5 — LSP: add caching safely

Now — and only now — caching can go in a decorator, because every implementation of IConsignmentRepository is genuinely substitutable and a wrapper can't surprise its callers.

→ produces Decorator.

services.AddScoped<IConsignmentRepository, RavenConsignmentRepository>();
services.Decorate<IConsignmentRepository, CachingConsignmentRepository>();

What we ended up with

Application/
  Billing/
    BillConsignmentCommand.cs        Mediator      (SRP)
    BillConsignmentHandler.cs        Mediator      (SRP)
    IConsignmentRepository.cs        Repository    (DIP)
    IInvoiceRenderer.cs              Adapter port  (DIP + ISP)
  Rates/
    IHaulageRateRule.cs              Strategy      (OCP)
    FerrousRateRule.cs
    NonFerrousRateRule.cs
    HazardousRateRule.cs
    HaulageRateCalculator.cs
Infrastructure/
  Persistence/
    RavenConsignmentRepository.cs    Repository    (DIP)
    CachingConsignmentRepository.cs  Decorator     (LSP made it safe)
  Documents/
    QuestPdfInvoiceRenderer.cs       Adapter       (DIP)

Every file in that tree exists because a principle demanded it. Nobody sat down and chose patterns. That's the relationship: you apply the principles to a real pain, and the patterns are the shape the solution takes.

Where SOLID misleads you

It's silent on the things that most often hurt. SOLID says nothing about data modelling, concurrency, transaction boundaries, allocation, N+1 queries, or coupling between your abstractions. A flawlessly SOLID system can still be slow, deadlock-prone and impossible to reason about.

Applied to CRUD, it's a net loss. If a feature is "save this form, show this list," five principles buy you 40 files and no flexibility. The cost of an abstraction is paid every time someone reads the code; the benefit is paid only when the second implementation arrives. For a lot of code, it never does.

"For testability" is the most abused justification. An interface over an external dependency (payment gateway, blob store, clock) genuinely helps. An interface over a pure calculation does not — just call it. TimeProvider in .NET 8 removed the last good reason to write IDateTimeProvider.

Worth knowing the counter-arguments. Dan North's CUPID (Composable, Unix-philosophy, Predictable, Idiomatic, Domain-based) is a serious alternative framing and a good interview answer if you're asked what you'd critique about SOLID. YAGNI and the rule of three are the practical governors that stop SOLID becoming architecture astronautics.

The mature position isn't "SOLID is wrong." It's that SOLID is a set of refactoring heuristics — extremely good at telling you why a change hurt, and unreliable at telling you what to build before anything has hurt yet.

For the architect interview

"Is dependency injection the same as dependency inversion?" No. DI is a mechanism for supplying dependencies; DIP is about which module owns the abstraction and which way the reference points. You can use a container and still violate DIP entirely. Answering this well separates people fast.

"Give me an LSP violation you've seen." The BCL one is free: int[] implements IList<T>, and Add throws NotSupportedException. Then give one of your own — bonus points if the fix was to narrow the interface, because that shows you see ISP and LSP as connected.

"Which principle do you break most often, deliberately?" Good answer: SRP, in vertical-slice handlers — I'll keep validation, mapping and orchestration together in one file because the alternative is four files nobody can follow, and the "actors" are the same team.

"How does Clean Architecture relate to SOLID?" It's DIP at project granularity. The dependency rule is dependency inversion, enforced by the compiler through project references rather than by discipline.

"When is a single-implementation interface justified?" When it crosses an ownership boundary (a third-party SDK, an external service) or a deployment boundary. Not when it wraps a pure function you wrote yesterday.

If you only remember three things

  1. DIP is the one that pays. Interfaces owned by the layer that needs them, infrastructure pointing inward. The test is your Domain.csproj having no NuGet packages.
  2. SRP means one actor, not one action. Ask who can ask you to change this, not how many things it does.
  3. Apply them on the second change, not the first. SOLID diagnoses pain you've already felt. Applied speculatively it produces exactly the rigidity it was invented to prevent.

And the connection back to the patterns guide: you don't choose patterns. You apply a principle to a specific pain, and the pattern is the shape the fix takes. If you can't name the principle a pattern is serving, you probably don't need the pattern.

SOLID, Honestly Five principles, what they actually mean in C#, and the patterns each one produces. — TechInterviewNotes