Robert C. Martin coined the SOLID acronym in Agile Software Development: Principles, Patterns, and Practices (Prentice Hall, 2002). Each letter names a design rule that answers one kind of maintenance pain. Every section below shows the code pattern that violates the principle, the corrected version, and what changes afterward.
S: single responsibility
A class or function changes for one reason. When it combines data writes, email sending, and auditing, any change to those three fronts hits the same file.
// violates: changes for three reasons
class UserService {
async createUser(data: CreateUserDto) {
const user = await this.db.user.create({ data })
await this.sendWelcomeEmail(user.email)
await this.auditLog('user.created', user.id)
return user
}
}
// fixed: each responsibility in its place
class UserService {
constructor(
private readonly userRepo: UserRepository,
private readonly emailService: EmailService,
private readonly auditService: AuditService
) {}
async createUser(data: CreateUserDto) {
const user = await this.userRepo.create(data)
await this.emailService.sendWelcome(user.email)
await this.auditService.log('user.created', user.id)
return user
}
}
The fixed version changes when the repository, the email template, or the audit policy changes. Martin calls that count the "axis of change," and Clean Code (Prentice Hall, 2008) repeats the same idea for functions: short function, one name, one action.
O: open for extension, closed for modification
Adding a payment method should not touch the class that processes payments. The code below requires editing the method every time a new type shows up:
// violates: each new type forces an edit here
class PaymentProcessor {
process(type: string, amount: number) {
if (type === 'credit_card') { /* ... */ }
else if (type === 'pix') { /* ... */ }
// 'boleto' requires editing this method
}
}
// fixed: a new type becomes a new class
interface PaymentMethod {
process(amount: number): Promise<PaymentResult>
}
class CreditCardPayment implements PaymentMethod {
async process(amount: number) { /* ... */ }
}
class PixPayment implements PaymentMethod {
async process(amount: number) { /* ... */ }
}
BoletoPayment arrives later without touching either class above. The Strategy pattern on Refactoring.Guru is the same structure under another name.
L: Liskov substitution
A subtype drops into the base type's place and the program stays correct. When code asks for instanceof to pick behavior, the contract already broke.
// violates: Square changes the height along with the width
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number) { this.width = w }
setHeight(h: number) { this.height = h }
area() { return this.width * this.height }
}
class Square extends Rectangle {
setWidth(w: number) { this.width = w; this.height = w }
setHeight(h: number) { this.width = h; this.height = h }
}
function increaseWidth(rect: Rectangle) {
rect.setWidth(rect.width + 1) // on a Square the height moves too
}
// fixed: a contract both types honor
interface Shape {
area(): number
scale(factor: number): Shape
}
Rectangle and Square share geometry and share nothing about setters. Separate hierarchies, or composition, fix it without fooling the compiler.
I: interface segregation
A wide interface forces implementers to write methods they do not call. The DataStore below makes a read-only caller sign connect(), write(), and subscribe().
// violates: five methods for a caller that needs three
interface DataStore {
read(id: string): Promise<any>
write(id: string, data: any): Promise<void>
delete(id: string): Promise<void>
subscribe(event: string, cb: Function): void
connect(): Promise<void>
}
// fixed: one interface per capability
interface Reader {
read(id: string): Promise<any>
}
interface Writer {
write(id: string, data: any): Promise<void>
}
interface EventSource {
subscribe(event: string, cb: Function): void
}
TypeScript helps here: Pick<DataStore, 'read'> covers the ad hoc case, while a named interface tells the next reader what the capability is.
D: dependency inversion
High-level services declare what they need and receive a ready implementation. The composition root lives at the edge of the application.
// violates: a concrete instance inside the service
class OrderService {
constructor() {
this.db = new PrismaClient()
}
}
// fixed: contract in the domain, implementation outside
interface OrderRepository {
save(order: Order): Promise<void>
findById(id: string): Promise<Order | null>
}
class OrderService {
constructor(private readonly repo: OrderRepository) {}
}
In production the container injects PrismaOrderRepository; a test injects InMemoryOrderRepository and runs without Postgres. A DI framework such as the NestJS reflect-metadata wiring automates the assembly, but the decision to decouple stays with domain code.
Where it applies and where to leave it alone
SOLID belongs where change is expensive: business rules, service layers, extension points a team touches every week. A four-line utility function, a migration script, and configuration code get no interface and no inheritance.
Martin Fowler draws that boundary in Is Design Dead?: evolutionary design starts from simple structure and refactors when the pain shows up. The practical question before reaching for a pattern: if change X arrives tomorrow, how many files break? Few, move on. Many, extract the abstraction.
References
- Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (Prentice Hall, 2002)
- Robert C. Martin, Clean Code (Prentice Hall, 2008)
- Is Design Dead? (Martin Fowler, 2001)
- Strategy Pattern (Refactoring.Guru)
- SOLID (Wikipedia)
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk