SOLID shows up in almost every senior and architect .NET interview, but it is rarely tested as trivia — nobody wins points for reciting five acronym letters. What separates a senior answer from a mid-level one is nuance: knowing where each principle actually pays off, where applying it blindly produces more harm than the problem it was meant to solve, and how the principles interact with dependency injection, testability and the competing pressures of YAGNI and KISS. This page works through each principle with realistic .NET examples, the over-application pitfalls interviewers specifically probe for, and how to talk about refactoring a real codebase toward SOLID without promising a rewrite nobody asked for.

Q1 What does the Single Responsibility Principle actually mean, and why does "a class should do one thing" lead engineers astray?#

Short answer: SRP means a class or module should have exactly one reason to change — one axis of change tied to one actor or stakeholder — not literally "one method" or "one thing" in the colloquial sense; a class that both computes an invoice total and formats and emails that invoice has two separate reasons to change even though both could loosely be described as "invoicing."

"One thing" is a folk simplification that pushes engineers toward absurd micro-classes, such as an Adder with a single Add method, that don't track a real reason to change at all — they just fragment cohesive behavior across files. Robert C. Martin's later clarification ties SRP to actors: a module should answer to exactly one group of stakeholders who could plausibly request a change to it. Take an Employee class with CalculatePay(), ReportHours() and Save(): payroll, operations and the data team are three different actors who could each demand an unrelated change to that class, and a change requested by one actor can silently break behavior the others depend on because they share a type. In .NET this shows up constantly in "service" classes that mix domain calculation, persistence and cross-cutting concerns like email or logging in the same method — splitting on actor, not on line count, is the heuristic a senior candidate should reach for.

C#
// Before: one class, three actors — pricing, notifications, persistence.
public class OrderProcessor
{
    public decimal CalculateTotal(Order order) => order.Lines.Sum(l => l.Price * l.Quantity);
    public Task SendConfirmationEmailAsync(Order order, CancellationToken ct) => throw new NotImplementedException();
    public Task SaveAsync(Order order, CancellationToken ct) => throw new NotImplementedException();
}

// After: each class answers to exactly one actor.
public class OrderPricingCalculator
{
    public decimal CalculateTotal(Order order) => order.Lines.Sum(l => l.Price * l.Quantity);
}

What interviewers look for: the actor/reason-to-change framing instead of "does one thing," backed by a concrete .NET example rather than an abstract restatement.

Common mistakes: shrinking classes until each has a single method and calling that SRP, which just relocates the coupling into whichever class now orchestrates the fragments.

Q2 Explain the Open/Closed Principle with a concrete .NET example. When does chasing OCP become premature abstraction?#

Short answer: OCP says a module should be open for extension but closed for modification — you should be able to add new behavior without editing and re-testing code that already works — which in C# usually means an interface plus new implementations (Strategy) rather than a growing switch inside existing logic; it becomes premature abstraction the moment you build that seam for a variant that doesn't exist yet.

A discount engine written as if (customer.Tier == "Gold") ... else if (customer.Tier == "Silver") violates OCP because every new tier means editing that method and re-testing every existing branch for regressions. Extracting an IDiscountStrategy interface with one implementation per tier lets you add a tier by adding a class, leaving the dispatch code untouched. That is the real payoff: the parts of the system already verified stay closed, while the parts known to vary are the only surface that changes.

The trap is applying this before a second variant exists to justify it. Introducing an interface, a factory and a DI registration for a rule with exactly one implementation and no near-term plan for a second is speculative generality — it adds indirection and a file to navigate for nothing. The senior signal is recognizing OCP as a response to known, recurring variation, not a default posture for every class; YAGNI wins when there is no second implementation and no concrete plan for one.

C#
public interface IDiscountStrategy
{
    decimal Apply(decimal subtotal);
}

public sealed class GoldTierDiscount : IDiscountStrategy
{
    public decimal Apply(decimal subtotal) => subtotal * 0.9m;
}

What interviewers look for: pairing the textbook definition with a specific "when not to" answer — OCP is one of the principles most often cited as a source of over-engineering.

Follow-up questions:

  • How would you introduce IDiscountStrategy later, once a second tier appears, without a disruptive refactor?
  • What is the difference between OCP via inheritance and OCP via composition, and which do you prefer in modern C#?

Q3 What's a realistic Liskov Substitution Principle violation you've seen in production C# code, and how do you detect LSP violations in review?#

Short answer: LSP means any subtype must be usable anywhere its base type is expected without the caller needing to know which concrete type it got; the realistic violation is rarely the textbook Rectangle/Square example — it is a derived class that throws NotSupportedException from an inherited method, or silently strengthens a precondition beyond what the base contract promised.

A common real case: IRepository<T> declares Task DeleteAsync(int id), and a ReadOnlyAuditRepository : IRepository<AuditRecord> throws NotSupportedException from DeleteAsync because audit records must never be deleted. That satisfies the compiler but violates LSP — generic code written against IRepository<T>, such as a background cleanup job, now has a way to crash at runtime its own type signature never warned it about. The honest fix is not implementing IRepository<T> for that type at all; model it as a narrower IReadableRepository<T> instead of forcing an interface onto a type that cannot honor the full contract. Another frequent case: an override that strengthens a precondition — a base Save(Customer c) accepts any non-null customer, but an override throws when c.Email is empty — so code written against the base type has no way to know about the tighter rule until a previously valid call fails in production.

Detecting these in review means reading overrides for exceptions the base method never declared, narrowed return behavior that breaks polymorphic callers, and any override whose comment explains an exception to the base behavior — that is almost always a hierarchy problem, not an implementation detail.

What interviewers look for: an example grounded in interfaces and exceptions rather than the geometry cliché, and the instinct to fix violations by redesigning the hierarchy, not by adding runtime type checks.

Common mistakes: "fixing" an LSP violation with is/as checks before calling a method, which relocates the violation into every call site instead of removing it.

Q4 How does the Interface Segregation Principle apply differently in C# than in languages without default interface methods?#

Short answer: ISP says clients should not be forced to depend on members they do not use, which classically meant splitting fat interfaces into small, role-specific ones; C#'s default interface members, since C# 8, let an interface grow optional members with a default body so implementers are not forced to write every member — but that does not remove the deeper cost of a fat interface, which is the wide surface every consumer sees and every test double has to account for.

The textbook ISP smell is a generic IRepository<T> with Add, Update, Delete, GetById, GetAll, Query, BulkInsert and Count — a read-only reporting service that only calls GetAll and Query still depends on, and a hand-rolled fake still has to implement, the other six members. Splitting into IReadRepository<T> and IWriteRepository<T> lets each consumer declare exactly what it needs, and narrows what a test's fake has to provide — a constructor asking for IReadRepository<Order> tells a reviewer, without reading the method body, that this class cannot mutate orders.

Default interface members change the implementation cost, not the dependency cost: they let a library interface add a member without breaking every existing implementer, which is useful for public API evolution, but a consumer that only calls GetAll still logically depends on the whole IRepository<T> contract if that is the type in its constructor — DIMs do not segregate the interface, they just make it non-breaking to grow one. The ISP fix is still splitting the interface; DIMs solve a narrower, separate compatibility problem.

What interviewers look for: distinguishing "DIMs help you avoid breaking implementers" from "DIMs are a substitute for interface segregation" — conflating the two is a common gap.

Follow-up questions:

  • Would you use a default interface member to grow a widely implemented internal interface, or split it instead? What does that decision depend on?

Q5 Walk through the Dependency Inversion Principle vs Dependency Injection — they're not the same thing. How would you explain the difference to a mid-level engineer?#

Short answer: DIP is a design principle: high-level and low-level modules should both depend on abstractions, and the abstraction should not depend on the details — the dependency arrow points from infrastructure toward an abstraction the domain owns, not the other way around. Dependency Injection is one implementation technique for supplying an object its dependencies from outside, and while DI is commonly used to satisfy DIP, you can do DI without DIP and DIP without a DI container.

Concretely: a PaymentService class depends on IPaymentGateway, declared in the same project as PaymentService (the high-level module); StripePaymentGateway lives in an infrastructure project that references the domain project, not the reverse — that ownership direction is DIP. Whether StripePaymentGateway gets wired into PaymentService via IServiceCollection, a hand-written composition root, or a static factory is purely a DI mechanism question and does not change whether DIP is satisfied. You can violate DIP while still using a DI container: register a concrete SqlOrderRepository and inject it directly with no IOrderRepository abstraction in sight, and you have DI without DIP — the container wires things up, but high-level code still names the low-level type directly.

The inverse also happens: a codebase with no container at all, wiring new PaymentService(new StripePaymentGateway()) by hand in a single composition root, still satisfies DIP as long as PaymentService only ever references IPaymentGateway. This distinction is also what separates legitimate DI from the Service Locator anti-pattern, where a class pulls its own dependencies from a container (provider.GetService<IFoo>()) instead of receiving them — that hides the dependency from the constructor signature, undermining the point of making dependencies explicit and testable.

What interviewers look for: the ownership-of-the-abstraction detail as the actual test of DIP, plus naming Service Locator as a DI-without-DIP-benefits anti-pattern.

Q6 When does applying SOLID principles make a codebase worse rather than better? Give a concrete example of over-application.#

Short answer: Every SOLID principle trades simplicity for flexibility, and applying one where the corresponding flexibility is never used is a net loss; the classic case is a ceremony-heavy CRUD service — an IOrderService and its only implementation, an IOrderRepository and its only implementation wrapping EF Core (already an abstraction over the database), plus a mapper interface — six files and three interfaces to add a row to a table.

Each decision might defend itself in isolation ("interfaces make it testable," "the repository lets us swap databases"), but the composite cost is real: a new engineer traces three indirections to find the SQL, fakes exist for abstractions never actually substituted in the code's history, and "testable" is doing a lot of work when DbContext with an in-memory or SQLite provider already gives a fast, realistic test seam without a hand-rolled repository at all. This is the most common concrete manifestation of over-applying DIP and ISP together: every dependency gets an interface "for testability" regardless of whether anything is ever substituted, and the codebase settles into a one-to-one interface-to-class ratio that adds navigation cost without ever paying off in an actual swap or an actual isolated test that would not have worked just as well against the concrete type.

The judgment call is asking, per abstraction: is there a second implementation today, a concrete near-term plan for one, or a genuine testing need a simpler seam would not satisfy? If the honest answer is no to all three, the interface is speculative, and the YAGNI critique applies directly — SOLID describes how to manage known variation and coupling, not a checklist to run against every class regardless of whether the problem it solves is actually present.

What interviewers look for: a specific, named failure mode rather than a vague "over-engineering is bad," and a repeatable three-question test for whether an abstraction earns its cost.

Common mistakes: treating "testability" as automatic license to add an interface everywhere, when concrete classes with virtual members, or a fast in-process fake for infrastructure, are often enough.

Q7 How do SOLID principles relate to testability? Walk through how violating SRP or DIP specifically makes unit testing harder.#

Short answer: SRP and DIP are the two principles most directly responsible for whether a class is unit-testable at all: SRP keeps a class's test surface small and its setup cheap, and DIP is what makes substituting a fast fake for a slow or external dependency possible in the first place — without it, a test either exercises real infrastructure or cannot run the code path at all.

Take a class that violates SRP by mixing pricing logic with direct SmtpClient calls and direct DbContext usage: a unit test for a pricing edge case, such as a coupon that should not stack with a sale, now has to invoke or mock SMTP and a real database, because the constructor's infrastructure calls are buried inside the method under test with no seam to intercept — that is also a DIP violation, since the pricing class depends on concrete infrastructure types instead of abstractions a test could substitute. Split the pricing calculation into its own class with only primitive and domain-typed inputs and outputs, and the same edge case becomes a two-line test with no mocking at all — SRP made the seam small enough that DIP was not even needed for that particular test.

Where DIP matters most is dependencies you genuinely cannot run inside a unit test — a payment gateway, a third-party API, wall-clock time. Depending on IPaymentGateway and TimeProvider, the modern .NET abstraction for the system clock, instead of concrete SDK types or DateTime.Now directly, is what lets a test substitute a fake that returns a scripted response instantly and deterministically — a class that constructs its own gateway or calls DateTime.Now inline cannot be unit tested for that behavior at all, only integration tested, which is slower and flakier.

What interviewers look for: connecting each principle to a specific testing mechanism — SRP to a smaller arrange/act/assert surface, DIP to substitutable seams — rather than a generic "SOLID makes code testable" claim.

Follow-up questions:

  • How would you retrofit a seam into a method that calls DateTime.Now or Guid.NewGuid() directly, in a codebase you cannot fully rewrite?

Q8 You're doing a code review and see a service class with seven constructor-injected dependencies. What does that tell you, and how do you refactor it?#

Short answer: Constructor over-injection is usually a visible symptom of an SRP violation, not a DI problem in itself — a class needing seven collaborators is very likely doing several unrelated jobs, each of which only needs two or three of those seven; the fix is extracting the cohesive sub-responsibilities into their own classes, not accepting the long constructor or hiding it behind a parameter object that bundles the same seven dependencies without separating the responsibilities.

Group the dependencies by what they are actually used for. In a typical case, an OrderProcessingService with IOrderRepository, IInventoryService, IPricingEngine, IEmailSender, ISmsSender, IAuditLogger and IPaymentGateway usually reveals two or three natural clusters: order persistence plus pricing, notification (which could itself collapse behind a single INotificationService), and payment. Splitting along those lines produces an OrderProcessingService with three dependencies, a small notification service, and a payment concern that moves to wherever it is actually orchestrated — often a step earlier in a workflow, not inside the order service at all.

The trap to call out explicitly: wrapping the seven dependencies into a single OrderServiceDependencies parameter object looks like a fix, since the constructor now takes one parameter, but it does not address SRP at all — the class still does seven jobs, the coupling is identical, and it is now also hidden from the constructor signature, which is worse for readability. Parameter objects are legitimate when several dependencies are genuinely consumed together as one cohesive unit; the test is whether the grouping reflects real cohesion or is just a wrapper to make a review comment go away.

What interviewers look for: treating constructor length as a symptom to investigate — what each dependency is used for, and whether they cluster — rather than either a hard rule or a non-issue.

Common mistakes: "fixing" a bloated constructor with a parameter object or a facade that still internally calls all seven dependencies, leaving the SRP violation and the coupling exactly where they were.

Q9 How would you refactor a legacy God Class toward SOLID incrementally in a live production codebase without a big-bang rewrite?#

Short answer: Incremental refactoring toward SOLID means creating seams before extracting behavior — write characterization tests around the God Class's current, even if messy, observable behavior first, then extract one cohesive responsibility at a time behind a new interface while the class keeps compiling and deploying at every step, using a Strangler Fig approach to route an increasing share of callers to the new piece until the old path is provably dead and can be deleted.

Start with tests, not extraction — a God Class usually has none, because it is hard to construct in isolation, so the first move is often a broad characterization test that pins today's behavior, quirks included, so the refactor has a safety net that is not "hope." From there, pick the responsibility with the clearest boundary and fewest internal dependencies on the rest of the class's state, extract it behind an interface, have the God Class delegate to the new class internally — a step that changes nothing observable — run the characterization tests, and only then move external callers over if that is warranted. Repeating this one responsibility at a time keeps each change reviewable and revertible; a single PR that "refactors the God Class to SOLID" is both risky and effectively unreviewable, while ten small "extract X" PRs each ship independently with a small blast radius.

Branch by abstraction is the tool when extraction cannot happen in one step because callers are spread across the codebase and cannot all move atomically: introduce an interface implemented first by a thin wrapper around the old code, migrate callers to depend on the interface with no behavior change, swap the implementation behind it for the new extracted class once every caller goes through it, then delete the old path last.

What interviewers look for: a concrete sequencing story — tests first, extract-and-delegate, branch by abstraction for spread-out callers — rather than "just refactor it carefully," which signals no real experience doing this under production constraints.

Follow-up questions:

  • How do you decide which responsibility to extract first when a God Class has five plausible candidates?
  • What do you do when the God Class has no tests and nobody on the team fully understands the domain logic anymore?

Q10 SOLID predates microservices and cloud-native design by decades. How do the principles map, or fail to map, onto service boundaries and API design?#

Short answer: SRP scales up almost directly — a service, like a class, should have one reason to change tied to one business capability, which is the same reasoning behind bounded-context-aligned service boundaries; DIP and ISP also translate well, but OCP and LSP translate much more loosely, because "closed for modification" is a far more expensive property to hold at a network boundary than inside a single compiled assembly.

SRP at the service level is essentially the argument for bounded-context-aligned services: a Billing service that also owns shipment tracking has two actors, finance and logistics, who can each force a change, and — worse than the in-process case — a bad deploy triggered by one actor's change can take down functionality the other depends on, with no compiler to catch the coupling. ISP maps onto API design directly: a single fat API returning everything every consumer might ever want forces every client to depend on a large, slow-changing contract, which is why Backend-for-Frontend patterns and field-selecting query APIs exist — narrow, client-shaped contracts instead of one interface serving every consumer.

OCP is where the mapping breaks down. "Open for extension, closed for modification" inside a class means adding a new implementation without touching existing code, verified by the compiler in seconds. At a service boundary, "closed for modification" really means "closed for breaking modification" — you can still change a service's internals or its API, but every change has to preserve existing consumers' contracts, through versioning or additive-only fields, because there is no compiler to catch a break and no way to force every consumer to redeploy atomically with you. LSP has a similar gap: it assumes a caller can substitute one implementation for another transparently, which works cleanly for library interfaces but gets fuzzy across service versions, where "substitutable" has to be defined contractually rather than enforced by a type system.

What interviewers look for: recognizing which principles scale up cleanly (SRP, ISP, DIP) versus which need real reinterpretation at a network boundary (OCP, LSP) — a candidate who claims SOLID "just applies to microservices too" without this nuance has not thought about where the analogy strains.

Quick-Fire Round#

QuestionAnswer
What does SRP's "reason to change" really mean?One actor or stakeholder per module, not one method per class.
What C# feature eases ISP-style interface growth without segregating it?Default interface members (C# 8+) — they ease evolution, not the dependency itself.
Who owns the abstraction under DIP?The high-level, business-facing module — not the low-level infrastructure module.
Is Service Locator an example of proper DI?No — it is DI-adjacent but hides dependencies instead of declaring them, undermining DIP's intent.
What is the fastest tell of an SRP violation in review?A constructor with many unrelated dependencies, or a class name joined with "and" or "Manager".
Does OCP mean never modifying a class again?No — it means adding known variation without touching stable, already-tested code paths.
What technique refactors a God Class without a big-bang rewrite?Strangler Fig and branch by abstraction, backed by characterization tests.
Which SOLID principle maps worst onto microservice APIs?OCP — "closed for modification" becomes the weaker "closed for breaking changes."

How to Prepare#

  • Be ready to give a .NET-specific example for each of the five letters, not just the textbook description.
  • Practice naming a case where each principle, applied blindly, made a real codebase worse — interviewers weight this as heavily as the positive definition.
  • Rehearse the DIP-vs-DI distinction until you can state it in two sentences; it is one of the most commonly confused pairs in senior interviews.
  • Have one incremental-refactoring story ready — extract-and-delegate, Strangler Fig, or branch by abstraction — that does not depend on a rewrite.
  • Know how SRP and DIP specifically enable unit testing, with a before-and-after example you can describe from memory.
  • Think through how SRP, ISP and DIP extend to service boundaries, and be honest about where OCP and LSP stop mapping cleanly.