Skip to main content

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.