You've probably opened a once-tiny JavaScript file, only to discover a tangle of unrelated functions that makes adding a simple feature feel risky. As codebases grow, this pain compounds: debugging slows, tests break unpredictably, and new teammates struggle to find their footing.
SOLID design principles were coined to stop that spiral by giving every module a clear purpose, stable extension points, and minimal coupling. This guide shows you how each principle translates to clean, readable JavaScript and TypeScript—practical patterns you can implement immediately.
In Brief:
- SOLID principles prevent codebases from becoming tangled messes by giving every module a clear purpose, stable extension points, and minimal coupling between components
- Five core principles cover single responsibility, open/closed design, reliable inheritance, focused interfaces, and dependency inversion to create maintainable JavaScript and TypeScript code
- Teams should adopt one principle at a time, starting with their biggest pain point, rather than attempting full implementation across existing codebases immediately
- Common pitfalls include over-engineering simple solutions and premature abstraction—wait for real patterns to emerge before extracting shared interfaces or base classes
What is SOLID?
SOLID is an acronym representing five core design principles in object-oriented programming that help developers create maintainable, flexible, and scalable software. Each letter stands for a specific principle:
- Single Responsibility: A class should have only one reason to change
- Open/Closed: Software entities should be open for extension but closed for modification
- Liskov Substitution: Subtypes must be substitutable for their base types
- Interface Segregation: Clients shouldn't depend on interfaces they don't use
- Dependency Inversion: High-level modules shouldn't depend on low-level modules; both should depend on abstractions
The Story Behind SOLID
Robert C. Martin developed these principles during the 1990s software crisis. Teams discovered that adding more developers to tangled codebases amplified chaos rather than solving it. Martin distilled hard-won lessons from this period into design heuristics that became the foundation of modern development practices.
Early articles treated each guideline—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—as separate concepts. Martin's breakthrough came in 2000 when he bundled them under the memorable "SOLID" acronym during conference talks and essays.
Martin's 2004 book, Agile Software Development: Principles, Patterns, and Practices, presents these design principles as important guidelines for maintainable, flexible code that support agile practices.
How SOLID Principles Transform Development Teams
SOLID design principles cut surprise regressions dramatically by forcing each module to take on one clear role and depend on abstractions instead of concrete details. Loosely coupled, highly cohesive code is simply harder to break.
Cleaner boundaries do more than lower your bug count—they accelerate new feature work. When a discount engine is an independent class instead of tangled conditional statements, you can introduce a "HolidaySaleStrategy" without touching production code.
This approach keeps feature branches small and merge conflicts rare, a direct consequence of proper modularity.
Testing follows the same trajectory. Classes that follow Dependency Inversion accept mocked interfaces, so you can spin up fast unit tests instead of bootstrapping databases or third-party APIs. The result is wider coverage with less setup.
These design principles give everyone a shared vocabulary—"This violates SRP," "Let's inject that dependency"—so code reviews become objective and collaborative. Over time the team accumulates less technical debt, freeing you from the perpetual refactor treadmill.
Mastering these patterns also pays personal dividends. JavaScript and TypeScript developers can translate interface-focused designs directly into the language's type system, demonstrating architectural skill that hiring managers recognize immediately. These principles tidy your projects and sharpen your entire development career.
The Five SOLID Principles Explained
The five design principles give you a mental checklist for keeping JavaScript or TypeScript code modular, testable, and change-friendly.
Let's walk through each principle, see how it fails, and refactor it together.
S - Single Responsibility Principle: One Job, Done Well
Single Responsibility Principle (SRP) says a module should change for only one reason. A "reason" isn't a whim—it's a stakeholder, a business rule, or an external system your code answers to. When you cram multiple reasons into one class, every edit becomes a game of Jenga.
Before – mixed responsibilities
1// order.service.ts
2class OrderService {
3 async place(order) {
4 // 1. Persist order
5 // 2. Charge payment
6 // 3. Send confirmation email
7 }
8}
This class touches persistence, billing, and notifications—three distinct reasons to change.
After – focused modules
1class OrderRepository {
2 async save(order) { /* DB logic */ }
3}
4
5class PaymentGateway {
6 async charge(order) { /* Billing API */ }
7}
8
9class EmailNotifier {
10 async send(order) { /* SMTP logic */ }
11}
12
13class OrderService {
14 constructor(
15 private repo: OrderRepository,
16 private gateway: PaymentGateway,
17 private notifier: EmailNotifier
18 ) {}
19
20 async place(order) {
21 await this.repo.save(order);
22 await this.gateway.charge(order);
23 await this.notifier.send(order);
24 }
25}
Now you can swap email providers or change the schema without touching charging logic.
SRP red flags you'll spot in code review include classes that mix HTTP handlers, business logic, and data access in one file, frequent merge conflicts around large "god" classes, and methods with comments like "also does…"
A practical refactor path is "extract till you scream": pull the loudest secondary responsibility into its own class, write tests, and repeat.
Strapi tip: Keep custom controller logic thin—call separate services for file uploads, validations, and analytics instead of piling that work into a single controller file.
O - Open/Closed Principle: Extend Without Breaking
The Open/Closed Principle tells you to add new behavior by extension, not modification. Touching shipped, tested code, risks, regressions, and emergency rollbacks.
Before – brittle switch
1class DiscountCalculator {
2 get(price, tier) {
3 switch (tier) {
4 case 'silver': return price * 0.95;
5 case 'gold': return price * 0.9;
6 case 'platinum': return price * 0.85;
7 default: return price;
8 }
9 }
10}
Every new tier means editing the method and re-testing the whole class.
After – strategy pattern
1interface DiscountStrategy {
2 apply(price: number): number;
3}
4
5class SilverDiscount implements DiscountStrategy {
6 apply(price) { return price * 0.95; }
7}
8
9class GoldDiscount implements DiscountStrategy {
10 apply(price) { return price * 0.9; }
11}
12
13class Checkout {
14 constructor(private strategy: DiscountStrategy) {}
15 total(price: number) { return this.strategy.apply(price); }
16}
Add a DiamondDiscount
class tomorrow—no existing file changes needed.
Common violations include if/else
or switch
blocks keyed on types and hard-coded arrays of behaviors. Lean on patterns like Strategy, Decorator, and plugin architectures.
Write plugins that hook into lifecycle events instead of hacking core files. Your customization survives framework upgrades because you extend, not modify.
L - Liskov Substitution Principle: Reliable Inheritance
The Liskov Substitution Principle (LSP) requires that any subclass can stand in for its parent without breaking expectations—contracts, invariants, or side effects.
Classic violation
1class Rectangle {
2 setWidth(w) { this.w = w; }
3 setHeight(h) { this.h = h; }
4 area() { return this.w * this.h; }
5}
6
7class Square extends Rectangle {
8 setWidth(n) { this.w = this.h = n; }
9 setHeight(n) { this.w = this.h = n; }
10}
Code that sets width and height independently on a Rectangle
will fail for Square
.
Safer design
1interface Shape { area(): number; }
2
3class Rectangle implements Shape {
4 constructor(private w: number, private h: number) {}
5 area() { return this.w * this.h; }
6}
7
8class Square implements Shape {
9 constructor(private side: number) {}
10 area() { return this.side * this.side; }
11}
Both classes satisfy Shape
without overriding behavior or surprising callers.
Symptoms of LSP trouble include subclasses throwing NotImplemented
, client code checking instanceof
before calling a method, and overrides that weaken preconditions or strengthen postconditions.TypeScript interfaces help you model behavior first and keep inheritance honest.
I - Interface Segregation Principle: Focused Contracts
The Interface Segregation Principle warns against "fat" interfaces that force clients to depend on actions they never use.
Before – bloated interface
1interface MediaPlayer {
2 play(file: string): void;
3 record(source: string): void;
4 stream(url: string): void;
5}
6
7class BasicPlayer implements MediaPlayer {
8 play(file) { /* ok */ }
9 record() { throw new Error('Not supported'); }
10 stream() { throw new Error('Not supported'); }
11}
Consumers that only need play
still carry dead weight.
After – segregated interfaces
1interface Playable { play(file: string): void; }
2interface Recordable { record(source: string): void; }
3interface Streamable { stream(url: string): void; }
4
5class BasicPlayer implements Playable {
6 play(file) { /* ... */ }
7}
8
9class ProRecorder implements Playable, Recordable {
10 play(file) { /* ... */ }
11 record(src) { /* ... */ }
12}
Each client pulls in exactly what it needs, simplifying testing and upgrades. Look out for interfaces with more than one domain verb; that's often a sign to split.
D - Dependency Inversion Principle: Depend on Abstractions
The Dependency Inversion Principle flips traditional layering: high-level policy shouldn't import low-level details directly—they both depend on ideals expressed as interfaces.
Before – hard-wired dependency
1class FileLogger {
2 log(msg) { /* write to file */ }
3}
4
5class UserController {
6 constructor() { this.logger = new FileLogger(); }
7 save(user) { this.logger.log(`Saved ${user.name}`); }
8}
Testing UserController
now writes files and slows your test suite.
After – injected abstraction
1interface Logger {
2 log(msg: string): void;
3}
4
5class ConsoleLogger implements Logger {
6 log(msg) { console.log(msg); }
7}
8
9class UserController {
10 constructor(private logger: Logger) {}
11 save(user) { this.logger.log(`Saved ${user.name}`); }
12}
13
14// Wiring
15const controller = new UserController(new ConsoleLogger());
You can swap ConsoleLogger
for a FileLogger
, RemoteLogger
, or a jest mock without touching UserController
.
In larger projects, an IoC container automates the wiring so you focus on behavior, not plumbing. Watch for new SomeDependency()
scattered across business code—that's a cue to invert.
By internalizing these principles, you'll feel confident extending features, rewiring dependencies, and shipping updates without the dread of hidden breakage.
A Practical SOLID Implementation Guide
Starting with a single principle when adopting these design practices is crucial for effectively improving your codebase without overwhelming your team. This approach allows you to gradually integrate these practices into new code, setting a foundation for future enhancements.
During code reviews, aim to identify violations by focusing on structural issues like unnecessary interdependencies and responsibilities scattered across classes.
To facilitate team adoption, consider implementing code review checklists that highlight common pitfalls and success criteria. Sharing concrete examples of how principles have been applied successfully within or outside your projects can also boost understanding and acceptance.
Adopting incrementally allows teams to grow comfortable with the changes and see tangible benefits over time.
Best practices for SOLID adoption:
- Start small - Begin with the principle that addresses your most urgent pain points
- Involve the team - Ensure everyone understands the benefits and implementation approach
- Document patterns - Create a shared repository of successful implementations
- Measure progress - Track improvements in code quality, test coverage, and developer satisfaction
- Celebrate wins - Acknowledge when these principles help solve real problems
In terms of tools, ESLint, SonarQube, and TypeScript provide valuable support in detecting and handling code quality and maintainability issues. While they can highlight certain problem areas, they do not directly enforce adherence to design principles such as SOLID without additional customization.
Measuring success should focus on metrics like reduced bug reports, faster feature implementation, and improved test coverage. These indicators show the positive outcomes of adopting well-structured code.
For JavaScript and TypeScript developers, these principles can transform the development workflow by ensuring that code remains maintainable and scalable as projects grow.
When developing with a headless CMS like Strapi, applying these principles becomes particularly advantageous when creating plugins. The modularity and clear separation of concerns fostered by clean architecture lead to more maintainable and reusable components within your CMS.
Where Teams Go Wrong with SOLID
While SOLID principles can dramatically improve your codebase, they can also lead to problems when misapplied. Here are the most common pitfalls and their practical solutions.
The Over-Engineering Trap
Apply these principles without context and you'll create more problems than you solve. The biggest trap is over-engineering—splitting code into layers of abstractions that serve no purpose. When every feature spawns a new interface or base class, complexity explodes and maintainability disappears.
Teams fall into this trap when they follow principles dogmatically rather than pragmatically. Start with simple implementations and only introduce abstractions when you have at least two concrete use cases that benefit from them. Use the Rule of Three—wait until you have three similar implementations before extracting a shared abstraction.
Premature Abstraction
Premature abstraction compounds the problem. Wrapping a single logging call behind an elaborate interface before understanding your domain creates unnecessary indirection and slows delivery.
In JavaScript and TypeScript, this appears as sprawling type definitions or injecting dependencies that should remain simple function parameters.
Embrace YAGNI (You Aren't Gonna Need It) and implement the simplest solution first. Refactor toward abstractions only when patterns emerge naturally in your codebase. Start with concrete implementations and extract interfaces only when multiple implementations become necessary.
Inconsistent Implementation
Inconsistent adoption across your codebase makes everything worse. One file embraces Dependency Inversion while the next scatters new everywhere. Engineering leaders who rely on these principles stress the need for team conventions and tooling to maintain alignment.
Create architectural decision records (ADRs) that document when and how to apply each principle. Implement team-wide code reviews focused on architectural consistency, and consider appointing architecture champions to guide adoption across teams.
Implementing SOLID in Your Development Workflow
Start with the principle that solves your biggest pain point. Apply it to new features first, then gradually refactor existing code during maintenance. Reinforce patterns through code reviews and automate checks with ESLint or TypeScript's strict mode. These guardrails give you confidence to refactor safely.
Pick one principle today and stick with it. Within weeks, you'll notice cleaner architecture leading to more intuitive decisions and faster delivery.
For Strapi projects, these principles make your plugins more maintainable as content models evolve.