I'm not talking about a state or matter but a set of five design principles for object-oriented programming:
- Single Responsibility
- Open/Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion.
Introduced by Robert C. Martin, they describe how classes and modules should be structured so that code stays maintainable, extensible, and testable as it grows.
Each principle addresses a different aspect of class design — how much a class should do, how it should handle change, how sub-types should behave, how interfaces should be shaped, and how dependencies between modules should be organized.
Together they form a general vocabulary for describing what "good" object-oriented design looks like, and why certain structures tend to hold up better than others over time.
These principles are as follows:
1. Single Responsibility Principle

A class should have only one reason to change — meaning it should be responsible for a single part of the system's functionality.
Suppose you need to build a class that handles a customer's order: it validates the order, calculates the total price, and sends a confirmation email once the order is placed. Take this for example:
class Order {
constructor(
private items: { name: string; price: number; quantity: number }[],
private customerEmail: string
) {}
validate(): boolean {
return this.items.length > 0 && this.items.every(item => item.quantity > 0);
}
calculateTotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
sendConfirmationEmail(): void {
const total = this.calculateTotal();
console.log(`Sending email to ${this.customerEmail}: Your order total is $${total}`);
// actual email-sending logic would go here
}
}
const order = new Order(
[{ name: "Keyboard", price: 50, quantity: 1 }],
"customer@example.com"
);
if (order.validate()) {
order.sendConfirmationEmail();
}
For a small system that's easy to write and easy to read.
Now suppose the business wants to switch email providers, or add a second notification channel like SMS. That logic lives inside Order, so changing it means editing the Order class directly. Anyone doing so is also touching the same class responsible for validation and price calculation — a change intended only for notifications now carries risk for unrelated logic.
The same problem appears in reverse: a change to how totals are calculated (say, adding tax) also happens inside a class that has nothing to do with tax rules conceptually, and everything to do with orders, emails, and validation all at once.
As Order grows, the probability of it getting bloated is 1.0. To prevent this, the various feature inside need to be placed separately as shown:
class Order {
constructor(
public items: { name: string; price: number; quantity: number }[],
public customerEmail: string
) {}
}
class OrderValidator {
validate(order: Order): boolean {
return order.items.length > 0 && order.items.every(item => item.quantity > 0);
}
}
class OrderCalculator {
calculateTotal(order: Order): number {
return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
class OrderNotifier {
constructor(private calculator: OrderCalculator) {}
sendConfirmationEmail(order: Order): void {
const total = this.calculator.calculateTotal(order);
console.log(`Sending email to ${order.customerEmail}: Your order total is $${total}`);
}
}
const order = new Order(
[{ name: "Keyboard", price: 50, quantity: 1 }],
"customer@example.com"
);
const validator = new OrderValidator();
const calculator = new OrderCalculator();
const notifier = new OrderNotifier(calculator);
if (validator.validate(order)) {
notifier.sendConfirmationEmail(order);
}
Order is now just a data structure. Validation, total calculation, and notification are each handled by a separate class, and each of those classes has exactly one reason to change: OrderValidator changes only if validation rules change, and the same is true for the others.
Each class is now easier to test in isolation — testing OrderCalculator no longer requires a valid email address or triggering an email send, since those concerns no longer live in the same object.
This is the core of the Single Responsibility Principle: not that a class should have only one method, but that everything inside a class should change for the same reason. When it doesn't, splitting the class along those reasons is usually the fix.
2. Open/Closed Principle
Software entities (classes, modules, functions) should be open for extension but closed for modification — you should be able to add new behavior without changing existing code.
Suppose the order system needs to support two payment methods: credit card and PayPal.
class PaymentProcessor {
processPayment(method: string, amount: number): void {
if (method === "credit_card") {
console.log(`Charging $${amount} to credit card`);
// credit card charging logic
} else if (method === "paypal") {
console.log(`Charging $${amount} via PayPal`);
// PayPal charging logic
} else {
throw new Error("Unsupported payment method");
}
}
}
const processor = new PaymentProcessor();
processor.processPayment("credit_card", 100);
processor.processPayment("paypal", 50);
This handles both payment methods correctly, and adding a condition for a new one seems simple enough on the surface.
Now the business wants to add cryptocurrency as a payment option. The easiest fix is another else if branch inside processPayment, right?. That means opening a class that already works, editing a method that already handles two other payment types correctly, and hoping the new branch doesn't interact badly with the existing ones.
As more payment methods get added, this method keeps growing, and every addition means modifying code that has nothing to do with the new payment type but sits in the same function. A typo or an unintended side effect in the new branch also risks breaking the branches already in production, since they all now live in the same block of conditional logic.
PaymentProcessor isn't closed to modification — every new payment method requires changing it directly.
interface PaymentMethod {
pay(amount: number): void;
}
class CreditCardPayment implements PaymentMethod {
pay(amount: number): void {
console.log(`Charging $${amount} to credit card`);
}
}
class PayPalPayment implements PaymentMethod {
pay(amount: number): void {
console.log(`Charging $${amount} via PayPal`);
}
}
class CryptoPayment implements PaymentMethod {
pay(amount: number): void {
console.log(`Charging $${amount} via cryptocurrency`);
}
}
class PaymentProcessor {
processPayment(method: PaymentMethod, amount: number): void {
method.pay(amount);
}
}
const processor = new PaymentProcessor();
processor.processPayment(new CreditCardPayment(), 100);
processor.processPayment(new PayPalPayment(), 50);
processor.processPayment(new CryptoPayment(), 25);
PaymentProcessor no longer contains any knowledge of specific payment types. It depends only on the PaymentMethod interface and calls .pay(), regardless of which implementation it receives. Adding a new payment method — say, bank transfer — means writing a new class that implements PaymentMethod, without touching PaymentProcessor or any of the existing payment classes at all.
This is what "open for extension, closed for modification" means in practice: the system gains new behavior through new code, not through edits to code that already works. Each payment method is also isolated from the others, so a bug in CryptoPayment has no way of affecting CreditCardPayment or PayPalPayment, since they no longer share a function body.
The mechanism enabling this is polymorphism — processPayment operates on the PaymentMethod interface rather than on concrete classes, which is also the seed of Dependency Inversion, covered later in this article.
3. Liskov Substitution Principle
Objects of a subtype must be usable anywhere the base type is expected, without breaking the correctness of the program. A subclass should honor every guarantee its parent class makes.
Suppose the order system needs to support refunds. Standard orders can be refunded, and a gift card order is a special kind of order that gets created the same way and flows through the same system.
Initial code
class Order {
constructor(public id: number, public total: number) {}
refund(): void {
console.log(`Order ${this.id} refunded: $${this.total}`);
}
}
class GiftCardOrder extends Order {
refund(): void {
throw new Error("Gift card orders cannot be refunded");
}
}
function processReturns(orders: Order[]): void {
for (const order of orders) {
order.refund();
}
}
const orders: Order[] = [
new Order(1, 80),
new GiftCardOrder(2, 50),
new Order(3, 120),
];
processReturns(orders);
GiftCardOrder is an Order as far as TypeScript is concerned, so it fits into the array without complaint, and the code compiles.
Running processReturns refunds order 1, then throws on order 2, and order 3 is never processed. The function was written against Order, and Order promises that refund() works. GiftCardOrder accepts the type but breaks the promise.
The compiler offers no help here, since the signatures match exactly. The failure only appears at runtime, and only when a gift card order happens to be in the list. The usual workaround is to add a check inside processReturns, such as if (order instanceof GiftCardOrder) continue;. That fixes the symptom but puts knowledge of a specific subtype into code that was supposed to work with any Order. Every new non-refundable order type would need another check.
Changed code
class Order {
constructor(public id: number, public total: number) {}
}
interface Refundable {
refund(): void;
}
class StandardOrder extends Order implements Refundable {
refund(): void {
console.log(`Order ${this.id} refunded: $${this.total}`);
}
}
class GiftCardOrder extends Order {
// no refund method: it makes no promise to refund
}
function processReturns(orders: Refundable[]): void {
for (const order of orders) {
order.refund();
}
}
const standardOrders: Refundable[] = [
new StandardOrder(1, 80),
new StandardOrder(3, 120),
];
processReturns(standardOrders);
const giftCardOrder = new GiftCardOrder(2, 50);
// processReturns([giftCardOrder]); // compile error: not Refundable
The original design made a promise in the base class, refund() works on every Order, and then broke it in a subclass. The refactor stops making that promise at the Order level. Refunding is now a capability described by the Refundable interface, and only classes that can actually refund implement it.
processReturns now accepts Refundable[], so it can only receive objects that honor the contract. Passing a GiftCardOrder is a compile-time error rather than a runtime crash, and the function needs no instanceof checks. Any new refundable order type works with it automatically, and any non-refundable one is excluded by the type system.
This is the Liskov Substitution Principle: wherever a type is expected, any of its subtypes must work without surprises. A subclass that throws on an inherited method, returns nonsense, or silently does nothing is a signal that the inheritance hierarchy is modeling the wrong relationship. The fix is usually to move the behavior out of the base type and into a narrower abstraction that only the compliant types share.
4. Interface Segregation Principle
No class should be forced to implement methods it does not use. Interfaces should be small and focused, so that implementers only depend on what is relevant to them.
Suppose the order system needs to handle two kinds of orders: physical orders, which are validated, paid for, shipped, and confirmed by email, and digital orders, such as software downloads, which go through the same flow except that there is nothing to ship.
interface OrderProcessor {
validate(): boolean;
chargePayment(): void;
shipOrder(): void;
sendConfirmation(): void;
}
class PhysicalOrder implements OrderProcessor {
validate(): boolean {
return true;
}
chargePayment(): void {
console.log("Charging payment for physical order");
}
shipOrder(): void {
console.log("Shipping physical order");
}
sendConfirmation(): void {
console.log("Sending confirmation for physical order");
}
}
class DigitalOrder implements OrderProcessor {
validate(): boolean {
return true;
}
chargePayment(): void {
console.log("Charging payment for digital order");
}
shipOrder(): void {
throw new Error("Digital orders cannot be shipped");
}
sendConfirmation(): void {
console.log("Sending confirmation for digital order");
}
}
function fulfill(order: OrderProcessor): void {
if (order.validate()) {
order.chargePayment();
order.shipOrder();
order.sendConfirmation();
}
}
fulfill(new PhysicalOrder());
fulfill(new DigitalOrder());
DigitalOrder satisfies the interface, so it compiles and can be passed anywhere an OrderProcessor is expected.
OrderProcessor describes everything an order might do, and DigitalOrder is forced to accept a method that has no meaning for it. The only options are to leave shipOrder() empty, which hides the fact that nothing happened, or to throw, as above. Calling fulfill on the digital order charges the payment and then crashes before the confirmation is sent.
The interface also creates a dependency that serves no one. Any code that only needs to charge payments, for example, still has to accept an object carrying shipOrder(), and any change to the shipping signature ripples out to every class implementing the interface, including those that never ship anything.
This has the same symptom as the previous section, a subtype breaking a promise, but a different cause. There the inheritance hierarchy was modeling the wrong relationship. Here the interface itself is too broad, bundling capabilities that not every implementer has.
Changed code
interface Validatable {
validate(): boolean;
}
interface Payable {
chargePayment(): void;
}
interface Shippable {
shipOrder(): void;
}
interface Notifiable {
sendConfirmation(): void;
}
class PhysicalOrder implements Validatable, Payable, Shippable, Notifiable {
validate(): boolean {
return true;
}
chargePayment(): void {
console.log("Charging payment for physical order");
}
shipOrder(): void {
console.log("Shipping physical order");
}
sendConfirmation(): void {
console.log("Sending confirmation for physical order");
}
}
class DigitalOrder implements Validatable, Payable, Notifiable {
validate(): boolean {
return true;
}
chargePayment(): void {
console.log("Charging payment for digital order");
}
sendConfirmation(): void {
console.log("Sending confirmation for digital order");
}
}
function charge(order: Validatable & Payable): void {
if (order.validate()) {
order.chargePayment();
}
}
function ship(order: Shippable): void {
order.shipOrder();
}
function confirm(order: Notifiable): void {
order.sendConfirmation();
}
const physical = new PhysicalOrder();
const digital = new DigitalOrder();
charge(physical);
ship(physical);
confirm(physical);
charge(digital);
confirm(digital);
// ship(digital); // compile error: DigitalOrder is not Shippable
The single OrderProcessor interface has been split into four, each describing one capability. DigitalOrder implements only the three that apply to it, so it no longer carries a shipOrder() method that it cannot honor. Attempting to ship a digital order is now a compile-time error instead of a runtime exception.
Each function also declares only what it needs. ship accepts anything Shippable and knows nothing about payment or confirmation, while charge requires Validatable & Payable and nothing more. A change to the shipping interface affects only the classes that actually ship.
This is the Interface Segregation Principle: clients should not be forced to depend on methods they do not use. In practice, an empty method, a method that throws "not supported," or an implementation that ignores half of its interface are all signs that the interface is bundling more than one role. The fix is to split it along those roles and let each class implement only the ones that describe it.
5. Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.
Suppose the order system needs to charge customers through Stripe when an order is placed, and the logic that coordinates placing an order lives in an OrderService class.
Initial code
class StripeGateway {
charge(amount: number): void {
console.log(`Charging $${amount} via Stripe`);
// call to Stripe's API would go here
}
}
class OrderService {
private gateway = new StripeGateway();
placeOrder(amount: number): void {
this.gateway.charge(amount);
console.log("Order placed");
}
}
const service = new OrderService();
service.placeOrder(100);
OrderService creates its own StripeGateway and uses it directly. The code is short and does exactly what was asked.
Now suppose the business moves to a different payment vendor. OrderService is the class that decides how an order gets placed, yet it contains a hard reference to one specific vendor's class. Switching vendors means editing OrderService, and so does every future switch. If the new vendor's method is named differently or takes different arguments, the call site inside OrderService has to change as well.
Testing has the same problem. placeOrder cannot be run without triggering StripeGateway, because the dependency is created inside the class and there is no way to replace it. In a real system that means a test either hits Stripe's API or cannot exist.
The dependency direction is the issue: the high-level class that holds the business flow depends directly on a low-level class that holds vendor details.
Changed code
interface PaymentGateway {
charge(amount: number): void;
}
class StripeGateway implements PaymentGateway {
charge(amount: number): void {
console.log(`Charging $${amount} via Stripe`);
}
}
class NewVendorGateway implements PaymentGateway {
charge(amount: number): void {
console.log(`Charging $${amount} via NewVendor`);
}
}
class FakeGateway implements PaymentGateway {
charged: number[] = [];
charge(amount: number): void {
this.charged.push(amount);
}
}
class OrderService {
constructor(private gateway: PaymentGateway) {}
placeOrder(amount: number): void {
this.gateway.charge(amount);
console.log("Order placed");
}
}
// production
const service = new OrderService(new StripeGateway());
service.placeOrder(100);
// switching vendors: no change to OrderService
const switched = new OrderService(new NewVendorGateway());
switched.placeOrder(100);
// testing: no network, and the result can be inspected
const fake = new FakeGateway();
new OrderService(fake).placeOrder(100);
console.log(fake.charged); // [100]
OrderService now depends on the PaymentGateway interface and nothing else. It does not know which vendor sits behind it, and it does not create the gateway itself. The gateway is passed in through the constructor, a technique called dependency injection, and the decision about which implementation to use moves out to the code that builds the service.
The dependency arrows have flipped. Before, OrderService pointed at StripeGateway. Now both point at PaymentGateway: OrderService uses it, and StripeGateway implements it. The abstraction is owned by the high-level side, shaped by what the business flow needs, and the vendor classes conform to it. That reversal is where the name comes from.
Switching vendors becomes a one-line change at the point where the service is constructed, and OrderService is untouched. Testing becomes straightforward too, since FakeGateway can stand in for a real vendor and record what was charged without any network call.
This is the Dependency Inversion Principle: business logic should be written against abstractions, with concrete details supplied from outside. It is also the principle that makes the others pay off in practice. The PaymentMethod interface from Section 2 follows the same pattern, and injecting it into a service would complete the picture.
Conclusion
These five principles describe different parts of class design, but they aren't independent of one another. Single Responsibility splits code along the lines of what changes together. Open/Closed lets new behavior arrive as new code rather than edits. Liskov Substitution and Interface Segregation keep the contracts between types honest, one at the level of inheritance and the other at the level of interfaces. Dependency Inversion points the dependencies at abstractions, so the other four can hold up as the system grows.
In practice they overlap. The PaymentMethod interface from the Open/Closed section is also an abstraction in the Dependency Inversion sense, and the Refundable interface from the Liskov section is a narrow interface in the Interface Segregation sense. A single refactor often satisfies two or three of them at once, which is why they tend to show up together.
They are also guidelines rather than rules. Applied to a small script, splitting every class and adding an interface for every dependency produces more files and indirection than the problem warrants. A reasonable approach is to apply a principle when the code gives a concrete reason: a class that keeps changing for unrelated reasons, a conditional that grows with every new variant, a subtype that throws on an inherited method, an interface with methods some implementers can't honor, or a dependency that makes testing impossible. Used that way, SOLID works as a shared vocabulary for describing design problems and their fixes, and the examples above are meant to make that vocabulary easy to recognize in real code.
For further reading, Robert C. Martin's Agile Software Development: Principles, Patterns, and Practices is where the principles were laid out in full.





Top comments (1)
Agreed on all five. But the dependency inversion principle gets quoted as if depending on abstractions is automatically better, but an interface with one implementation is just an extra file. You only get your money back from that seam when a second implementation arrives, or the test double you actually need. Until then you've added a seam nobody crosses.