SOLID is an acronym for five principles of object-oriented software design. These principles are intended to make software more maintainable and scalable. The principles are:
Single Responsibility Principle
A class should have only one responsibility or one reason to change. This means that a class should do one thing and do it well, without being concerned with other aspects of the system.
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
getName() {
return this.name;
}
getEmail() {
return this.email;
}
setEmail(email) {
this.email = email;
}
}
In the above example, the User
class has a single responsibility: representing a user with a name and email address. Its methods are all related to this responsibility and do not perform any unrelated tasks.
Open/Closed Principle
Classes should be open for extension but closed for modification. This means that classes should be designed in a way that allows them to be extended to add new behavior without modifying existing code.
class Shape {
getArea() {
// method to be implemented by subclasses
}
}
class Circle extends Shape {
constructor(radius) {
this.radius = radius;
}
getArea() {
return Math.PI * this.radius * this.radius;
}
}
class Rectangle extends Shape {
constructor(width, height) {
this.width = width;
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
In the above example, the Shape
class is open for extension because it provides an abstract getArea()
method that can be implemented by subclasses. The Circle
and Rectangle
classes are closed for modification because they extend the Shape
class without modifying its behavior.
Liskov Substitution Principle
Subclasses should be substitutable for their base classes. This means that objects of a subclass should be able to be used in any context where objects of the base class can be used, without causing any problems.
class Vehicle {
startEngine() {
// method to be implemented by subclasses
}
}
class Car extends Vehicle {
startEngine() {
console.log('Vroom, vroom!');
}
}
class ElectricCar extends Vehicle {
startEngine() {
console.log('Silent, but deadly...');
}
}
function testVehicle(vehicle) {
vehicle.startEngine();
}
const car = new Car();
const electricCar = new ElectricCar();
testVehicle(car); // logs 'Vroom, vroom!'
testVehicle(electricCar); // logs 'Silent, but deadly...'
In the above example, the Vehicle
class defines an abstract startEngine()
method that is implemented by the Car
and ElectricCar
classes. The testVehicle()
function expects a Vehicle
instance and calls its startEngine()
method, regardless of whether the instance is a Car
or an ElectricCar
.
This demonstrates the Liskov Substitution Principle because the derived classes can be used interchangeably with the base class.
Interface Segregation Principle
Clients should not be forced to depend on methods they do not use. This means that a class should not implement an interface if it does not use all of the methods defined in that interface.
class Document {
constructor(title, content) {
this.title = title;
this.content = content;
}
getTitle() {
return this.title;
}
getContent() {
return this.content;
}
setTitle(title) {
this.title = title;
}
setContent(content) {
this.content = content;
}
}
class ReadOnlyDocument extends Document {
constructor(title, content) {
super(title, content);
}
setTitle(title) {
throw new Error('Cannot modify a read-only document.');
}
setContent(content) {
throw new Error('Cannot modify a read-only document.');
}
}
const doc1 = new Document('My Document', 'Some content.');
doc1.setTitle('New Title');
const doc2 = new ReadOnlyDocument('My Read-Only Document', 'Some content.');
doc2.setTitle('New Title'); // throws an error
In the above example, the Document
class defines a set of methods for managing a document's title and content. The ReadOnlyDocument
class extends the Document
class and overrides the setTitle()
and setContent()
methods to throw an error if they are called, thus implementing a read-only interface for the document.
This allows you to use the Document
class when you need to modify a document, and the ReadOnlyDocument
class when you only need to read its contents.
Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. This means that classes should not depend on concrete implementations of other classes, but rather on abstractions or interfaces.
class UserRepository {
constructor() {
this.users = [];
}
getById(id) {
return this.users.find(user => user.id === id);
}
add(user) {
this.users.push(user);
}
update(id, data) {
const user = this.getById(id);
if (user) {
Object.assign(user, data);
}
}
delete(id) {
this.users = this.users.filter(user => user.id !== id);
}
}
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
getUser(id) {
return this.userRepository.getById(id);
}
updateUser(id, data) {
return this.userRepository.update(id, data);
}
deleteUser(id) {
return this.userRepository.delete(id);
}
}
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
In the above example, the UserService
class depends on the UserRepository
class to manage user records. However, the UserService
class does not depend on a specific implementation of the UserRepository
class. Instead, it depends on an abstract interface that defines the required behavior of the repository.
This allows you to use any class that implements the required interface as the user repository, without modifying the UserService
class. For example, you could create a MockUserRepository
class that simulates the behavior of a user repository for testing purposes, and use it with the UserService
class without making any changes to the UserService
class.
Conclusion
In JavaScript, these principles can be applied in a number of ways. For example, you can use the class
keyword and the extends
keyword to create a base class and subclasses, and you can use interfaces (which are just objects with specific methods) to define contracts that your classes must implement. You can also use dependency injection to invert dependencies between classes, and you can use composition to provide more granular control over the behavior of your classes.