Object-oriented programming (OOP) promises reusable components, extensibility, and codebases that grow with evolving business requirements. In practice, OOP projects often become brittle, tightly coupled, and resistant to change. The SOLID principles provide a time-tested guide for designing classes and modules that remain understandable and adaptable as applications scale.
This article revisits each SOLID principle with modern development contexts in mind. You'll see how the ideas extend beyond academic definitions, how to recognize violations in real code reviews, and how to apply the guidance without over-engineering solutions.
Why SOLID Still Matters
- Software lifecycles are longer than ever; features are expected to evolve continuously.
- DevOps and continuous delivery demand safe, incremental changes.
- Teams rotate frequently, so clarity and predictability trump clever shortcuts.
- Stacks may mix microservices, event-driven flows, and traditional monoliths—SOLID is one of the few frameworks that applies across them all.
SOLID at a Glance
| Principle | Intent | Anti-Patterns When Ignored |
|---|---|---|
| Single Responsibility | One reason to change per class/module | God objects, massive controllers |
| Open/Closed | Extend behavior without modifying existing code | Flag bloat, copy-paste branching |
| Liskov Substitution | Subtypes must honor base type contracts | Surprising runtime exceptions |
| Interface Segregation | Depend on small, specific interfaces | Stubs throwing UnsupportedOperationException |
| Dependency Inversion | High-level policies depend on abstractions | Hard-coded infrastructure logic |
1. Single Responsibility Principle (SRP)
Definition: A class should have one and only one reason to change. That "reason" usually maps to a business capability or cohesive technical concern.
What to look for:
- Classes orchestrating unrelated concerns, such as both persistence and UI formatting.
- Methods longer than what fits on a screen or that require excessive scrolling to follow.
- Interfaces whose names include conjunctions (
UserAndReportService).
Refactoring approach:
- Identify distinct responsibilities via commit history or feature requests.
- Extract behavior into focused collaborators (e.g.,
InvoiceCalculator,InvoiceSerializer). - Introduce clear seams for testing—smaller classes are easier to mock and verify.
TypeScript Example:
// Anti-pattern: handles parsing, validation, and persistence.
class UserProfileManager {
save(rawJson: string) {
const parsed = JSON.parse(rawJson);
if (!parsed.email?.includes('@')) {
throw new Error('Invalid email');
}
database.insert('users', parsed);
}
}
// Refined responsibilities.
class UserParser {
parse(rawJson: string) {
return JSON.parse(rawJson);
}
}
class UserValidator {
validate(user: { email: string }) {
if (!user.email.includes('@')) {
throw new Error('Invalid email');
}
}
}
class UserRepository {
constructor(private db = database) {}
save(user: unknown) {
this.db.insert('users', user);
}
}
The refactored composition allows teams to swap the repository for a mocked data store or reuse validation logic in APIs and CLI tooling.
2. Open/Closed Principle (OCP)
Definition: Software entities should be open for extension but closed for modification. In practice, you should add new behavior by adding code, not editing stable, tested modules.
Signals you are violating OCP:
- Feature toggles accumulating in a single class, causing cascades of
if/elselogic. - Regression risk every time an evolved feature touches a shared switch statement.
- Hotfixes duplicating logic because extending existing modules is risky.
Strategies to honor OCP:
- Favor polymorphism or strategy patterns over enumerations of behavior.
- Use composition via dependency injection to provide new implementations.
- Abstract cross-cutting concerns (logging, caching) behind decorators to layer behavior.
Example: Instead of toggling between file-based and cloud storage via if/else, define a StorageProvider interface and register new providers without editing client code.
3. Liskov Substitution Principle (LSP)
Definition: Objects of a superclass should be replaceable with objects of a subclass without breaking correctness.
Common violations:
- Overriding methods to throw
NotImplementedException. - Subclasses narrowing method preconditions (e.g., requiring non-null where base accepts null).
- Returning stronger postconditions or violating expected invariants.
Practical guidance:
- Document behavioral contracts—what does a method promise?
- When a subtype cannot meet the base contract, reconsider the hierarchy; use composition.
- For TypeScript/Java/Kotlin, rely on type systems and tests to enforce substitution via interface implementations, not deep inheritance trees.
Test to add: For each subclass, run the same integration tests written for the base type. If tests require branching logic to accommodate the subtype, LSP is probably broken.
4. Interface Segregation Principle (ISP)
Definition: Clients should not be forced to depend on methods they do not use. Instead of massive "god interfaces," favor smaller, role-specific contracts.
Why it matters:
- Makes mocking simpler—tests only need to emulate the methods they interact with.
- Encourages a language that matches the domain:
Auditable,Versioned,Searchable. - Prevents changes requested by one consumer from breaking others.
Implementation patterns:
- Split large interfaces into focused fragments with clear intent.
- In TypeScript, use intersection types or mixins to compose behavior.
- Adopt command/query separation: write-only interfaces separate from read-only ones.
Caution: Excessive micro-interfaces can confuse collaborators. Use naming conventions and module documentation to help teammates understand how fragments fit together.
5. Dependency Inversion Principle (DIP)
Definition: 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.
Symptoms of DIP violations:
- Application services importing concrete database drivers or HTTP clients directly.
- Infrastructure changes forcing widespread edits in business logic classes.
- Difficulty writing unit tests because collaborators cannot be swapped easily.
Modern application of DIP:
- Use IoC containers or dependency injection frameworks sparingly—constructor injection in plain classes often suffices.
- Provide default infrastructure implementations at composition roots (e.g., web controllers).
- Keep abstractions in core modules; place details (ORM, file system) in outer layers.
Illustration:
interface NotificationSender {
send(subject: string, message: string): Promise<void>;
}
class EmailSender implements NotificationSender {
constructor(private client: SmtpClient) {}
send(subject: string, message: string) {
return this.client.sendEmail(subject, message);
}
}
class IncidentAlerter {
constructor(private sender: NotificationSender) {}
async alert(message: string) {
await this.sender.send('Incident', message);
}
}
IncidentAlerter depends on the abstraction, making it testable with an in-memory sender while letting production code wire an SMTP or Slack implementation.
Applying SOLID Without Over-Engineering
- Start with clarity, evolve to abstractions. Avoid introducing interfaces until duplication or volatility justifies them.
- Keep feedback loops short. Write tests before refactors to ensure behavior remains consistent.
- Measure complexity. Track metrics like cyclomatic complexity, class dependencies, and test coverage to decide where SOLID refactoring delivers ROI.
- Pair with domain-driven design (DDD). Bounded contexts and ubiquitous language reinforce SRP and DIP decisions.
Common Pitfalls
- Pattern cargo culting: Introducing factories, service locators, or dependency injection frameworks without actual variability needs.
- Hyper-fragmentation: Splitting responsibilities so finely that understanding control flow becomes harder than before.
- Ignoring performance: Additional indirection can add overhead—profile critical paths and collapse indirection where necessary.
- Lack of documentation: SOLID-compliant code should still explain collaborators and invariants via clear naming and short docstrings.
SOLID Review Checklist
Use this list during design reviews or refactoring sessions:
- Does each class have a narrow, testable responsibility?
- When extending behavior, can you add a new class or strategy without editing core logic?
- Can a subclass replace its base type in tests without special casing?
- Are interfaces expressing cohesive roles, or are there unused methods?
- Can high-level policies run with alternative implementations (in-memory, mocked, different vendor SDK)?
Beyond SOLID
SOLID is foundational, not exhaustive. Combine it with:
- DRY (Don't Repeat Yourself): Avoid identical logic across modules while respecting SRP.
- YAGNI (You Aren't Gonna Need It): Resist speculative abstractions; refactor when change arrives.
- Clean Architecture and Hexagonal Architecture: These extend DIP to entire application boundaries, aligning with modern microservice and modular monolith strategies.
Final Thoughts
The SOLID principles endure because they frame timeless architectural trade-offs: coupling versus cohesion, abstraction versus concreteness, stability versus flexibility. When applied pragmatically, they help teams deliver features faster, reduce regression risk, and keep codebases adaptable years after the initial launch. Integrate SOLID into code reviews, documentation, and onboarding, and your engineering organization will find it easier to scale both people and products.