Understand the Acronym Spaghetti: Part 3 - SOLID Principles
Understand the Acronym Spaghetti: Part 3 - SOLID Principles
TLDR
- Single Responsibility: Each function should do one thing well
- Open/Closed: Add new features without changing existing code
- Liskov Substitution: Replacements should work the same way
- Interface Segregation: Don't force unused dependencies
- Dependency Inversion: Depend on contracts, not implementations
In Part 1, we covered The Foundation - core principles, and in Part 2, we explored Code Quality & Safety principles.
SOLID Architecture principles are about the big picture - how to structure systems that can grow and evolve over time. While SOLID comes from object-oriented programming, these patterns apply to React components, Node.js modules, and modern JavaScript applications.
SOLID Principles Deep Dive
SOLID is the foundation of object-oriented architecture. These 5 principles work together to create flexible, maintainable systems.
SOLID
Also known as: The 5 OOP Principles
Definition: 5 OOP principles for clean architecture: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion.
Why it matters: Creates flexible, maintainable systems that can evolve without breaking existing code.
When to break this rule: Simple applications where the overhead of full SOLID compliance outweighs the benefits.
Note: We covered Single Responsibility Principle in Part 1. Here are the other 4 principles:
Open/Closed Principle (OCP)
Open for extension, closed for modification
Simple explanation: You should be able to add new features without changing existing code.
React component example
// ❌ Must modify existing code to add new behavior
const WelcomeNotification = ({ user }: { user: User }) => {
const getMessage = () => {
if (user.type === 'premium') {
return 'Welcome Premium User!';
} else if (user.type === 'basic') {
return 'Welcome!';
}
// To add 'enterprise' - must modify this function
};
return <div>{getMessage()}</div>;
};
// ✅ Can extend without modifying existing code
const welcomeStrategies = {
premium: (user: User) => `Welcome ${user.name}! Enjoy your premium features.`,
basic: (user: User) => `Welcome ${user.name}!`,
enterprise: (user: User) => `Welcome ${user.name}! Your enterprise dashboard awaits.`
};
const WelcomeNotification = ({ user }: { user: User }) => {
const strategy = welcomeStrategies[user.type] || welcomeStrategies.basic;
return <div>{strategy(user)}</div>;
};
Node.js API example
// ❌ Must modify to add new user types
app.post('/api/welcome', (req, res) => {
const { user } = req.body;
if (user.type === 'premium') {
// Premium welcome logic
} else if (user.type === 'basic') {
// Basic welcome logic
}
// To add 'enterprise' - must modify this endpoint
});
// ✅ Extensible without modification
const welcomeHandlers = {
premium: async (user: User) => {
await emailService.sendPremiumWelcome(user);
},
basic: async (user: User) => {
await emailService.sendBasicWelcome(user);
}
};
app.post('/api/welcome', async (req, res) => {
const { user } = req.body;
const handler = welcomeHandlers[user.type] || welcomeHandlers.basic;
await handler(user);
res.json({ success: true });
});
Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types
Simple explanation: If you have different implementations of the same interface, you should be able to swap them without breaking anything.
React component example
// ❌ Different components with inconsistent interfaces
const BasicButton = ({ label, onClick }: { label: string; onClick: () => void }) => {
return <button onClick={onClick}>{label}</button>;
};
const PremiumButton = ({
label,
onClick,
theme
}: {
label: string;
onClick: (event: any) => void; // Different signature!
theme: string; // Extra required prop!
}) => {
return <button className={theme} onClick={onClick}>{label}</button>;
};
// ✅ Consistent interfaces - can be swapped freely
interface ButtonProps {
label: string;
onClick: () => void;
theme?: string;
}
const BasicButton = ({ label, onClick, theme = 'default' }: ButtonProps) => {
return <button className={theme} onClick={onClick}>{label}</button>;
};
const PremiumButton = ({ label, onClick, theme = 'premium' }: ButtonProps) => {
return <button className={theme} onClick={onClick}>{label}</button>;
};
Node.js service example
// ❌ Services with inconsistent interfaces
const basicEmailService = {
send: (to: string, subject: string, body: string) => {
// Send basic email
}
};
const premiumEmailService = {
send: (to: string, subject: string, body: string, priority: 'high' | 'low') => {
// Requires extra parameter - breaks substitution
}
};
// ✅ Consistent interfaces - can be swapped freely
interface EmailService {
send(to: string, subject: string, body: string, options?: any): Promise<void>;
}
const basicEmailService: EmailService = {
send: async (to, subject, body) => {
// Send basic email
}
};
const premiumEmailService: EmailService = {
send: async (to, subject, body, options = {}) => {
// Send premium email with optional features
}
};
Interface Segregation Principle (ISP)
Clients shouldn't depend on interfaces they don't use
Simple explanation: Don't force a component or function to depend on methods it doesn't need. Make small, focused interfaces instead of big ones.
React component example
// ❌ Fat interface - component forced to depend on methods it doesn't use
interface UserService {
getUser(id: string): Promise<User>;
createUser(data: any): Promise<User>;
deleteUser(id: string): Promise<void>;
generateReport(): Promise<string>;
sendNotifications(): Promise<void>;
}
const UserProfile = ({ userId, userService }: {
userId: string;
userService: UserService; // Depends on methods it doesn't need
}) => {
const [user, setUser] = useState(null);
useEffect(() => {
userService.getUser(userId).then(setUser); // Only uses getUser
}, [userId]);
return <div>{user?.name}</div>;
};
// ✅ Focused interfaces - components only depend on what they need
interface UserReader {
getUser(id: string): Promise<User>;
}
const UserProfile = ({ userId, userReader }: {
userId: string;
userReader: UserReader; // Only depends on reading
}) => {
const [user, setUser] = useState(null);
useEffect(() => {
userReader.getUser(userId).then(setUser); // Clear dependency
}, [userId]);
return <div>{user?.name}</div>;
};
Node.js API example
// ❌ Fat service interface
const userController = {
getProfile: async (req, res, userService: UserService) => {
const user = await userService.getUser(req.params.id); // Only needs getUser
res.json(user);
}
};
// ✅ Focused service dependencies
interface UserReader {
getUser(id: string): Promise<User>;
}
interface UserWriter {
createUser(data: any): Promise<User>;
updateUser(id: string, data: any): Promise<User>;
}
const userController = {
getProfile: async (req, res, userReader: UserReader) => {
const user = await userReader.getUser(req.params.id);
res.json(user);
},
updateProfile: async (req, res, userWriter: UserWriter) => {
const user = await userWriter.updateUser(req.params.id, req.body);
res.json(user);
}
};
Dependency Inversion Principle (DIP)
Depend on abstractions, not concretions
Simple explanation: Don't depend on specific implementations. Depend on interfaces so you can easily swap out different implementations.
Why "inversion"? Traditionally, high-level modules (business logic) depend on low-level modules (database, file system). DIP inverts this - both should depend on abstractions.
Node.js service example
// ❌ Traditional dependency flow - high-level depends on low-level
const UserService = () => {
const saveUser = async (user: User) => {
// Direct dependency on specific implementations
await fetch('/api/mysql/users', { // Hard-coded to MySQL API
method: 'POST',
body: JSON.stringify(user)
});
console.log('User saved'); // Hard-coded to console logging
// Hard to test, hard to change database or logger
};
return { saveUser };
};
// ✅ Inverted dependencies - both depend on abstractions
interface Database {
save(user: User): Promise<void>;
}
interface Logger {
log(message: string): void;
}
const createUserService = (db: Database, logger: Logger) => {
const saveUser = async (user: User) => {
await db.save(user); // Depends on abstraction
logger.log('User saved'); // Depends on abstraction
};
return { saveUser };
};
// Easy to test with mocks, easy to swap implementations
const userService = createUserService(mysqlDatabase, consoleLogger);
React component example
// ❌ Component with hard-coded dependencies
const UserForm = () => {
const [user, setUser] = useState({});
const handleSubmit = async () => {
// Direct dependency on specific API endpoint
await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user)
});
// Direct dependency on specific analytics service
gtag('event', 'user_created', { user_id: user.id });
};
return <form onSubmit={handleSubmit}>...</form>;
};
// ✅ Component depends on abstractions
interface UserService {
saveUser(user: User): Promise<void>;
}
interface Analytics {
track(event: string, data: any): void;
}
const UserForm = ({ userService, analytics }: {
userService: UserService;
analytics: Analytics;
}) => {
const [user, setUser] = useState({});
const handleSubmit = async () => {
await userService.saveUser(user); // Depends on abstraction
analytics.track('user_created', { user_id: user.id }); // Depends on abstraction
};
return <form onSubmit={handleSubmit}>...</form>;
};
Benefits:
- Testability: Inject mocks for testing
- Flexibility: Swap implementations without changing business logic
- Maintainability: Changes to low-level modules don't affect high-level modules
Common Pitfall: Trying to apply all SOLID principles at once instead of gradually refactoring towards them as the system grows.
Summary
SOLID principles are the foundation of good object-oriented design. They work together to create systems that are:
- Flexible: Easy to extend and modify
- Testable: Components can be tested in isolation
- Maintainable: Changes don't ripple through the entire system
Start with Single Responsibility and Dependency Inversion - these give you the biggest bang for your buck.
Coming next: Part 4 covers System Architecture patterns like CQRS and Hexagonal Architecture, and Part 5 explores Advanced Principles for elegant code.
Remember: good architecture is not about following every pattern - it's about making intentional choices that serve your specific needs.