What is SOLID principles in JS context?
SOLID is an acronym for five design principles intended to make software designs more understandable, flexible, and maintainable. Coined by Robert C. Martin (Uncle Bob), these principles are not specific to any language but are highly applicable and beneficial when developing robust and scalable applications in JavaScript. Adhering to SOLID helps in creating cleaner, more modular, and less error-prone codebases.
1. Single Responsibility Principle (SRP)
A class, module, or function should have only one reason to change. This means it should have only one primary responsibility. In JavaScript, this often translates to ensuring functions and classes are highly focused on a single task, avoiding 'God objects' or functions that do too much.
Example: Violation (Tight Coupling)
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
saveToDatabase() {
// Logic to save user to database
console.log(`Saving user ${this.name} to DB.`);
}
sendWelcomeEmail() {
// Logic to send welcome email
console.log(`Sending welcome email to ${this.email}.`);
}
}
Here, the User class has three responsibilities: managing user data, saving to a database, and sending emails. If the email sending logic changes, the User class needs modification, even if the user data structure remains the same.
Example: Adherence (Separation of Concerns)
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
// Only responsible for user data properties and methods related to user's intrinsic behavior
}
class UserRepository {
save(user) {
// Logic to save user to database
console.log(`Saving user ${user.name} to DB.`);
}
}
class EmailService {
sendWelcomeEmail(user) {
// Logic to send welcome email
console.log(`Sending welcome email to ${user.email}.`);
}
}
Now, each class has a single responsibility. Changes to database logic only affect UserRepository, and email changes only affect EmailService, making the system more modular and easier to maintain.
2. Open/Closed Principle (OCP)
Software entities (classes, modules, functions) should be open for extension, but closed for modification. This means you should be able to add new functionality without altering existing, working code.
Example: Violation (Requires Modification)
class ReportGenerator {
generateReport(type, data) {
if (type === 'JSON') {
return JSON.stringify(data);
} else if (type === 'CSV') {
const headers = Object.keys(data[0]).join(',');
const rows = data.map(row => Object.values(row).join(',')).join('\n');
return `${headers}\n${rows}`;
}
throw new Error('Unknown report type');
}
}
Adding a new report type (e.g., XML) requires modifying the generateReport method, which violates OCP by changing existing, proven code.
Example: Adherence (Open for Extension)
class JsonReportFormatter {
format(data) {
return JSON.stringify(data);
}
}
class CsvReportFormatter {
format(data) {
const headers = Object.keys(data[0]).join(',');
const rows = data.map(row => Object.values(row).join(',')).join('\n');
return `${headers}\n${rows}`;
}
}
// New formatters can be added without modifying existing code
class XmlReportFormatter {
format(data) {
// Basic XML structure example
const items = data.map(item =>
`<item>${Object.entries(item).map(([k, v]) => `<${k}>${v}</${k}>`).join('')}</item>`
).join('');
return `<data>${items}</data>`;
}
}
class ReportGenerator {
constructor(formatter) { // Accepts any formatter with a 'format' method
this.formatter = formatter;
}
generateReport(data) {
return this.formatter.format(data);
}
}
// Usage:
// const data = [{id: 1, name: 'Test User'}, {id: 2, name: 'Another User'}];
// const jsonGenerator = new ReportGenerator(new JsonReportFormatter());
// console.log(jsonGenerator.generateReport(data));
// const csvGenerator = new ReportGenerator(new CsvReportFormatter());
// console.log(csvGenerator.generateReport(data));
// const xmlGenerator = new ReportGenerator(new XmlReportFormatter());
// console.log(xmlGenerator.generateReport(data));
By using a strategy pattern or composition, new report formatters can be added without changing the ReportGenerator class, only by creating new formatter classes and passing them in. The ReportGenerator is closed for modification but open for extension.
3. Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering the correctness of the program. In JavaScript, this means that if you have a class hierarchy, derived classes should be usable wherever their base classes are used, without unexpected behavior or breaking the 'contract' defined by the base class.
Example: Violation (Unexpected Behavior)
class Bird {
fly() {
return 'Flying high!';
}
}
class Penguin extends Bird {
fly() {
throw new Error('Penguins cannot fly!'); // Violates LSP
}
}
function makeBirdFly(bird) {
console.log(bird.fly());
}
// makeBirdFly(new Bird()); // Works as expected
// makeBirdFly(new Penguin()); // Throws an error, breaking the expectation that all Birds can fly
The Penguin subclass breaks the contract of the Bird superclass's fly method. If a function expects any Bird and calls fly, a Penguin instance would cause an error, making it not substitutable.
Example: Adherence (Consistent Behavior)
class Bird {
// Base functionality for all birds (e.g., eat, layEggs)
}
class FlyingBird extends Bird {
fly() {
return 'Flying high!';
}
}
class NonFlyingBird extends Bird {
swim() {
return 'Swimming gracefully!';
}
}
class Sparrow extends FlyingBird {}
class Penguin extends NonFlyingBird {}
function makeFlyingBirdPerform(bird) {
if (bird instanceof FlyingBird) {
console.log(bird.fly());
} else {
console.log('This bird is not a flying bird.');
}
}
// makeFlyingBirdPerform(new Sparrow()); // Works: 'Flying high!'
// makeFlyingBirdPerform(new Penguin()); // Works: 'This bird is not a flying bird.'
By creating a hierarchy that accurately reflects capabilities (e.g., FlyingBird vs. NonFlyingBird), Penguin no longer inherits a fly method it cannot implement. Any function expecting a FlyingBird will work correctly, and functions expecting a generic Bird can handle its specific types appropriately without unexpected failures.
4. Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use. In JavaScript, this typically means avoiding 'fat' interfaces on objects or modules. Instead, create smaller, more specific object shapes (contracts) that clients can implement or depend on, often using duck typing or composition.
Example: Violation (Fat Interface)
class Worker {
work() {
console.log('Working...');
}
eat() {
console.log('Eating lunch...');
}
sleep() {
console.log('Sleeping...');
}
}
class Robot extends Worker {
// Robots don't eat or sleep, but are forced to implement these methods
eat() {
throw new Error('Robots do not eat!');
}
sleep() {
throw new Error('Robots do not sleep!');
}
}
The Worker class has a 'fat' interface, forcing Robot to implement methods it doesn't use (or throw errors for them), violating ISP. Any client expecting a Worker might try to call eat() or sleep() on a Robot, leading to runtime errors.
Example: Adherence (Segregated Interfaces)
// Define 'interfaces' as functions expecting specific methods
const canWork = (obj) => typeof obj.work === 'function';
const canEat = (obj) => typeof obj.eat === 'function';
const canSleep = (obj) => typeof obj.sleep === 'function';
class Human {
work() { console.log('Human working...'); }
eat() { console.log('Human eating...'); }
sleep() { console.log('Human sleeping...'); }
}
class Robot {
work() { console.log('Robot working...'); }
}
function performWork(entity) {
if (canWork(entity)) {
entity.work();
} else {
console.log('Cannot work.');
}
}
function provideLunch(entity) {
if (canEat(entity)) {
entity.eat();
} else {
console.log('Does not need to eat.');
}
}
// performWork(new Human()); // Human working...
// performWork(new Robot()); // Robot working...
// provideLunch(new Human()); // Human eating...
// provideLunch(new Robot()); // Does not need to eat.
By segregating concerns, Robot only implements work(), and Human implements work(), eat(), and sleep(). Client functions (performWork, provideLunch) only depend on the specific capabilities they need, without forcing entities to have methods they don't use.
5. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions. This promotes loose coupling, making systems easier to change and test by reducing direct dependencies.
Example: Violation (Tight Coupling to Details)
class MySQLDatabase {
save(data) {
console.log(`Saving data to MySQL: ${JSON.stringify(data)}`);
}
}
class UserService {
constructor() {
this.database = new MySQLDatabase(); // Direct dependency on a concrete low-level module
}
createUser(user) {
// Some business logic
this.database.save(user);
}
}
The UserService (high-level module) directly depends on MySQLDatabase (low-level module). If you want to switch to a different database (e.g., MongoDB or a mock database for testing), you have to modify UserService.
Example: Adherence (Depend on Abstraction)
// Abstraction (implicit interface/contract): any object with a 'save' method
class MySQLDatabase {
save(data) {
console.log(`Saving data to MySQL: ${JSON.stringify(data)}`);
}
}
class MongoDBDatabase {
save(data) {
console.log(`Saving data to MongoDB: ${JSON.stringify(data)}`);
}
}
class UserService {
constructor(database) { // Dependency Inversion via constructor injection
this.database = database; // Depends on an abstraction (anything with a .save method)
}
createUser(user) {
// Some business logic
this.database.save(user);
}
}
// Usage:
// const mySQLDb = new MySQLDatabase();
// const userServiceWithMySQL = new UserService(mySQLDb);
// userServiceWithMySQL.createUser({ id: 1, name: 'Alice' });
// const mongoDb = new MongoDBDatabase();
// const userServiceWithMongoDB = new UserService(mongoDb);
// userServiceWithMongoDB.createUser({ id: 2, name: 'Bob' });
Now, UserService depends on an abstraction (any object that provides a save method), not a concrete database implementation. The specific database implementation is injected into the UserService. This allows easy switching of database technologies or providing mock implementations for testing without modifying the core UserService logic.