SOLID Principles Are Not Rules — They're Trade-offs
SOLID is the most cited and least understood set of principles in software engineering. Most explanations stop at the acronym. Here's what each principle actually means for your codebase — and when to break them.
The problem with how SOLID is taught
Every engineering blog has a SOLID article. Most of them read like this: "S stands for Single Responsibility Principle. A class should have only one reason to change." Then a trivial example with a User class, and you're supposed to feel enlightened.
The issue isn't that the definition is wrong. It's that it's useless without context. "One reason to change" is not an objective measure — it depends on what axis of change you're designing for. And that depends on your system, your team, and your domain.
SOLID principles are not laws of physics. They're design heuristics that describe trade-offs. Understanding the trade-off is the skill. Reciting the acronym is not.
S — Single Responsibility Principle
The common definition: A class should have only one reason to change.
The useful definition: A module should be responsible to one, and only one, actor.
The difference matters. "One reason to change" is vague. "One actor" is concrete. An actor is a group of users or stakeholders that would request changes to the module.
// This violates SRP — not because it "does two things,"
// but because two different actors care about it
class Employee {
calculatePay(): number {
// CFO's team cares about this
return this.hoursWorked * this.hourlyRate
}
generateReport(): string {
// COO's team cares about this
return `${this.name}: ${this.hoursWorked}h`
}
save(): void {
// DBA / infrastructure team cares about this
db.employees.update(this.id, this.toJSON())
}
}
The problem isn't that the class is "too big." It's that a change requested by the finance team (modify pay calculation) could break the reporting logic that the operations team depends on. The blast radius of a change extends beyond the actor who requested it.
The fix is straightforward — separate along actor boundaries:
class PayCalculator {
calculate(employee: Employee): number {
return employee.hoursWorked * employee.hourlyRate
}
}
class EmployeeReporter {
generate(employee: Employee): string {
return `${employee.name}: ${employee.hoursWorked}h`
}
}
class EmployeeRepository {
save(employee: Employee): void {
db.employees.update(employee.id, employee.toJSON())
}
}
When to break it: Early in a project, when the actors are unclear or overlapping. Over-splitting a module before you understand the domain creates indirection without benefit. If you're not sure whether two behaviors will diverge, keep them together and split when the tension becomes real.
O — Open/Closed Principle
The common definition: Software entities should be open for extension, closed for modification.
The useful definition: You should be able to add new behavior to a system without editing existing, tested code.
This is the principle that gets abused the most. Taken literally, it leads to AbstractStrategyFactoryProvider hierarchies that nobody can navigate. Taken pragmatically, it means designing your system so that the most likely axis of change can be handled by adding code rather than modifying code.
// Closed for modification: the processor doesn't change
// Open for extension: add new handlers without touching existing code
interface PaymentHandler {
type: string
process(amount: number, details: Record<string, unknown>): Promise<PaymentResult>
}
class PaymentProcessor {
private handlers: Map<string, PaymentHandler> = new Map()
register(handler: PaymentHandler): void {
this.handlers.set(handler.type, handler)
}
async process(type: string, amount: number, details: Record<string, unknown>): Promise<PaymentResult> {
const handler = this.handlers.get(type)
if (!handler) throw new Error(`No handler for payment type: ${type}`)
return handler.process(amount, details)
}
}
// Adding Stripe support = adding code, not editing PaymentProcessor
class StripeHandler implements PaymentHandler {
type = "stripe"
async process(amount: number, details: Record<string, unknown>): Promise<PaymentResult> {
// Stripe-specific logic
}
}
The handler registry pattern is the workhorse of OCP in real systems. Plugin architectures, middleware chains, event systems — they're all variations of "register a new behavior without modifying the orchestrator."
When to break it: When the abstraction boundary is wrong. If every new "extension" requires changing the interface, you predicted the wrong axis of change. It's better to refactor toward the actual axis than to pile abstractions on a flawed boundary. Also, for code that genuinely changes rarely, adding extension points is premature complexity.
L — Liskov Substitution Principle
The common definition: Subtypes must be substitutable for their base types.
The useful definition: If your code accepts a type T, it should work correctly with any subtype of T without knowing which subtype it's dealing with.
The classic violation is the square-rectangle problem, which makes everyone nod and then promptly forget. Here's a more realistic example:
interface ReadOnlyStore<T> {
get(id: string): Promise<T | null>
list(): Promise<T[]>
}
interface Store<T> extends ReadOnlyStore<T> {
save(item: T): Promise<void>
delete(id: string): Promise<void>
}
// This violates LSP
class CachedStore<T> implements Store<T> {
async save(item: T): Promise<void> {
// Saves to cache only — doesn't persist
this.cache.set(item.id, item)
}
async delete(id: string): Promise<void> {
throw new Error("Cannot delete from cache")
}
// ... get and list work fine
}
Any code expecting a Store can call delete(). If you swap in CachedStore, that code breaks at runtime. The subtype is not substitutable — it weakens the contract by throwing on an operation the base type promises to support.
The fix: CachedStore should implement ReadOnlyStore, not Store. The type hierarchy should reflect the actual behavioral contract.
class CachedStore<T> implements ReadOnlyStore<T> {
async get(id: string): Promise<T | null> {
return this.cache.get(id) ?? null
}
async list(): Promise<T[]> {
return Array.from(this.cache.values())
}
}
When to break it: Almost never. LSP violations are bugs in disguise. They create systems where substituting an implementation causes runtime failures that the type system can't catch. If you find yourself throwing NotImplementedError in a subtype, the type hierarchy is wrong.
I — Interface Segregation Principle
The common definition: No client should be forced to depend on methods it does not use.
The useful definition: Keep interfaces small and role-specific so that consumers only see what they need.
This is SRP applied to interfaces. Fat interfaces create coupling — if a consumer only needs to read data but the interface also exposes write methods, the consumer is coupled to the write contract even though it never uses it.
// Fat interface — forces every consumer to know about everything
interface UserService {
getUser(id: string): Promise<User>
updateUser(id: string, data: Partial<User>): Promise<User>
deleteUser(id: string): Promise<void>
listUsers(filter: UserFilter): Promise<User[]>
exportUsersToCsv(): Promise<Buffer>
sendPasswordResetEmail(userId: string): Promise<void>
}
A component that only displays a user profile is now coupled to CSV exports and password resets. When the email logic changes, the profile component's dependency changes too — even though it doesn't care.
// Segregated interfaces — each consumer depends only on what it uses
interface UserReader {
getUser(id: string): Promise<User>
listUsers(filter: UserFilter): Promise<User[]>
}
interface UserWriter {
updateUser(id: string, data: Partial<User>): Promise<User>
deleteUser(id: string): Promise<void>
}
interface UserExporter {
exportToCsv(): Promise<Buffer>
}
interface PasswordResetter {
sendResetEmail(userId: string): Promise<void>
}
// The implementation can satisfy all interfaces
class UserServiceImpl implements UserReader, UserWriter, UserExporter, PasswordResetter {
// ... full implementation
}
Now the profile component depends on UserReader. The admin panel depends on UserReader and UserWriter. The export job depends on UserExporter. Each consumer is shielded from changes to unrelated concerns.
When to break it: When the overhead of many small interfaces exceeds the coupling cost. For internal modules with a single consumer, a slightly larger interface is simpler than a handful of single-method interfaces. Segregation pays off at boundaries — between services, between layers, between teams.
D — Dependency Inversion Principle
The common definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
The useful definition: The module that contains the business logic should define the interface it needs. The infrastructure module should implement that interface.
This is the most architecturally significant SOLID principle. Without it, your business logic is hostage to your infrastructure choices. Changing the database means rewriting the domain layer. Swapping the email provider means editing the order processing code.
// WITHOUT dependency inversion:
// Business logic depends directly on infrastructure
import { PrismaClient } from '@prisma/client'
import { SendGrid } from '@sendgrid/mail'
class OrderService {
private db = new PrismaClient()
private mailer = new SendGrid()
async placeOrder(order: Order): Promise<void> {
await this.db.order.create({ data: order })
await this.mailer.send({ to: order.customerEmail, subject: 'Order confirmed' })
}
}
The OrderService is welded to Prisma and SendGrid. Testing requires a real database and a real email API. Swapping to a different ORM means rewriting order logic.
// WITH dependency inversion:
// Business logic defines what it needs; infrastructure adapts
interface OrderRepository {
save(order: Order): Promise<void>
}
interface NotificationService {
sendOrderConfirmation(email: string, order: Order): Promise<void>
}
class OrderService {
constructor(
private orders: OrderRepository,
private notifications: NotificationService,
) {}
async placeOrder(order: Order): Promise<void> {
await this.orders.save(order)
await this.notifications.sendOrderConfirmation(order.customerEmail, order)
}
}
// Infrastructure implements the business-defined interfaces
class PrismaOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await prisma.order.create({ data: order })
}
}
class SendGridNotificationService implements NotificationService {
async sendOrderConfirmation(email: string, order: Order): Promise<void> {
await sendgrid.send({ to: email, subject: 'Order confirmed' })
}
}
Now OrderService can be tested with in-memory fakes. The database can be swapped from Postgres to MongoDB without touching order logic. The email provider can change from SendGrid to Resend with zero impact on the domain layer.
When to break it: For leaf modules that are unlikely to change and have no testing cost. If your application will only ever use one database and you know it, injecting a repository interface for every table adds ceremony without benefit. Apply DIP at the boundaries that matter — the ones where you need testability, swappability, or team independence.
The meta-principle
SOLID principles share a common theme: manage the cost of change. Every principle, from different angles, asks the same question: "When this system changes (and it will), how much code will you need to touch, and how much will break?"
- SRP limits the blast radius of a change
- OCP lets you add behavior without risking existing behavior
- LSP ensures substitutions don't create runtime surprises
- ISP reduces the surface area of coupling between modules
- DIP makes the direction of dependency match the direction of importance
None of these are absolute. All of them have a cost — indirection, abstraction, additional types. The engineering judgment is knowing when the cost of the principle is lower than the cost of violating it. In a startup with three engineers and a six-month runway, rigid SOLID adherence is a liability. In a platform with fifty engineers and a five-year horizon, ignoring SOLID is a different kind of liability.
The right answer, as always, is: it depends. But now you know what it depends on.
This article is part of a series on software design fundamentals.
Related Posts

Building for Scale: Architecture Patterns That Actually Work
Most scaling advice is generic. Here are the patterns that have consistently worked across real systems handling millions of requests — and the ones that sound good but fail in practice.

Why Your Search Returns Nothing — And How MongoDB Vector Search Fixes It
Keyword search can only find what's literally there. When users search for 'laptop bag' and your documents say 'notebook carrying case,' regex won't help. Vector search understands meaning — and MongoDB Atlas supports it natively.

The AI Engineering Mindset: What Changes When You Build with LLMs
AI engineering isn't just software engineering with a model attached. The feedback loops, failure modes, and quality signals are fundamentally different. Here's how to think about it.