skip to content

SOLID Principles

How the five principles rest on paradigm mechanisms — encapsulation, substitution, dispatch, dependency direction — without restating them. Interviewers expect you to connect the two layers.

on this pageshow

questions

2

The five SOLID design principles (single responsibility, open/closed, Liskov substitution, interface segregation, dependency inversion) were written down for class-based languages with implementation inheritance. Go and Rust have no implementation inheritance at all. For each of the five, name the language mechanism the principle actually spends, say which ones carry over untouched, and explain which one's classic wording has to be restated and why.

level: middleimportance: should knowfreq 45%

answer

  1. one letter spends inheritance, four spend only modules and interfaces
  2. SRP: re-pick the noun — class vs package vs process
  3. Rust DIP: dyn = vtable + heterogeneous; generic = monomorphized, one type
  4. open/closed restated: new impl, not new subclass
  5. no subclass ⇒ substitutability is contract-only ⇒ contract test suites

basics

~20 s

Single responsibility spends only a unit of change; interface segregation and dependency inversion spend only interfaces — all three carry over untouched. Open/closed's "add a subclass" wording must be restated as "add an implementation". Liskov survives as intent but loses its compiler checks.

solid answer

~1 min

Each letter spends a different mechanism, and only one spends inheritance. - **Single responsibility** spends a *unit of change*, not a class. In Go that unit is the package, so "one reason to change" is stated per package; applying it per type there yields single-type packages nobody wants. - **Interface segregation** spends interfaces and nothing else. Only the *price* of a narrow interface varies — near zero where the consumer declares a structural interface at the call site (Go's convention), a source edit in every implementer where conformance is declared by the implementer. - **Dependency inversion** spends indirection at the call site. Rust prices it two ways: `&dyn Trait` erases the type behind a vtable and lets one collection hold mixed implementations; a generic `impl Trait` parameter is monomorphized, costs nothing at runtime, but one `Vec<T>` holds a single implementation type. - **Open/closed** is the restated one: extension means a new impl, never a subclass; reuse comes from embedding or default trait methods. - **Liskov** survives as intent but loses its scaffolding — with no subclass there is no compiler-checked signature, variance or access check, so substitutability is contract-only and teams recover it with contract test suites every implementation must pass.

go deeper

for a junior

Recall that only one of the five is phrased around inheritance (open/closed) and that the other four need just modules and interfaces. Being able to say "add a new implementation instead of a new subclass" is enough.

for a middle

Give the mechanism for each letter and name the noun shift for single responsibility (class vs package). Know that Liskov loses its compiler-checked shape rules when there is no subclass.

for a senior

Carry the Rust dyn-vs-generic tradeoff concretely — vtable and heterogeneous storage against monomorphized, zero-cost, single-type — and describe contract test suites as the practical replacement for the substitutability checks the compiler no longer provides.

for a principal

Frame it as portability of design rules across paradigms: state what must be re-decided per language (the unit of change, the wording of extension, the enforcement story for substitutability) and how that changes review standards and the shape of an architecture diagram's real code cost.

## The question behind the question SOLID was written down about class-based, single-inheritance languages — Smalltalk, C++, Java, C#. Carried into Go, Rust, Elixir, or a TypeScript codebase built from functions and records, it usually gets one of two lazy treatments: "that's object-oriented dogma, ignore it," or a rote application that produces single-type packages and interfaces nobody consumes. The useful move is mechanical. For each principle ask: *what language feature does this actually spend?* Four of the five spend something any modular language already has. One spends implementation inheritance — and only in its wording, not in its intent. ## Single responsibility — spends a unit of change The principle says a module should have one reason to change. "Module" is deliberately unspecified; the paradigm supplies the candidate noun. In class-based languages the candidate is the class, which is why the textbook example is a class that both formats a report and stores it. In Go the compilation and visibility unit is the package, and idiomatic Go puts several small types sharing one job into one package; applying the rule per type there produces one-type packages and import churn nobody wants. In Erlang or Elixir the unit is the module, or a supervised process. Nothing about the principle changes — the noun it quantifies over changes. If you don't re-pick that noun deliberately, you import another language's granularity along with the rule. ## Interface segregation — spends interfaces, nothing more Splitting one fat contract into several narrow ones needs an interface mechanism and nothing else: no classes, no base types, no inheritance. It therefore carries over to Go and Rust untouched. What differs between languages is only the *price* of a narrow contract, and that price is set by who declares conformance — near zero where the consumer declares a small structural interface right beside the function that takes it (Go's convention), a source edit in every implementer and every test double where the implementer must declare conformance. Same principle either way; in one dialect it is a discipline you must remember, in the other it is the path of least resistance. ## Dependency inversion — spends indirection at the call site The principle asks that high-level policy name an abstraction rather than a concrete detail. *Any* mechanism that lets a call site name something other than a concrete type satisfies it: an interface, a trait, a protocol, a function value, a duck-typed parameter. Inheritance is not on that list, so the principle is untouched by its absence. Rust makes the engineering choice unusually visible because it offers two implementations of the same indirection, with different bills. A trait object (`&dyn Trait`, `Box<dyn Trait>`) erases the concrete type behind a vtable: dispatch is an indirect call, the binary holds one copy of the code, and a single `Vec<Box<dyn Trait>>` can hold several different implementations at once. A generic parameter (`fn run<T: Trait>(t: T)`, or `impl Trait` in argument position) is monomorphized — the compiler emits a specialized copy per concrete type, dispatch is static and inlinable, runtime cost is nil, binary size and compile time grow, and one `Vec<T>` can only ever hold a single implementation type. Both are dependency inversion. Neither is more "correct"; you pick per call site, on whether you need heterogeneous storage or a runtime-selected implementation (trait object) or a hot monomorphic path (generic). Languages without that fork simply have the choice made for them — a Java interface call is always the erased, vtable-shaped one. ## Open/closed — the one whose wording must be restated The familiar formulation — "open for extension, closed for modification: add a subclass rather than editing the class" — is a statement about implementation inheritance, and neither Go nor Rust has it. Extension there means defining a new type and giving it an impl of an existing trait, or writing a new function over an existing interface. Behaviour *reuse*, which the base class used to supply for free, comes from embedding (Go) or default trait-method bodies (Rust) instead. The restated, durable version is: *depend on an abstraction so new participants can be added without editing the code that consumes them.* The subclass was always the accident; the stable consuming code was always the point. ## Liskov substitution — survives as intent, loses its scaffolding In a subclassing language the compiler enforces a skeleton of substitutability: signature compatibility, covariant returns, no narrowing of access, variance rules on generics. Remove subclassing and that skeleton disappears. A Go type satisfying an interface has agreed to method names and shapes and to *nothing else*; a Rust impl has agreed to the trait's signatures. Preconditions, postconditions and invariants — the actual content of Liskov — are unchecked in every language, but here even the shape checks stop implying a hierarchy. So substitutability becomes purely a behavioural contract, and the practical recovery is a *contract test suite*: one shared set of tests written against the interface that every implementation must pass, plus property-based tests for the invariants prose can't pin down. ## What the mapping is worth Two payoffs. First, it tells you what to re-pick when you change languages: the noun for single responsibility, the wording for open/closed, and the enforcement story for Liskov — the rest transfers verbatim. Second, when someone says "SOLID doesn't apply to Go," the accurate reply is that four of the five apply so cheaply they stop being visible as advice, and the fifth is phrased around a mechanism the language does not have. ## How to answer in an interview Don't recite five definitions — that is table stakes. Name the mechanism each one spends, then name a language where that mechanism is absent or free and say what changes. The strongest single beat is the Rust one: the same dependency inversion has two prices, chosen per call site, which shows you treat the principle as a design constraint rather than a syntax habit.

  • Rust lets the same dependency be taken as `&dyn Trait` or as a generic parameter. When does the trait object earn its runtime cost?
    When you need heterogeneous storage or runtime selection: one collection holding several implementations, a plugin or strategy chosen from configuration, or a recursive/self-referential structure that can't be monomorphized. It also caps binary size and compile time, since the generic version emits one specialized copy per concrete type. The generic form wins on hot monomorphic paths where inlining across the boundary matters.
  • If there is no subclass, what recovers the substitutability guarantees Liskov used to get partly from the compiler?
    A contract test suite: one set of tests written against the interface or trait, which every implementation is required to run and pass, so a new impl that strengthens a precondition or breaks an invariant fails immediately. Property-based tests cover the invariants prose can't pin down, and documented trait laws state the obligations the signature can't. None of it is enforced by the type system, so it only works if adding an implementation without wiring up the suite is treated as an incomplete change.
  • What goes wrong if you apply single responsibility per type in Go the way you would per class in Java?
    You get a proliferation of one-type packages, because Go's unit of change and visibility is the package, not the type. That inflates import graphs, forces internal helpers to become exported, and invites import cycles that Go rejects outright. Idiomatic Go groups several small collaborating types with a single job in one package and states "one reason to change" about that package.

saying these in an interview costs you the question

  • "SOLID doesn't apply to Go or Rust because they aren't object-oriented" — four of the five spend only modules and interfaces, which both languages have.
  • Treating open/closed as literally meaning "subclass instead of editing" and concluding the principle is unavailable without inheritance.
  • Claiming a Rust generic parameter violates dependency inversion because it is monomorphized — the call site still names the trait; monomorphization is a compile-time detail.
  • Asserting that matching method signatures make a type Liskov-substitutable; signatures never carry preconditions, postconditions or invariants.
  • Applying single responsibility per type in Go, producing single-type packages, instead of re-picking the unit of change as the package.

context

open as a page

The substitution principle in SOLID (the "L") asks that a subtype be usable anywhere its supertype is expected without breaking callers written against the supertype. A type checker, however, only verifies shape: names, arity, parameter and return types. How much of substitutability can a language actually mechanise? Contrast Eiffel's inherited contracts with `require else` and `ensure then`, Ada 2012's split between the `Pre` aspect and the `Pre'Class` aspect, Java's and C#'s covariant arrays with their runtime `ArrayStoreException` / `ArrayTypeMismatchException`, and read-only interfaces such as Kotlin's `List` (versus `MutableList`) or C#'s `IReadOnlyList<T>`.

level: principalimportance: nice to knowfreq 26%

basics

~20 s

Type checkers verify shape only: names, arity, types. Behaviour is left to convention. Eiffel and Ada 2012 inherit contracts so a tightened precondition is unwritable; Java and C# instead mechanise an unsound rule, covariant arrays, and patch it with runtime store checks.

open as a page