Blog

SOLID, Read Critically, in C#, Java, Go and Rust

What each SOLID principle originally said, who said it, and how it gets misread. Liskov substitution broken in running code and in Java and .NET’s own libraries, dependency inversion measured with the Go build tool, and each principle in C#, Java, Go and Rust.

SOLID is the most quoted set of design principles in object-oriented programming, and one of the most misquoted. “A class should do one thing.” “Never modify existing code.” “Put an interface in front of everything.” None of those is what the principles’ authors wrote.

This part reads each principle from its source: what it said, what problem it answered, where it’s misapplied, and what it looks like in C#, Java, Go and Rust, including the two languages without class inheritance. Where a principle makes a claim you can test, we tested it. Part 6 covered cohesion, coupling and information hiding, and SOLID restates several of those ideas for classes.

Try this first

A Rectangle class has setWidth, setHeight and area. A Square extends it and keeps its sides equal: setting the width also sets the height, and the other way round. Geometrically, every square is a rectangle.

A function written against Rectangle does this:

r.setWidth(5);
r.setHeight(4);
return r.area();

What does it return for a Rectangle? What does it return for a Square? Is Square a correct subclass? Write your answers down.

Where SOLID came from

The principles are older than the acronym, and they have different authors.

  • 1987: Barbara Liskov, in a keynote at OOPSLA, described the substitution property that became the Liskov Substitution Principle.
  • 1988: Bertrand Meyer described the open-closed principle in Object-Oriented Software Construction.
  • 1995 and 1996: by his own account, Robert C. Martin started writing about a set of object-oriented design principles in March 1995 on the comp.object newsgroup. In 1996 he wrote a series of columns in The C++ Report: the open-closed principle (January 1996), Liskov substitution (March), dependency inversion (May) and interface segregation.
  • 2000: Martin’s paper “Design Principles and Design Patterns” lists four class-design principles, in the order OCP, LSP, DIP, ISP. There’s no single responsibility principle in it, and no acronym, though its package principles include a close relative: “Classes that change together, belong together.”
  • Around 2004: by Martin’s account, Michael Feathers pointed out that the principles spell SOLID if you rearrange them. In InfoQ’s summary of a 2018 interview, Martin is recorded as saying: “I think it was 2004 and Michael wrote me an e-mail, and said that if you rearranged the principles they spell SOLID.”

Martin has also been clear about what kind of thing they are. In 2009 he wrote: “The SOLID principles are not rules. They are not laws. They are not perfect truths.” He called them “heuristics”, and added: “Principles have to be applied with judgement.” That’s the licence for reading them critically.

S: the Single Responsibility Principle

The statement. Martin’s best-known wording is “A class should have one, and only one, reason to change.” His book chapter on it, from 2002, credits the idea to Tom DeMarco and Meilir Page-Jones: “They called it cohesion.”

What “reason to change” means. In 2014 Martin wrote a post to clear up the confusion the name caused. Is a bug fix a reason to change? A refactoring? No: “Certainly the code is not responsible for bug fixes or refactoring.” Then:

“And this gets to the crux of the Single Responsibility Principle. This principle is about people.”

His example is an Employee class with three methods, each specified by a different part of the business: calculatePay by the CFO’s organisation, reportHours by the COO’s, and save by the CTO’s technology organisation. Keep them in one class, and a change the CFO’s people ask for can, in his words, “inadvertently break the reportHours method” that the COO’s people rely on. His other wording is the useful one: “Gather together the things that change for the same reasons. Separate those things that change for different reasons.” He notes that “this is just another way to define cohesion and coupling.”

The common misreading: “a class should do one thing”. Part of the confusion is that Martin’s Clean Code does give a “One Thing” rule, for functions. And Martin said SRP itself began that way. In a 2005 comment he wrote that it “began as: A module should do one thing, do it well, and do it only”, and changed into “one and only one reason to change”. In his 2002 book chapter, his example goes against the folklore too. A Modem interface mixes connection management (dial, hangup) with data transfer (send, recv). Martin splits the interfaces, and keeps one class implementing both: “notice that I have recoupled the two responsibilities into a single ModemImplementation class. This is not desirable, but it may be necessary.”

The critique. Dan North, who proposed CUPID as an alternative in 2022, argues that “one and only one reason to change” is “trivially easy to refute”, because even one line can change for security, compliance or operational reasons, and that splitting code by it is “often a premature segregation with negative consequences.” Martin didn’t drop that wording: in 2014 he explained that the reasons to change are people. North’s examples, security and compliance, are people too, which is exactly where the principle gets hard to apply. North calls it “an arbitrary constraint”; Martin calls it a heuristic. You don’t have to pick a side to use the question it asks: who will ask for this code to change?

Across languages. SRP isn’t only about classes. A Go package, a Rust module and a C# assembly each have reasons to change. The test is the same everywhere: list who asks for changes to this unit. If it’s two groups with different priorities, expect their changes to collide.

O: the Open-Closed Principle

Meyer’s version, 1988. Martin later quoted Meyer’s original definition in full. A module is open “if it is available for extension”, such as adding fields or functions. It’s closed “if is available for use by other modules” [sic]: it has “a well-defined, stable description”, has been compiled into a library, or approved and published. Meyer’s mechanism for having both was inheritance. In his own 2021 summary, the principle explains “the ability to keep a module both closed (immediately usable as is) and open to extension”, which he says is done “through inheritance”, preserving the module’s basic semantics.

Martin’s version, 1996. The famous sentence is Martin’s paraphrase: “To paraphrase him: SOFTWARE ENTITIES (CLASSES, MODULES, FUNCTIONS, ETC.) SHOULD BE OPEN FOR EXTENSION, BUT CLOSED FOR MODIFICATION.” His mechanism was still inheritance, but from an abstract base class, an interface, rather than from a concrete module: “the abstractions are abstract base classes, and the unbounded group of possible behaviors is represented by all the possible derivative classes.” In 2014 he went further: “Plugin systems are the ultimate consummation, the apotheosis, of the Open-Closed Principle.”

The part that’s usually left out. The same 1996 column says: “It should be clear that no significant program can be 100% closed.” And: “Since closure cannot be complete, it must be strategic. That is, the designer must choose the kinds of changes against which to close his design.” That’s Parnas’s criterion from Part 6 again: guess what will change, and design for that, not for everything.

The common misreading: “never edit existing code”, or “add an abstraction for every future variation”. Editing code is fine. OCP is a reason to put an extension point where you expect variation, such as payment methods, discount rules or export formats, so adding one doesn’t require editing the code that uses them.

In Rust, an extension point looks like this, first with a trait, then as functions:

trait Discount {
    fn apply(&self, cents: i64) -> i64;
}

struct Percent(i64);

impl Discount for Percent {
    fn apply(&self, cents: i64) -> i64 {
        cents - cents * self.0 / 100
    }
}

// Closed: adding a new kind of discount doesn't change this function.
fn checkout(cents: i64, discounts: &[&dyn Discount]) -> i64 {
    discounts.iter().fold(cents, |total, d| d.apply(total))
}

// Added later, and nothing above had to change.
struct FirstOrder;

impl Discount for FirstOrder {
    fn apply(&self, cents: i64) -> i64 {
        (cents - 500).max(0)
    }
}

// The functional version: a rule is just a function.
fn checkout_with(cents: i64, rules: &[&dyn Fn(i64) -> i64]) -> i64 {
    rules.iter().fold(cents, |total, rule| rule(total))
}

fn main() {
    println!("{}", checkout(10_000, &[&Percent(10)]));
    println!("{}", checkout(10_000, &[&Percent(10), &FirstOrder]));

    let member = |cents: i64| cents * 95 / 100;
    let first_order = |cents: i64| (cents - 500).max(0);
    println!("{}", checkout_with(10_000, &[&member, &first_order]));
}

It prints:

9000
8500
9000

Across languages:

Language Extension without editing the caller
C# an interface or abstract class; a delegate such as Func<long, long>; default interface members (C# 8) to add a method to a published interface
Java an interface; a lambda for a functional interface; default methods to evolve a published interface
Go a small interface, satisfied without the implementer declaring it; a function value
Rust a trait, used generically or as dyn Trait; a closure

Default interface members suit OCP’s original meaning of “closed”: a published interface used by others. They need .NET Core 3.0 or later, and two interfaces supplying the same default can still create an ambiguity the implementing class must resolve. Microsoft’s guidance says “The most common scenario is to safely add members to an interface already released and used by innumerable clients.”

L: the Liskov Substitution Principle

Liskov’s statement, 1987. In her keynote “Data Abstraction and Hierarchy”, Barbara Liskov wrote:

“What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.”

(Her paper places a citation after “substitution property”, omitted here.)

Three things about it are often missed:

  • She hedged: “something like”.
  • It’s about subtypes, not subclasses. She wrote that “subclass” and “superclass” are “simply linguistic concepts in programming languages”, which “can be used to implement subtypes, but also, as mentioned above, in other ways.”
  • Names and signatures aren’t enough. “Stacks and queues might have operations of the same names”, but they aren’t subtypes of one another, “because the meanings of the operations are different for them.”

Liskov and Wing, 1994, made it precise in “A Behavioral Notion of Subtyping”: properties that can be proved about objects of type T “should hold even though the object is actually a member of a subtype of that type”. They added history constraints, properties about how an object changes over time, and gave an example that matters to every collection library: “an immutable sequence, whose elements can be fetched but not stored, is not a supertype of mutable array”.

Martin’s version, 1996, stated it for C++: “FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE CLASSES MUST BE ABLE TO USE OBJECTS OF DERIVED CLASSES WITHOUT KNOWING IT.” And he used the example from the start of this post. In 2020 he corrected his own framing: “People (including me) have made the mistake that this is about inheritance. It is not. It is about sub-typing.” So LSP applies just as much to Go interfaces and Rust traits.

The Rectangle and the Square

Step through what happens:

the same function, written against Rectangle, gets two objects r.setWidth(5); r.setHeight(4); return r.area(); // the caller expects 20 new Rectangle() new Square() 1. both start as 1 × 1 2. setWidth(5): the Square also sets its height to 5 3. setHeight(4): the Square also sets its width to 4 4. area(): 20 and 16. The Square kept its rule and broke the caller's

The areas are the output of the Java program below, run by the series’ verifier; the 1 × 1 starting size is only for the drawing. Robert Martin used this Rectangle and Square example in his 1996 column on the Liskov Substitution Principle.

void main() {
    IO.println("Rectangle: " + resizeAndMeasure(new Rectangle()));
    IO.println("Square:    " + resizeAndMeasure(new Square()));
}

// Written against Rectangle, and correct for every rectangle.
int resizeAndMeasure(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    return r.area();
}

class Rectangle {
    protected int width;
    protected int height;

    void setWidth(int w) {
        width = w;
    }

    void setHeight(int h) {
        height = h;
    }

    int area() {
        return width * height;
    }
}

// Keeps its own rule (all sides equal), and breaks the caller's.
class Square extends Rectangle {
    @Override
    void setWidth(int w) {
        width = w;
        height = w;
    }

    @Override
    void setHeight(int h) {
        width = h;
        height = h;
    }
}

It prints:

Rectangle: 20
Square:    16

So the answer to the opening question: 20 for the rectangle and 16 for the square. Square compiles, and each of its methods is correct on its own terms. But a caller who relied on setHeight not changing the width is now wrong. Martin’s conclusion: “Behaviorally, a Square is not a Rectangle! And it is behavior that software is really all about.” And: “The validity of a model can only be expressed in terms of its clients.”

The fixes are to make shapes immutable, so there are no setters whose effects can differ, or not to relate Square and Rectangle by subtyping at all.

The standard libraries do it too

Java and .NET contain well-known cases, each with a documented way around it:

void main() {
    List<String> names = Collections.unmodifiableList(new ArrayList<>(List.of("Ada")));
    try {
        names.add("Grace");
    } catch (UnsupportedOperationException e) {
        IO.println("List.add on an unmodifiable list: " + e.getClass().getSimpleName());
    }

    Object[] objects = new String[1];
    try {
        objects[0] = 42;
    } catch (ArrayStoreException e) {
        IO.println("an Integer into a String[] seen as Object[]: " + e.getClass().getSimpleName());
    }

    Stack<String> stack = new Stack<>();
    stack.push("first");
    stack.push("second");
    stack.insertElementAt("last in", 0);
    IO.println("pop() after Vector's insertElementAt: " + stack.pop());
}

It prints:

List.add on an unmodifiable list: UnsupportedOperationException
an Integer into a String[] seen as Object[]: ArrayStoreException
pop() after Vector's insertElementAt: second
using System.Collections.ObjectModel;

var names = new ReadOnlyCollection<string>(["Ada"]);

IList<string> asList = names;           // compiles: ReadOnlyCollection<T> implements IList<T>
Console.WriteLine($"IsReadOnly: {asList.IsReadOnly}");
try
{
    asList.Add("Grace");
}
catch (NotSupportedException e)
{
    Console.WriteLine($"IList<T>.Add: {e.GetType().Name}");
}

IReadOnlyList<string> readOnly = names; // the smaller interface promises only what works
Console.WriteLine($"IReadOnlyList<T>: {readOnly.Count} name, {readOnly[0]}");

object[] objects = new string[1];
try
{
    objects[0] = 42;
}
catch (ArrayTypeMismatchException e)
{
    Console.WriteLine($"an int into a string[] seen as object[]: {e.GetType().Name}");
}

It prints:

IsReadOnly: True
IList<T>.Add: NotSupportedException
IReadOnlyList<T>: 1 name, Ada
an int into a string[] seen as object[]: ArrayTypeMismatchException

What each case shows:

  • Unmodifiable collections. Java’s Collection documentation marks add as an “optional operation”: an implementation that doesn’t support it “should define the corresponding method to throw UnsupportedOperationException”. .NET puts an IsReadOnly property on ICollection<T>, and ReadOnlyCollection<T>.Add “always throws NotSupportedException”. So formally, the contracts allow it. In practice, a method that takes an IList<T> has to check IsReadOnly first, and one that takes a Java List can only try and catch the exception, since Collection has no such query.
  • Covariant arrays. In both languages a String[] can be used as an Object[], so the compiler accepts storing a number in it, and the runtime throws. The C# specification says array assignments therefore “include a run-time check”.
  • Stack extends Vector. Java’s Stack inherits Vector‘s methods, including inserting anywhere. Insert at the bottom, and the last element added isn’t the first one out. Its documentation now recommends: “A more complete and consistent set of LIFO stack operations is provided by the Deque interface and its implementations, which should be used in preference to this class.”
  • The .NET fix is an interface that promises less. IReadOnlyList<T> has no Add to throw. That’s interface segregation fixing a substitution problem.
  • Read-only isn’t immutable. Liskov and Wing’s example says an immutable sequence can’t be a supertype of a mutable array. IReadOnlyList<T> avoids that trap because it promises read access, not that the contents never change: List<T> implements it, and ReadOnlyCollection<T> is documented as “a read-only wrapper around the specified list”. Java’s documentation says the same: “An unmodifiable collection is not necessarily immutable.” Code that needs contents that never change should ask for an immutable type, such as .NET’s ImmutableList<T> or a copy made with Java’s List.copyOf.

None of these documents uses the word “Liskov”. Calling them violations is a reading, and a debatable one where the contract itself says an operation is optional.

Substitution in Go and Rust

Go checks an interface by method signatures only. The behaviour is in the documentation. io.Writer‘s documentation says: “Write must return a non-nil error if it returns n < len(p).” A type can compile as a writer and break that:

package main

import (
	"bytes"
	"fmt"
	"io"
	"strings"
)

// halfWriter compiles as an io.Writer, but breaks its documented contract:
// "Write must return a non-nil error if it returns n < len(p)."
type halfWriter struct{ buf bytes.Buffer }

// onlyReader hides strings.Reader's WriteTo method, so io.Copy runs its own copy loop.
type onlyReader struct{ io.Reader }

func (w *halfWriter) Write(p []byte) (int, error) {
	half := p[:len(p)/2]
	w.buf.Write(half)
	return len(half), nil
}

func main() {
	var good bytes.Buffer
	n, err := io.Copy(&good, onlyReader{strings.NewReader("hello, world")})
	fmt.Println("bytes.Buffer:", n, err)

	var bad halfWriter
	n, err = io.Copy(&bad, onlyReader{strings.NewReader("hello, world")})
	fmt.Println("halfWriter:  ", n, err)
}

It prints:

bytes.Buffer: 12 <nil>
halfWriter:   6 short write

io.Copy checks the contract: when a write returns fewer bytes than it was given and no error, it stops and returns io.ErrShortWrite, which prints as “short write”. (The onlyReader wrapper hides strings.Reader‘s own WriteTo method, which would otherwise do the copy and make the same check.) Code that calls Write directly and ignores the count would lose half the data silently. In Go, substitutability is kept by documentation and tests, not by the compiler.

Go has no inheritance, but embedding can recreate the Stack problem. Embedding a type in a struct promotes all of its methods, and they count towards satisfying interfaces. A Counter that embeds sync.Mutex exposes Lock and Unlock to every caller. Embed only what the outer type should offer, or keep the inner value in an unexported field.

Rust has no implementation inheritance. Its book says: “There is no way to define a struct that inherits the parent struct’s fields and method implementations without using a macro.” It shares code in two limited ways: default method implementations in a trait, and supertraits, where one trait requires another. The same substitution rule applies to every type that implements a trait: its documented contract is what callers rely on.

Variance is the type-checked half of substitution. C# lets a generic interface declare its type parameters out (covariant) or in (contravariant). IEnumerable<out T> can safely treat a sequence of strings as a sequence of objects, because it only hands values out. That’s the rule the covariant arrays above skipped, and why they need a run-time check. Liskov and Wing noted that such rules are necessary and not enough: “type checking, while very useful, captures only a small part of what it means for a program to be correct”.

Explain it like I’m ten

You have a remote-control car, and a set of instructions: “press forward for two seconds, and the car moves one metre.” Your friend gives you a new car that fits the same remote. You press forward for two seconds, and it spins in a circle. It fits the remote, but it doesn’t do what the instructions say.

A good replacement doesn’t only fit. It keeps every promise the instructions made, so anyone who learned to drive the first car can drive the new one without being surprised.

The precise version

  • The remote is the type a program is written against: Rectangle, IList<T>, io.Writer.
  • “Fits the remote” is compiling: the same method names and signatures.
  • “Does what the instructions say” is behavioural subtyping: everything the program could rely on about the original type still holds for the replacement, including how its state changes over time.
  • Where the analogy breaks: instructions for software are often incomplete. Formally, Liskov and Wing’s test is the specification: what can be proved from it. When the specification is silent, as it is about whether setHeight may change the width, Martin’s practical answer is to ask what clients reasonably assume, since a model can only be validated “in terms of its clients”. That’s a reason to write the contract down.

I: the Interface Segregation Principle

The statement, from Martin’s 1996 column: “CLIENTS SHOULD NOT BE FORCED TO DEPEND UPON INTERFACES THAT THEY DO NOT USE.” The reason: “When clients are forced to depend upon interfaces that they don’t use, then those clients are subject to changes to those interfaces.”

What it was about. In C++, a class that depended on a “fat” interface had to be recompiled whenever any part of it changed, even parts it never called. Martin’s 2020 restatement keeps the point for today’s languages: “Clients do depend on methods they don’t call, if they have to be recompiled and redeployed when one of those methods is modified.”

What it didn’t say. It doesn’t say every interface should have one method, or that a class should be small. Martin’s column allows for “objects that require non-cohesive interfaces”, as long as clients see them through smaller, cohesive interfaces. One class can implement several.

Across languages:

  • C# and Java: split interfaces by client: IReadOnlyList<T> for readers, IList<T> for code that changes a list. One class implements both.
  • Go: small interfaces are the norm. Effective Go: “Interfaces with only one or two methods are common in Go code”. Rob Pike’s Go proverb, as collected by the Go community, is “The bigger the interface, the weaker the abstraction.” io.Reader has one method, and anything from a file to a network connection satisfies it.
  • Rust: a trait meant to be used as dyn Trait must be dyn compatible, and a method with type parameters breaks that. So the compiler pushes you to separate generic methods from the ones meant to be called through dyn:
trait Store {
    fn put<T: ToString>(&mut self, key: &str, value: T);
}

struct Memory;

impl Store for Memory {
    fn put<T: ToString>(&mut self, _key: &str, _value: T) {}
}

fn main() {
    let stores: Vec<Box<dyn Store>> = vec![Box::new(Memory)];
    println!("{}", stores.len());
}

The compiler reports:

error[E0038]: the trait `Store` is not dyn compatible

The Rust Reference lists the rule: a dispatchable method must “Not have any type parameters”. There are two ways out. Split the generic method into its own trait, or keep it and mark it where Self: Sized, which the Reference calls “explicitly non-dispatchable”: the trait then works as dyn Store, and that one method just can’t be called through it. The property was “formerly known as object safety“, a name older material still uses.

D: the Dependency Inversion Principle

The statement, from Martin’s 1996 column:

“A. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS. B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS.”

Why “inversion”. Martin explained that structured design tended to produce programs “in which high level modules depend upon low level modules”, and that a well-designed object-oriented program is “inverted” compared with that. The business rules shouldn’t import the database. The database code should implement something the business rules define.

Who owns the interface. Both Microsoft and the Go project say the same thing in different words. Microsoft’s architecture guide: A calls “an abstraction that B implements”, so that B depends “on an interface controlled by A at compile time”. Go’s Code Review Comments: “Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values.” The deliberate exception is a shared standard contract that many packages implement and many consume, such as Go’s io.Writer or Java’s java.sql.Connection: it belongs to neither side.

Measured: which side a change reaches

We wrote a small Go program three ways. An orders package holds the business rule (an order must cost something), and a postgres package stores orders.

  • Before: orders imports postgres and calls it directly.
  • After: orders defines a one-method Store interface. postgres imports orders and implements it. main wires them together.
  • Contract: the Store interface and the Order type sit in a small store package of their own, which both orders and postgres import.

Then, in each, we made two separate changes, one error message at a time: one inside postgres, and one inside the business rule in orders. After each, we asked the Go build tool which packages it had to recompile. Choose a design:

an arrow means "imports". Red: stale after a change to the database adapter cmd/appwires things together ordersbusiness rules postgresdatabase adapter storethe contract: Order, Store

Import graphs, stale packages and recompiled library packages as reported by go list and go build -x, each after changing one error message, by checks/part07_dip/run.py. Recompiling here takes milliseconds: what the graph shows is which side has to change when the other does.

Before After Contract
orders imports postgres nothing from the app store
postgres imports nothing from the app orders store
Change inside postgres: library packages recompiled orders, postgres postgres postgres
Change to a business rule in orders: library packages recompiled orders orders, postgres orders
The orders test imports postgres nothing from the app store

Read the middle column carefully. Inverting the dependency didn’t remove it: it turned it round. A database change no longer reaches the business rules, but a business-rule change now reaches the adapter. That’s DIP’s bet, in Martin’s words from 2000: “concrete things change alot, abstract things change much less frequently” [sic]. The policy shouldn’t be at the mercy of the details.

If neither side should be rebuilt for the other’s changes, give the contract a package of its own, as the third design does: each change then recompiles only the package that changed. Recompiling here takes milliseconds, and other toolchains decide differently when to recompile, so the lasting gains aren’t build time. They’re which code has to change, and be re-tested, when the other side does, and a test for the business rules that doesn’t need the database. This is the after version’s orders package:

// Package orders holds the business rules, and the interface they need.
package orders

import "errors"

type Order struct {
	ID    string
	Cents int64
}

// Store is owned by the code that uses it. Any database can satisfy it.
type Store interface {
	Insert(o Order) error
}

type Service struct{ store Store }

func NewService(store Store) *Service { return &Service{store: store} }

func (s *Service) Place(id string, cents int64) error {
	if cents <= 0 {
		return errors.New("an order must cost something")
	}
	return s.store.Insert(Order{ID: id, Cents: cents})
}

DIP, dependency injection and inversion of control are different things

  • The dependency inversion principle is about the direction of source-code dependencies.
  • Inversion of control is broader: a framework calls your code rather than your code calling it. Martin Fowler wrote in 2004 that calling a container special because it uses inversion of control “is like saying my car is special because it has wheels.”
  • Dependency injection is a specific technique: something outside an object supplies its dependencies, usually through the constructor. Fowler and the inversion-of-control advocates he talked with settled on that name because “Inversion of Control is too generic a term”.

You can have one without the others. The Go program above follows DIP with no container at all: main passes the store in by hand. And you can inject a concrete class through a container without inverting any dependency.

.NET builds a container in. Microsoft.Extensions.DependencyInjection registers services with a lifetime: transient (a new instance each time), scoped (one per scope, and ASP.NET Core creates a scope per request) or singleton (one for the application). The mistake to know is the captive dependency, a term Microsoft credits to Mark Seemann: “a longer-lived service holds a shorter-lived service captive”. With ValidateOnBuild, the container checks every registration when it’s built, and refuses; ValidateScopes on its own would catch the problem only when the service is resolved:

#:package Microsoft.Extensions.DependencyInjection@10.0.0
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<PriceCache>();    // lives for the whole app
services.AddScoped<RequestContext>();   // one per request

try
{
    services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true });
}
catch (AggregateException e)
{
    foreach (var inner in e.InnerExceptions) Console.WriteLine(inner.Message);
}

sealed class RequestContext
{
    public string User { get; set; } = "anonymous";
}

// A singleton holding a scoped service would keep the first request's context forever.
sealed class PriceCache(RequestContext context)
{
    public string Owner => context.User;
}

It prints:

Error while validating the service descriptor 'ServiceType: PriceCache Lifetime: Singleton ImplementationType: PriceCache': Cannot consume scoped service 'RequestContext' from singleton 'PriceCache'.

The message comes from an InvalidOperationException, wrapped in an AggregateException because the container reports every bad registration at once. The .NET host builders, including ASP.NET Core’s, turn this validation on when the app runs in the development environment. It can’t see services resolved later through a factory or IServiceProvider, which is one more reason to avoid that style. Part 13 covers dependency direction at the scale of a whole application: layers, hexagonal architecture and a functional core.

Across languages: Java applications commonly use a container such as Spring’s, or pass dependencies by hand. Go and Rust programs mostly wire dependencies by hand in main, and a function parameter is often all the “injection” they need.

The five principles, side by side

Originally Common misreading Read it as
S “one and only one reason to change”, later “about people” (Martin) “a class does one thing” group code by who asks for its changes
O modules both usable as-is and extensible (Meyer, 1988, through inheritance); via abstract interfaces, and “must be strategic” (Martin, 1996) “never edit code”, or “abstract everything” add extension points where you expect variation
L a subtype keeps the behaviour programs rely on (Liskov, 1987; Liskov and Wing, 1994) “about inheritance”, or “matching signatures is enough” every implementation keeps its interface’s promises
I clients shouldn’t depend on methods they don’t use (Martin, 1996) “one method per interface” give each kind of client the interface it needs
D high-level policy and low-level detail both depend on abstractions (Martin, 1996) “use a DI container” the business rules own the interfaces; adapters implement them

Trade-offs

Abstractions have a cost. Every interface is a file, a name, an indirection and something to keep in sync. OCP and DIP pay off where variation or substitution actually happens, and add noise where it doesn’t. Martin’s own “strategic” closure is the guide.

More, smaller pieces aren’t always better. John Ousterhout and Robert Martin’s 2024–25 debate covered method length, comments and test-driven development. On decomposition they agreed “that it is possible to over-decompose”. Martin defended splitting code by the One Thing rule and by SRP, and summed up: “we disagree on the relative weighting of those two values.”

Principles or properties. Dan North argues that “Principles are like rules: you are either compliant or you are not”, and prefers describing properties of good code. Replying in 2020 to North’s earlier talk on the subject, Martin wrote that “The best way to make a complicated mess is to tell everyone to “just be simple” and give them no further guidance.” Both are useful: the principles name failure modes, and judgement decides when they apply.

Inheritance is the riskiest reuse. The Rust book gives the reason: subclasses “shouldn’t always share all characteristics of their parent class but will do so with inheritance.” Stack extends Vector and Square extends Rectangle are that risk realised. Part 8 covers abstraction without inheritance.

Common mistakes

An interface per class, created in advance. Go’s review guidance is blunt: “Do not define interfaces before they are used: without a realistic example of usage, it is too difficult to see whether an interface is even necessary”. Create one when a second implementation or a test seam needs it.

Splitting code until each class has one method. That’s the “do one thing” misreading of SRP. Split by reasons to change, and keep code that changes together in one place.

Subclassing for code reuse. Square extends Rectangle reuses fields and breaks callers. Prefer composition, or a shared interface without shared state.

Throwing “not supported” from an interface you implement. Every caller now has to know which implementation it has. Offer a smaller interface instead, as IReadOnlyList<T> does.

Putting the interface next to the implementation. An IOrderRepository in the database project, which the business logic then imports, points the dependency the wrong way. Put it where it’s used.

Equating dependency injection with good design. A container that injects concrete classes everywhere, with a service locator for the hard cases, keeps all the coupling and hides it. Microsoft’s guidelines say: “Avoid using the service locator pattern.”

Registering a scoped service inside a singleton. It becomes a singleton in disguise, sharing one request’s data with every other request. Keep scope validation on.

Interview questions

Try to answer each one before opening the model answer.

1. What does the Single Responsibility Principle actually mean?

Show a strong answer
  • Martin’s wording: a module should have one reason to change. In 2014 he clarified that reasons to change are people: “This principle is about people.”
  • In practice: group code by who requests its changes. His example is an Employee class whose pay calculation (the CFO’s organisation), hours report (the COO’s) and persistence (the CTO’s) change for different people, so a change for one can break another.
  • Also stated as: “Gather together the things that change for the same reasons. Separate those things that change for different reasons.” That’s cohesion and coupling.
  • Not: “a class does one thing” or “small classes”. Martin said the principle started as “do one thing” and changed.

Likely follow-up: “How do you find the reasons to change?” Look at who requests changes (product areas, teams, regulators), and at the version history for code that changes together for different tickets.

2. Explain the Liskov Substitution Principle with an example that isn’t Rectangle and Square.

Show a strong answer
  • Principle: a subtype must keep the behaviour that programs written against its supertype rely on, not just its method signatures. Liskov (1987) framed it as programs’ behaviour being “unchanged” under substitution.
  • Example: a read-only collection used as a mutable list. .NET’s ReadOnlyCollection<T> implements IList<T>, and its Add “always throws NotSupportedException”. Java’s unmodifiable lists throw UnsupportedOperationException. Code written against the list interface fails at run time.
  • Other examples: covariant arrays that throw on store; a Go io.Writer that returns a short count with no error.
  • Fixes: a smaller interface that promises less (IReadOnlyList<T>), immutability, or composition instead of inheritance.
  • Nuance: Java and .NET write the escape hatch into the contract (“optional operation”, IsReadOnly), so formally it’s allowed, and the cost moves to callers checking at run time.

Likely follow-up: “Does LSP apply in Go, which has no inheritance?” Yes. Any type satisfying an interface is a subtype of it. Martin wrote in 2020: “All implementations of interfaces are subtypes of an interface.”

3. What’s the difference between dependency inversion, dependency injection and inversion of control?

Show a strong answer
  • Dependency inversion (DIP): a design principle about which way source dependencies point. High-level policy defines abstractions; low-level details implement them.
  • Inversion of control: a general property of frameworks: the framework calls your code.
  • Dependency injection: a technique where dependencies are supplied from outside, usually via constructors. Martin Fowler named it in 2004 because inversion of control was “too generic a term”.
  • Independence: you can follow DIP with hand wiring in main, and you can use a DI container to inject concrete classes without inverting anything.
  • Evidence it matters: in a Go program where the business rules owned the Store interface, changing the database adapter no longer recompiled the business package, and its test no longer needed the database. The cost moved: a business-rule change now recompiled the adapter. Putting the interface in its own contract package removed both.

Likely follow-up: “Where should the interface live?” With the code that uses it. Microsoft calls it “an interface controlled by A”; Go’s guidance says interfaces belong “in the package that uses values of the interface type”.

4. Is “open for extension, closed for modification” practical? Doesn’t all code get modified?

Show a strong answer
  • Origin: Meyer (1988) meant a module that’s stable and usable by others (closed) and can still be extended (open), through inheritance. The slogan is Martin’s 1996 paraphrase, where the extension is through an abstract base class rather than a concrete module.
  • Martin’s own limit: “no significant program can be 100% closed”; closure “must be strategic”.
  • Practical reading: put extension points where variation is expected (payment methods, pricing rules, export formats), so adding a variant doesn’t require editing the code that uses them. Elsewhere, edit freely.
  • Tools: interfaces, traits and function values; for published interfaces, default interface members in C# and default methods in Java.

Likely follow-up: “What’s the cost of applying OCP everywhere?” Speculative abstractions: extra interfaces and plug-in points that no second variant ever uses, which make the code harder to follow.

5. How does the Interface Segregation Principle apply in Go and Rust?

Show a strong answer
  • Principle: clients shouldn’t depend on methods they don’t use, because they get coupled to changes in them.
  • Go: small interfaces defined by the consumer are idiomatic: io.Reader, io.Writer, and one-method interfaces in the package that needs them. Because satisfaction is implicit, a large concrete type can satisfy many small interfaces without declaring any of them.
  • Rust: traits are split by capability, and code asks for exactly the bounds it needs, such as T: Read. A trait with generic methods isn’t dyn compatible, so a trait meant for dyn use either splits those methods into another trait or marks them where Self: Sized.
  • Not: “one method per interface” as a rule. Martin allowed classes with non-cohesive interfaces, provided clients see cohesive ones.

Likely follow-up: “What does a fat interface cost in a language without recompilation issues?” Harder test doubles, implementations forced to stub methods they don’t support, and every change to the interface touching every implementer.

6. Why is Square extends Rectangle a problem if a square is a rectangle?

Show a strong answer
  • Because software models behaviour, not geometry. A mutable Rectangle promises that setHeight doesn’t change the width. Square must break that to keep its sides equal.
  • The run: a function that sets width 5 and height 4 returns 20 for a Rectangle and 16 for a Square.
  • Martin: “Behaviorally, a Square is not a Rectangle!” and a model’s validity “can only be expressed in terms of its clients”.
  • Fixes: immutable shapes (a new shape instead of setters), a common Shape interface with area() and no setters, or no subtype relation.

Likely follow-up: “Would it be fine if Rectangle were immutable?” Mostly, yes: with no setters, the width-and-height promise can’t be broken. Other behaviour can still differ, such as an equals that compares classes, or a withWidth method that returns a Square, so check the whole contract.

7. When would you not apply SOLID?

Show a strong answer
  • Small or short-lived code: scripts, prototypes, one-off tools, where extension points will never be used.
  • Where there’s one implementation and no test seam needed: an interface adds indirection without hiding anything.
  • When splitting would scatter code that changes together: over-decomposition makes readers jump between many small pieces.
  • In performance-critical inner loops: dynamic dispatch through an interface can block inlining. Rust’s book notes it “prevents some optimizations”. The .NET and Java JIT compilers, and Go with profile-guided optimisation, can often remove that cost, and generics give static dispatch, so measure before trading a design away for speed. Part 8 measures it.
  • Martin himself: “The SOLID principles are not rules.” They’re heuristics for recognised problems. Apply them when you see the problem, such as rigid or fragile code, not by default.

Likely follow-up: “How do you tell whether an abstraction has earned its place?” It has a second implementation, protects a boundary that changes independently, or makes a test possible that otherwise isn’t.

8. In ASP.NET Core, what’s a captive dependency and how do you avoid it?

Show a strong answer
  • Definition: a longer-lived service holds a shorter-lived one, such as a singleton that takes a scoped service in its constructor. The scoped service effectively becomes a singleton, sharing one request’s state with all later requests.
  • Detection: with scope validation on, the container throws “Cannot consume scoped service ‘RequestContext’ from singleton ‘PriceCache’.” The .NET host builders enable it in the development environment.
  • Fixes:
  • Make the consumer scoped.
  • Make the dependency singleton-safe.
  • If a singleton truly needs scoped work, create a scope per operation with IServiceScopeFactory, and dispose of it.
  • Avoid: resolving services from IServiceProvider inside business code, the service locator pattern, which hides these problems.

Likely follow-up: “What lifetime should an EF Core DbContext have?” Scoped, one per request, which is what AddDbContext registers by default. A DbContext tracks the changes of one unit of work and isn’t safe to share between threads.

Sources

What to remember

  • SOLID is five heuristics from several authors, collected and named over about fifteen years. Martin, who collected them, says they’re “not rules”.
  • SRP: group code by who asks for its changes, not by “one thing”.
  • OCP: put extension points where you expect variation. Closure “must be strategic”.
  • LSP: an implementation must keep its type’s promises, not just its signatures. It applies to interfaces and traits as much as to subclasses.
  • ISP: give each kind of client the interface it needs, and no more.
  • DIP: the business rules own the interfaces, and the details implement them. Moving the interface kept database changes out of the business package and its test, and turned the dependency round rather than removing it; a separate contract package keeps each side’s changes to itself.
  • Dependency injection is one way to supply dependencies. It isn’t the same as dependency inversion, and a container doesn’t make a design good.

Read a principle by the problem it was written to solve. When you don’t have that problem, you don’t need the principle.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.