This article provides a clear and concise overview of the SOLID design principles, accompanied by straightforward code examples to help you grasp each concept with ease.
SOLID is a set of five design principles intended to make software designs more understandable, flexible, and maintainable.
Table of Contents
- S — Single Responsibility Principle (SRP)
- O — Open-Closed Principle (OCP)
- L — Liskov Substitution Principle (LSP)
- I — Interface Segregation Principle (ISP)
- D — Dependency Inversion Principle (DIP)
The principles are particularly useful in object-oriented design and are commonly applied in front-end and back-end development. Here’s a brief overview of each SOLID principle with a code example in TypeScript:
S — Single Responsibility Principle (SRP)
A class should have one and only one reason to change, meaning it should have only one job or responsibility.
This principle encourages a focused approach, ensuring that changes or updates to one aspect of your UI won’t inadvertently affect unrelated parts.
// UserProfile.tsx
import React from 'react';
interface UserProfileProps {
username: string;
email: string;
}
const UserProfile: React.FC<UserProfileProps> = ({ username, email }) => {
return (
<div>
<h2>{username}</h2>
<p>{email}</p>
</div>
);
};
export default UserProfile;
Here, UserProfile
is responsible only for displaying user information.
O — Open-Closed Principle (OCP)
Software entities should be open for extension but closed for modification.
This approach ensures that the core components remain stable and unchanged, reducing the risk of unintended side effects when adding new functionalities.
// Alert.tsx
import React from 'react';
interface AlertProps {
message: string;
}
const Alert: React.FC<AlertProps> = ({ message }) => {
return <div className="alert">{message}</div>;
};
export default Alert;
// SuccessAlert.tsx
import React from 'react';
import Alert from './Alert';
const SuccessAlert: React.FC<{ message: string }> = ({ message }) => {
return <Alert message={`Success: ${message}`} />;
};
export default SuccessAlert;
Alert
can be extended by SuccessAlert
without modifying the original Alert
component.
L — Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
In simpler terms, if you have a base component or module, any derived components should be usable in place of the base component without causing unexpected issues.
// BaseButton.tsx
import React from 'react';
interface BaseButtonProps {
onClick: () => void;
label: string;
}
const BaseButton: React.FC<BaseButtonProps> = ({ onClick, label }) => {
return <button onClick={onClick}>{label}</button>;
};
export default BaseButton;
// IconButton.tsx
import React from 'react';
import BaseButton from './BaseButton';
interface IconButtonProps extends BaseButtonProps {
icon: string;
}
const IconButton: React.FC<IconButtonProps> = ({ onClick, label, icon }) => {
return (
<BaseButton onClick={onClick} label={<span><i className={icon}></i> {label}</span>} />
);
};
export default IconButton;
IconButton
can be used anywhere BaseButton
without affecting the correctness of the application.
I — Interface Segregation Principle (ISP)
No client should be forced to depend on methods it does not use. This means creating specific interfaces for specific needs.
In other words, rather than creating a single large interface, break it down into smaller, focused interfaces that are tailored to individual components.
// interfaces.ts
export interface Flyable {
fly(): void;
}
export interface Swimmable {
swim(): void;
}
// Bird.ts
import { Flyable } from './interfaces';
class Bird implements Flyable {
fly() {
console.log('Bird is flying');
}
}
// Fish.ts
import { Swimmable } from './interfaces';
class Fish implements Swimmable {
swim() {
console.log('Fish is swimming');
}
}
Separate interfaces Flyable
and Swimmable
are created to ensure that classes only implement what they need.
D — Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules but on abstractions. Both should depend on abstractions.
In simpler terms, instead of components directly depending on each other, they rely on interfaces or abstract classes, making the code more adaptable to changes.
// Logger.ts
export interface Logger {
log(message: string): void;
}
export class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}
// UserService.ts
import { Logger } from './Logger';
class UserService {
constructor(private logger: Logger) {}
createUser(username: string) {
this.logger.log(`User created: ${username}`);
}
}
// App.ts
import { UserService } from './UserService';
import { ConsoleLogger } from './Logger';
const logger = new ConsoleLogger();
const userService = new UserService(logger);
userService.createUser('JohnDoe');
Here, UserService
depends on the Logger
abstraction, making it flexible to change the logging mechanism without altering the UserService
.
These SOLID principles help in creating software that is easy to maintain, extend, and refactor, which is essential for developing robust front-end and back-end applications.