What are SOLID principles?

What are SOLID principles?

SOLID principles are five design principles that help us keep our application reusable, maintainable, scalable, and loosely coupled. The SOLID principles are:

  • Single-responsibility principle

  • Open-Closed principle

  • Liskov substitution principle

  • Interface segregation principle

  • Dependency inversion principle

Okay, let’s examine each of these principles individually. I use React as an example, but the core concepts are similar to other programming languages and frameworks.

Single-responsibility Principle

The Single Responsibility Principle states that a component should have one clear purpose or responsibility. It should focus on specific functionality or behavior and avoid taking on unrelated tasks. Following SRP makes components more focused, modular, easily comprehensible, and modified. Let’s see the actual implementation.

const Products = () => {
    return (
        <div className="products">
            {products.map((product) => (
                <div key={product?.id} className="product">
                    <h3>{product?.name}</h3>
                    <p>${product?.price}</p>
                </div>
            ))}
        </div>
    );
};

In the above example, the Products component violates the Single Responsibility Principle by taking on multiple responsibilities. It manages the iteration of products and handles the UI rendering for each product. This can make the component challenging to understand, maintain, and test in the future.

Instead, do this to adhere to SRP:

// ✅ Good Practice: Separating Responsibilities into Smaller Components

import Product from './Product';
import products from '../../data/products.json';

const Products = () => {
    return (
        <div className="products">
            {products.map((product) => (
                <Product key={product?.id} product={product} />
            ))}
        </div>
    );
};

// Product.js
// Separate component responsible for rendering the product details
const Product = ({ product }) => {
    return (
        <div className="product">
            <h3>{product?.name}</h3>
            <p>${product?.price}</p>
        </div>
    );
};

This separation ensures each component has a single responsibility, making them easier to understand, test, and maintain.

Open-Closed Principle

The Open-Closed Principle emphasizes that components should be open for extension (can add new behaviors or functionalities) but closed for modification(existing code should remain unchanged). This principle encourages the creation of code that is resilient to change, modular, and easily maintainable. Let’s see the actual implementation.

// ❌ Bad Practice: Violating the Open-Closed Principle

// Button.js
// Existing Button component
const Button = ({ text, onClick }) => {
  return (
    <button onClick={onClick}>
      {text}
    </button>
  );
}

// Button.js
// Modified Existing Button component with additional icon prop (modification)
const Button = ({ text, onClick, icon }) => {
  return (
    <button onClick={onClick}>
      <i className={icon} />
      <span>{text}</span>
    </button>
  );
}

// Home.js
// 👇 Avoid: Modified existing component prop
const Home = () => {
  const handleClick= () => {};

  return (
    <div>
      {/* ❌ Avoid this */}
      <Button text="Submit" onClick={handleClick} icon="fas fa-arrow-right" /> 
    </div>
  );
}

In the above example, we modify the existing Button component by adding an icon prop. Altering an existing component to accommodate new requirements violates the Open-Closed Principle. These changes make the component more fragile and introduce the risk of unintended side effects when used in different contexts.

Instead, Do this:

// ✅ Good Practice: Open-Closed Principle

// Button.js
// Existing Button functional component
const Button = ({ text, onClick }) => {
  return (
    <button onClick={onClick}>
      {text}
    </button>
  );
}

// IconButton.js
// IconButton component
// ✅ Good: You have not modified anything here.
const IconButton = ({ text, icon, onClick }) => {
  return (
    <button onClick={onClick}>
      <i className={icon} />
      <span>{text}</span>
    </button>
  );
}

const Home = () => {
  const handleClick = () => {
    // Handle button click event
  }

  return (
    <div>
      <Button text="Submit" onClick={handleClick} />
      {/* 
      <IconButton text="Submit" icon="fas fa-heart" onClick={handleClick} />
    </div>
  );
}

In the above example, we create a separate IconButton functional component. The IconButton component encapsulates the rendering of an icon button without modifying the existing Button component. It adheres to the Open-Closed Principle by extending the functionality through composition rather than modification.

Liskov Substitution Principle

The Liskov Substitution Principle (LSP) is a fundamental principle of object-oriented programming that emphasizes the need for substitutability of objects within a hierarchy. In the context of React components, LSP promotes the idea that derived components should be able to substitute their base components without affecting the correctness or behavior of the application. Let’s see the actual implementation.

// ⚠️ Bad Practice
// This approach violates the Liskov Substitution Principle as it modifies 
// the behavior of the derived component, potentially resulting in unforeseen 
// problems when substituting it for the base Select component.
const BadCustomSelect = ({ value, iconClassName, handleChange }) => {
  return (
    <div>
      <i className={iconClassName}></i>
      <select value={value} onChange={handleChange}>
        <options value={1}>One</options>
        <options value={2}>Two</options>
        <options value={3}>Three</options>
      </select>
    </div>
  );
};

const LiskovSubstitutionPrinciple = () => {
  const [value, setValue] = useState(1);
  const handleChange = (event) => {
    setValue(event.target.value);
  };
  return (
    <div>
      {/** ❌ Avoid this */}
      {/** Below Custom Select doesn't have the characteristics of base `select` element */}
      <BadCustomSelect value={value} handleChange={handleChange} />
    </div>
  );

In the above example, we have a BadCustomSelect component intended to serve as a custom select input in React. However, it violates the Liskov Substitution Principle (LSP) because it restricts the behavior of the base select element.

Instead, Do this:

// ✅ Good Practice
// This component follows the Liskov Substitution Principle and allows the use of select's characteristics.

const CustomSelect = ({ value, iconClassName, handleChange, ...props }) => {
  return (
    <div>
      <i className={iconClassName}></i>
      <select value={value} onChange={handleChange} {...props}>
        <options value={1}>One</options>
        <options value={2}>Two</options>
        <options value={3}>Three</options>
      </select>
    </div>
  );
};
const LiskovSubstitutionPrinciple = () => {
  const [value, setValue] = useState(1);
  const handleChange = (event) => {
    setValue(event.target.value);
  };
  return (
    <div>
      {/* ✅ This CustomSelect component follows the Liskov Substitution Principle */}
      <CustomSelect
        value={value}
        handleChange={handleChange}
        defaultValue={1}
      />
    </div>
  );
};

In the revised code, we have a CustomSelect component intended to extend the functionality of the standard select element in React. The component accepts props such as value, iconClassName, handleChange, and additional props using the spread operator ...props. By allowing the use of the select element's characteristics and accepting additional props, the CustomSelect component follows the Liskov Substitution Principle (LSP).

Interface Segregation Principle

The Interface Segregation Principle (ISP) suggests that interfaces should be focused and tailored to specific client requirements rather than being overly broad and forcing clients to implement unnecessary functionality. Let’s see the actual implementation.

// ❌ Avoid: disclose unnecessary information for this component
// This introduces unnecessary dependencies and complexity for the component
const ProductThumbnailURL = ({ product }) => {
  return (
    <div>
      <img src={product.imageURL} alt={product.name} />
    </div>
  );
};

// ❌ Bad Practice
const Product = ({ product }) => {
  return (
    <div>
      <ProductThumbnailURL product={product} />
      <h4>{product?.name}</h4>
      <p>{product?.description}</p>
      <p>{product?.price}</p>
    </div>
  );
};

const Products = () => {
  return (
    <div>
      {products.map((product) => (
        <Product key={product.id} product={product} />
      ))}
    </div>
  );
}

In the above example, we pass the entire product details to the ProductThumbnailURL component, even though it doesn’t require it. It adds unnecessary risks and complexity to the component and violates the Interface Segregation Principle (ISP).

Let’s refactor to adhere to ISP:

// ✅ Good: reducing unnecessary dependencies and making 
// the codebase more maintainable and scalable.
const ProductThumbnailURL = ({ imageURL, alt }) => {
  return (
    <div>
      <img src={imageURL} alt={alt} />
    </div>
  );
};

// ✅ Good Practice
const Product = ({ product }) => {
  return (
    <div>
      <ProductThumbnailURL imageURL={product.imageURL} alt={product.name} />
      <h4>{product?.name}</h4>
      <p>{product?.description}</p>
      <p>{product?.price}</p>
    </div>
  );
};

const Products = () => {
  return (
    <div>
      {products.map((product) => (
        <Product key={product.id} product={product} />
      ))}
    </div>
  );
};

In the revised code, the ProductThumbnailURL component only receives the required information instead of the entire product details. It prevents unnecessary risks and fosters the Interface Segregation Principle (ISP).

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) emphasizes that high-level components should not depend on low-level components. This principle fosters loose coupling and modularity and facilitates easier maintenance of software systems. Let’s see the actual implementation.

// ❌ Bad Practice
// This component follows concretion instead of abstraction and 
// breaks Dependency Inversion Principle

const CustomForm = ({ children }) => {
  const handleSubmit = () => {
    // submit operations
  };
  return <form onSubmit={handleSubmit}>{children}</form>;
};

const DependencyInversionPrinciple = () => {
  const [email, setEmail] = useState();

  const handleChange = (event) => {
    setEmail(event.target.value);
  };

  const handleFormSubmit = (event) => {
    // submit business logic here
  };

  return (
    <div>
      {/** ❌ Avoid: tightly coupled and hard to change */}
      <BadCustomForm>
        <input
          type="email"
          value={email}
          onChange={handleChange}
          name="email"
        />
      </BadCustomForm>
    </div>
  );
};

The CustomForm component is tightly coupled to its children, preventing flexibility and making it challenging to change or extend its behavior.

Instead, Do this:

// ✅ Good Practice
// This component follows abstraction and promotes Dependency Inversion Principle

const AbstractForm = ({ children, onSubmit }) => {
  const handleSubmit = (event) => {
    event.preventDefault();
    onSubmit();
  };

  return <form onSubmit={handleSubmit}>{children}</form>;
};

const DependencyInversionPrinciple = () => {
  const [email, setEmail] = useState();

  const handleChange = (event) => {
    setEmail(event.target.value);
  };

  const handleFormSubmit = () => {
    // submit business logic here
  };

  return (
    <div>
      {/** ✅ Use the abstraction instead */}
      <AbstractForm onSubmit={handleFormSubmit}>
        <input
          type="email"
          value={email}
          onChange={handleChange}
          name="email"
        />
        <button type="submit">Submit</button>
      </AbstractForm>
    </div>
  );
};

In the revised code, we introduce the AbstractForm component, which acts as an abstraction for the form. It receives the onSubmit function as a prop and handles the form submission. This approach allows us to easily swap out or extend the form behavior without modifying the higher-level component.

Conclusion

The SOLID principles provide guidelines that empower developers to create well-designed, maintainable, and extensible software solutions. By adhering to these principles, developers can achieve modularity, code reusability, flexibility, and reduced code complexity.