SOLID is five principles of object-oriented design: single responsibility, open-closed, Liskov substitution, interface segregation and dependency inversion. Robert C. Martin gathered them. His own summary on The Principles of OOD is that they “focus very tightly on dependency management”. Each one is about what a piece of code depends on, and what has to change when something it depends on changes.
Each principle below has a plain statement, the position the Prickles canon takes on it and a short example in TypeScript, JavaScript, Python, Java and PHP. The canon here means the 51 tenets this site publishes. It keeps four of the five SOLID principles as tenets. Open-closed has no tenet of its own, because F4 Simplicity covers it. That tenet says not to add the extension point until the second caller arrives.
SOLID design principles at a glance
| Letter | Principle | In one line | In the canon |
|---|---|---|---|
| S | Single responsibility | Each unit has one reason to change | F1 Single Responsibility Principle |
| O | Open-closed | Add behaviour by adding code, and leave working code alone | Part of F4 Simplicity |
| L | Liskov substitution | A subtype works wherever its parent type is promised | A9 Liskov Substitution Principle |
| I | Interface segregation | No code depends on methods it doesn’t use | A10 Interface Segregation Principle |
| D | Dependency inversion | Business logic and the detail it uses both depend on an abstraction | A11 Dependency Inversion Principle |
Where SOLID came from
On his Principles of OOD page, Martin traces the “first glimmer” of the principles to an article he posted to the comp.object newsgroup in March 1995. That page lists all five as principles of class design, with single responsibility first. For the detail, it points to his book Agile Software Development, Principles, Patterns, and Practices (2002).
Two of the five began with other people. Martin’s paper Design Principles and Design Patterns (2000) credits open-closed to Bertrand Meyer. Wikipedia’s entry for the open-closed principle places the term in Meyer’s book Object-Oriented Software Construction (1988). The same paper credits Liskov substitution to Barbara Liskov. Wikipedia’s article on the Liskov substitution principle dates it to her 1987 conference keynote, Data Abstraction and Hierarchy.
Last came the acronym, usually credited to Michael Feathers. Wikipedia cites Martin’s Clean Architecture for that. Martin told the story himself in a 2018 InfoQ podcast:
I think it was 2004 and Michael wrote me an e-mail, and said that if you rearranged the principles they spell SOLID.
Examples in five languages
All the examples use one small shop. An order there has a reference, the customer’s email address and its lines. Each line has a unit price in pence and a quantity, and prices stay in whole pence until a receipt prints them. The later examples reuse the Order type and the two functions from the first.
Each example shows code that follows the principle. The text around it describes a version that breaks it. Every example is written to the limits the Prickles lint packs set, where cyclomatic complexity counts the separate paths through a function:
- cyclomatic complexity of 3 or less, as S2 Cyclomatic Caps sets
- at most 15 lines and 3 parameters a function
- no comments
- braces on every
if - a name for every number other than -1, 0 and 1
Single responsibility principle
A unit of code should have one reason to change. In Solid Relevance (2020), Martin puts it this way: “Gather together the things that change for the same reasons. Separate things that change for different reasons.”
A checkout function that adds up an order and also writes the receipt has two reasons to change. A new discount rule changes the total, and new wording changes the receipt. Split into two functions, each has one:
const PENCE_PER_POUND = 100;
const poundFormat = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });
export interface OrderLine {
readonly unitPence: number;
readonly quantity: number;
}
export interface Order {
readonly reference: string;
readonly customerEmail: string;
readonly lines: readonly OrderLine[];
}
export const orderTotalPence = (order: Order): number =>
order.lines.reduce((total, line) => total + line.unitPence * line.quantity, 0);
export const receiptText = (totalPence: number): string =>
`Thank you for your order. Total: ${poundFormat.format(totalPence / PENCE_PER_POUND)}`; const PENCE_PER_POUND = 100;
const poundFormat = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });
export interface OrderLine {
readonly unitPence: number;
readonly quantity: number;
}
export interface Order {
readonly reference: string;
readonly customerEmail: string;
readonly lines: readonly OrderLine[];
}
export const orderTotalPence = (order: Order): number =>
order.lines.reduce((total, line) => total + line.unitPence * line.quantity, 0);
export const receiptText = (totalPence: number): string =>
`Thank you for your order. Total: ${poundFormat.format(totalPence / PENCE_PER_POUND)}`; const PENCE_PER_POUND = 100;
const poundFormat = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });
export const orderTotalPence = (order) =>
order.lines.reduce((total, line) => total + line.unitPence * line.quantity, 0);
export const receiptText = (totalPence) =>
`Thank you for your order. Total: ${poundFormat.format(totalPence / PENCE_PER_POUND)}`; from dataclasses import dataclass
PENCE_PER_POUND = 100
@dataclass(frozen=True)
class OrderLine:
unit_pence: int
quantity: int
@dataclass(frozen=True)
class Order:
reference: str
customer_email: str
lines: tuple[OrderLine, ...]
def order_total_pence(order: Order) -> int:
return sum(line.unit_pence * line.quantity for line in order.lines)
def receipt_text(total_pence: int) -> str:
return f"Thank you for your order. Total: £{total_pence / PENCE_PER_POUND:,.2f}" public record OrderLine(int unitPence, int quantity) {}
public record Order(String reference, String customerEmail, List<OrderLine> lines) {}
public final class Pricing {
public static int orderTotalPence(Order order) {
return order.lines().stream().mapToInt(line -> line.unitPence() * line.quantity()).sum();
}
}
public final class Receipts {
private static final double PENCE_PER_POUND = 100.0;
public static String receiptText(int totalPence) {
double pounds = totalPence / PENCE_PER_POUND;
return String.format(Locale.UK, "Thank you for your order. Total: £%,.2f", pounds);
}
} const PENCE_PER_POUND = 100;
const DECIMAL_PLACES = 2;
final readonly class OrderLine
{
public function __construct(public int $unitPence, public int $quantity)
{
}
}
final readonly class Order
{
public function __construct(
public string $reference,
public string $customerEmail,
public array $lines,
) {
}
}
function orderTotalPence(Order $order): int
{
return array_sum(array_map(
fn (OrderLine $line): int => $line->unitPence * $line->quantity,
$order->lines,
));
}
function receiptText(int $totalPence): string
{
$pounds = number_format($totalPence / PENCE_PER_POUND, DECIMAL_PLACES);
return "Thank you for your order. Total: £{$pounds}";
} Prickles treats F1 Single Responsibility Principle as the keystone of the canon. Its working test asks who pays when the unit changes. The tenet also warns against the literal reading, because one reason to change doesn’t mean the smallest function you can write. The line and complexity limits are there to flag a tangle early, and deciding what the one job is stays with whoever writes and reviews the code.
Open-closed principle
Code should be open for extension and closed for modification. You add behaviour by adding code, without editing code that already works. In his 2000 paper, Martin ranks it above the others:
Of all the principles of object oriented design, this is the most important.
Prickles takes a different view on timing, so open-closed sits inside F4 Simplicity and has no tenet of its own. F4’s rule is to build for the requirement at hand and leave out the parameter, flag, abstraction or option until a second caller needs it. An extension point added early is code written for a caller that doesn’t exist yet.
Suppose the shop starts with one delivery price, held in a single named constant, with nothing to extend. A delivery-pricing interface with one implementation would follow open-closed to the letter, with only the one price behind it. When next-day delivery arrives, it brings the need for an extension point with it. The smallest one that serves both methods is a table:
const DELIVERY_PENCE = { standard: 395, nextDay: 695 } as const;
export type DeliveryMethod = keyof typeof DELIVERY_PENCE;
export const deliveryPence = (method: DeliveryMethod): number => DELIVERY_PENCE[method]; const DELIVERY_PENCE = { standard: 395, nextDay: 695 } as const;
export type DeliveryMethod = keyof typeof DELIVERY_PENCE;
export const deliveryPence = (method: DeliveryMethod): number => DELIVERY_PENCE[method]; const DELIVERY_PENCE = Object.freeze({ standard: 395, nextDay: 695 });
export const deliveryPence = (method) => DELIVERY_PENCE[method]; from typing import Literal
DeliveryMethod = Literal["standard", "next_day"]
DELIVERY_PENCE: dict[DeliveryMethod, int] = {"standard": 395, "next_day": 695}
def delivery_pence(method: DeliveryMethod) -> int:
return DELIVERY_PENCE[method] public enum Delivery {
STANDARD(395),
NEXT_DAY(695);
private final int pence;
Delivery(int pence) {
this.pence = pence;
}
public int pence() {
return pence;
}
} enum Delivery
{
case STANDARD;
case NEXT_DAY;
private const PENCE = ['STANDARD' => 395, 'NEXT_DAY' => 695];
public function pence(): int
{
return self::PENCE[$this->name];
}
} A third method now means adding to the table. The code that reads the table doesn’t change. That’s still open-closed, with the extension point added when a second caller needed it. Martin Fowler’s Yagni (2015) makes the same case for waiting. YAGNI stands for “You Aren’t Gonna Need It”, and Fowler warns that an extensibility point that’s never used is “likely to also get in your way as well”. He’s also clear that YAGNI doesn’t apply to work that makes code easier to change, so keeping each unit small and well named still counts.
Liskov substitution principle
A subtype must work wherever its parent type is promised, so code written against the parent can’t tell which subtype it has. In practice, a subtype accepts every input the parent accepts, guarantees every result the parent guarantees and throws nothing the parent didn’t.
Couriers give the shop delivery quotes. In this shop, letter post takes nothing over 100 g. If its quote method threw an error above that weight, every caller written against Courier would break on the first heavy parcel. So the contract itself says a courier may decline, and letter post declines by returning no quote:
const LETTER_LIMIT_GRAMS = 100;
const LETTER_PENCE = 85;
export interface Courier {
quotePence(weightGrams: number): number | undefined;
}
export class LetterPost implements Courier {
quotePence(weightGrams: number): number | undefined {
return weightGrams > LETTER_LIMIT_GRAMS ? undefined : LETTER_PENCE;
}
}
export const quotesPence = (couriers: readonly Courier[], weightGrams: number): number[] =>
couriers.flatMap((courier) => courier.quotePence(weightGrams) ?? []); const LETTER_LIMIT_GRAMS = 100;
const LETTER_PENCE = 85;
export interface Courier {
quotePence(weightGrams: number): number | undefined;
}
export class LetterPost implements Courier {
quotePence(weightGrams: number): number | undefined {
return weightGrams > LETTER_LIMIT_GRAMS ? undefined : LETTER_PENCE;
}
}
export const quotesPence = (couriers: readonly Courier[], weightGrams: number): number[] =>
couriers.flatMap((courier) => courier.quotePence(weightGrams) ?? []); const LETTER_LIMIT_GRAMS = 100;
const LETTER_PENCE = 85;
export class LetterPost {
quotePence(weightGrams) {
return weightGrams > LETTER_LIMIT_GRAMS ? undefined : LETTER_PENCE;
}
}
export const quotesPence = (couriers, weightGrams) =>
couriers.flatMap((courier) => courier.quotePence(weightGrams) ?? []); from typing import Protocol
LETTER_LIMIT_GRAMS = 100
LETTER_PENCE = 85
class Courier(Protocol):
def quote_pence(self, weight_grams: int) -> int | None:
...
class LetterPost(Courier):
def quote_pence(self, weight_grams: int) -> int | None:
if weight_grams > LETTER_LIMIT_GRAMS:
return None
return LETTER_PENCE
def quotes_pence(couriers: list[Courier], weight_grams: int) -> list[int]:
quotes = (courier.quote_pence(weight_grams) for courier in couriers)
return [quote for quote in quotes if quote is not None] public interface Courier {
OptionalInt quotePence(int weightGrams);
}
public final class LetterPost implements Courier {
private static final int LIMIT_GRAMS = 100;
private static final int PENCE = 85;
@Override
public OptionalInt quotePence(int weightGrams) {
if (weightGrams > LIMIT_GRAMS) {
return OptionalInt.empty();
}
return OptionalInt.of(PENCE);
}
}
public final class Quotes {
public static List<Integer> quotesPence(List<Courier> couriers, int weightGrams) {
return couriers.stream()
.flatMapToInt(courier -> courier.quotePence(weightGrams).stream())
.boxed()
.toList();
}
} interface Courier
{
public function quotePence(int $weightGrams): ?int;
}
final class LetterPost implements Courier
{
private const LIMIT_GRAMS = 100;
private const PENCE = 85;
public function quotePence(int $weightGrams): ?int
{
if ($weightGrams > self::LIMIT_GRAMS) {
return null;
}
return self::PENCE;
}
}
function quotesPence(array $couriers, int $weightGrams): array
{
$quotes = array_map(
fn (Courier $courier): ?int => $courier->quotePence($weightGrams),
$couriers,
);
return array_values(array_filter($quotes, fn (?int $quote): bool => $quote !== null));
} quotesPence handles a missing quote because the contract says one can be missing. It never checks which courier it has. A9 Liskov Substitution Principle reads every override against its parent’s inputs, results and invariants, meaning the conditions the parent promises always hold. Its answer to “prefer composition over inheritance” is that Liskov covers the times you do inherit, so the two rules don’t conflict.
Interface segregation principle
No code should depend on methods it doesn’t use, so the code that calls an interface decides its shape.
Two callers use the shop’s order store, each for its own job. The dispatch email only looks orders up, and checkout only saves them. A single OrderStore interface with both methods would tie the dispatch email to save, so a change to saving could reach code that never saves. Splitting it into two narrow interfaces keeps them apart. The database adapter implements both:
export interface OrderReader {
find(reference: string): Promise<Order | undefined>;
}
export interface OrderWriter {
save(order: Order): Promise<void>;
}
export const dispatchSubject = async (
orders: OrderReader,
reference: string
): Promise<string | undefined> => {
const order = await orders.find(reference);
return order ? `Order ${order.reference} is on its way` : undefined;
}; export interface OrderReader {
find(reference: string): Promise<Order | undefined>;
}
export interface OrderWriter {
save(order: Order): Promise<void>;
}
export const dispatchSubject = async (
orders: OrderReader,
reference: string
): Promise<string | undefined> => {
const order = await orders.find(reference);
return order ? `Order ${order.reference} is on its way` : undefined;
}; export const dispatchSubject = async (orders, reference) => {
const order = await orders.find(reference);
return order ? `Order ${order.reference} is on its way` : undefined;
}; from typing import Protocol
class OrderReader(Protocol):
def find(self, reference: str) -> Order | None:
...
class OrderWriter(Protocol):
def save(self, order: Order) -> None:
...
def dispatch_subject(orders: OrderReader, reference: str) -> str | None:
order = orders.find(reference)
if order is None:
return None
return f"Order {order.reference} is on its way" public interface OrderReader {
Optional<Order> find(String reference);
}
public interface OrderWriter {
void save(Order order);
}
public final class DispatchEmail {
public static Optional<String> subject(OrderReader orders, String reference) {
return orders.find(reference)
.map(order -> "Order " + order.reference() + " is on its way");
}
} interface OrderReader
{
public function find(string $reference): ?Order;
}
interface OrderWriter
{
public function save(Order $order): void;
}
function dispatchSubject(OrderReader $orders, string $reference): ?string
{
$order = $orders->find($reference);
return $order === null ? null : "Order {$order->reference} is on its way";
} JavaScript has no interface keyword. There, the methods a function calls make up its interface, and dispatchSubject calls only find. In every language, a test of the dispatch email needs a fake with one method.
A10 Interface Segregation Principle applies the split when callers have separate needs, and accepts that more interfaces means more names to keep. Where the callers need the same methods, one interface is fine.
Dependency inversion principle
Business logic shouldn’t depend on low-level detail. Both should depend on an abstraction that the business logic owns. The 2000 paper gives the short form: “Depend upon Abstractions. Do not depend upon concretions.”
Sending a receipt after an order is shop policy. Which email service sends it is a detail that changes when the shop changes provider. So checkout declares the interface it needs. An adapter for the email service, at the edge of the system, implements it:
export interface ReceiptSender {
send(email: string, text: string): Promise<void>;
}
export const sendReceipt = async (order: Order, receipts: ReceiptSender): Promise<void> => {
await receipts.send(order.customerEmail, receiptText(orderTotalPence(order)));
}; export interface ReceiptSender {
send(email: string, text: string): Promise<void>;
}
export const sendReceipt = async (order: Order, receipts: ReceiptSender): Promise<void> => {
await receipts.send(order.customerEmail, receiptText(orderTotalPence(order)));
}; export const sendReceipt = async (order, receipts) => {
await receipts.send(order.customerEmail, receiptText(orderTotalPence(order)));
}; from typing import Protocol
class ReceiptSender(Protocol):
def send(self, email: str, text: str) -> None:
...
def send_receipt(order: Order, receipts: ReceiptSender) -> None:
receipts.send(order.customer_email, receipt_text(order_total_pence(order))) public interface ReceiptSender {
void send(String email, String text);
}
public final class Checkout {
public static void sendReceipt(Order order, ReceiptSender receipts) {
receipts.send(order.customerEmail(), Receipts.receiptText(Pricing.orderTotalPence(order)));
}
} interface ReceiptSender
{
public function send(string $email, string $text): void;
}
function sendReceipt(Order $order, ReceiptSender $receipts): void
{
$receipts->send($order->customerEmail, receiptText(orderTotalPence($order)));
} Before, checkout depended on the email code. Now the email code depends on an interface checkout owns. That reversal gives dependency inversion its name. A test passes a fake that records what it was asked to send, so it needs no email service.
A11 Dependency Inversion Principle applies the rule to volatile detail such as the database, the email provider or a third-party API. It leaves stable utilities, time and console output alone unless a test needs a fake.
When SOLID goes too far
Each principle adds structure to the code, and where nothing needs that structure, it’s more code to read and change for no benefit. PT3 Every Pattern Has a Failure Mode asks you to name a pattern’s cost before you use it. The same habit works for SOLID:
- Single responsibility, read as a call to make every function as small as possible, gives functions too small to mean anything alone. A reader then has to follow the calls to see the job.
- Open-closed, applied before a second caller exists, gives extension points nobody extends.
- Interface segregation, applied where callers need the same methods, gives more names and no benefit.
- Dependency inversion, applied to stable code, gives interfaces with one implementation and no test fake. F4 rejects an abstraction layer with only one implementation.
Liskov substitution has no entry in that list, because it adds no structure. It only constrains a subtype you’ve already chosen to write. Sandi Metz’s The Wrong Abstraction (2016) gives the general case: “duplication is far cheaper than the wrong abstraction”.
The Prickles packs give a coding agent these tenets as rules, beside lint configs with the same limits these examples are written to. The implementation page shows what each agent gets.
How to check code against SOLID
- Say what each function or class does in one sentence. If the sentence needs “and”, the unit probably has two reasons to change.
- For each new interface, parameter or flag, name the callers that need it today. Until there’s a second one, leave it out.
- For each subtype, check that it accepts every input its parent accepts, returns what its parent promises and throws nothing new.
- For each parameter typed as an interface, list the methods the function calls. If the interface has more, split it or narrow the type.
- For each import in business logic, ask whether it’s a database, an email service or another detail that changes. If it is, depend on an interface the business logic owns.
