SOLID is a set of five object-oriented design principles, coined by Robert C. Martin, that guide how to structure classes and modules so they stay flexible as requirements change. The goal is code that is easy to extend and hard to break.
The five principles
- S, Single Responsibility, a class should have one reason to change. Separate concerns so a single business decision touches a single module.
- O, Open/Closed, software should be open for extension but closed for modification. Add behavior through new code (polymorphism, composition) rather than editing what already works.
- L, Liskov Substitution, subtypes must be usable anywhere their base type is expected, without surprising the caller. A subclass that weakens guarantees breaks the contract.
- I, Interface Segregation, prefer many small, focused interfaces over one large one. Clients should not depend on methods they never call.
- D, Dependency Inversion, depend on abstractions, not concretions. High-level policy should not import low-level detail; both depend on a shared interface.
In code
The same small design demonstrates the principles working together: a focused Notifier interface (ISP), one class per channel with a single job (SRP), new channels added without editing existing code (OCP), substitutable implementations (LSP), and a service that depends on the abstraction rather than concrete channels (DIP).
// SRP + OCP: one class per channel; add new channels without editing existing code
class EmailNotifier {
send(message) {
console.log(`Email: ${message}`);
}
}
class SmsNotifier {
send(message) {
console.log(`SMS: ${message}`);
}
}
// DIP: depends on the abstraction (any object with send());
// LSP: every notifier is substitutable here
class AlertService {
constructor(notifiers) {
this.notifiers = notifiers;
}
raise(message) {
for (const notifier of this.notifiers) {
notifier.send(message);
}
}
}// ISP: small, focused interface
interface Notifier {
send(message: string): void;
}
// SRP + OCP: one class per channel; add new channels without editing existing code
class EmailNotifier implements Notifier {
send(message: string): void {
console.log(`Email: ${message}`);
}
}
class SmsNotifier implements Notifier {
send(message: string): void {
console.log(`SMS: ${message}`);
}
}
// DIP: depends on the abstraction; LSP: any Notifier is substitutable here
class AlertService {
constructor(private readonly notifiers: Notifier[]) {}
raise(message: string): void {
for (const notifier of this.notifiers) {
notifier.send(message);
}
}
}// ISP: small, focused interface
public interface INotifier
{
void Send(string message);
}
// SRP + OCP: one class per channel; add new channels without editing existing code
public class EmailNotifier : INotifier
{
public void Send(string message) => Console.WriteLine($"Email: {message}");
}
public class SmsNotifier : INotifier
{
public void Send(string message) => Console.WriteLine($"SMS: {message}");
}
// DIP: depends on the abstraction; LSP: any INotifier is substitutable here
public class AlertService
{
private readonly IEnumerable<INotifier> _notifiers;
public AlertService(IEnumerable<INotifier> notifiers) => _notifiers = notifiers;
public void Raise(string message)
{
foreach (var notifier in _notifiers)
notifier.Send(message);
}
}from typing import Protocol
# ISP: small, focused interface
class Notifier(Protocol):
def send(self, message: str) -> None: ...
# SRP + OCP: one class per channel; add new channels without editing existing code
class EmailNotifier:
def send(self, message: str) -> None:
print(f"Email: {message}")
class SmsNotifier:
def send(self, message: str) -> None:
print(f"SMS: {message}")
# DIP: depends on the abstraction; LSP: any Notifier is substitutable here
class AlertService:
def __init__(self, notifiers: list[Notifier]) -> None:
self._notifiers = notifiers
def raise_alert(self, message: str) -> None:
for notifier in self._notifiers:
notifier.send(message)Why it matters
Each principle reduces coupling and localizes change. Together they push toward modules that can be tested in isolation, swapped via interfaces, and extended without ripple effects across the codebase.
Trade-offs
SOLID is guidance, not law. Over-applying it through premature interfaces, deep abstraction layers, and one-method classes adds indirection that hurts readability. Apply each principle when a real axis of change appears, not speculatively.
Related reading
These principles complement boundary-focused design like bounded contexts, which draws the seams SOLID then keeps clean.
