If you've ever found yourself deep in the trenches of a startup, you know that understanding and applying SOLID principles in object-oriented programming is crucial. These principles make your software more understandable, flexible, and maintainableβsomething we desperately needed when we started scaling our little project.
Let's dive into each principle with a story from my startup days, where JavaScript was our go-to language, and we were constantly thinking about design, databases, and schema.
π Single Responsibility Principle (SRP)
Definition: A class should have only one reason to change, meaning it should have only one job or responsibility.
Story Time:
Back in the day, we had a massive User class that did everything from managing user data to sending emails. It was a nightmare. Here's how we refactored it:
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
class UserService {
createUser(user) {
// logic to create user
}
getUser(id) {
// logic to get user
}
}
class UserNotificationService {
sendWelcomeEmail(user) {
// logic to send email
}
}
const user = new User('John Doe', 'john.doe@example.com');
const userService = new UserService();
userService.createUser(user);
const notificationService = new UserNotificationService();
notificationService.sendWelcomeEmail(user);
By breaking the responsibilities into separate classes, each one had a clear, single purpose. This made our codebase cleaner and more manageable.
π Open/Closed Principle (OCP)
Definition: Software entities should be open for extension but closed for modification.
Story Time:
We were adding new shapes to our graphic design tool and wanted to do it without touching the existing code. Here's how we handled it:
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * Math.pow(this.radius, 2);
}
}
const shapes = [new Rectangle(4, 5), new Circle(3)];
const totalArea = shapes.reduce((sum, shape) => sum + shape.area(), 0);
console.log(totalArea);
By designing our shape classes this way, we could add new shapes without modifying the existing ones. It was a real game-changer for our design team.
π Liskov Substitution Principle (LSP)
Definition: Subtypes must be substitutable for their base types without altering the correctness of the program.
Story Time:
We learned this the hard way. We had a Bird class and subclasses like Duck and Ostrich. One day, everything broke because Ostrich didn't fly like the others.
class Bird {
fly() {
console.log('I can fly');
}
}
class Duck extends Bird {}
class Ostrich extends Bird {
fly() {
throw new Error('I cannot fly');
}
}
function makeBirdFly(bird) {
bird.fly();
}
const duck = new Duck();
makeBirdFly(duck); // Works fine
const ostrich = new Ostrich();
makeBirdFly(ostrich); // Throws error
We had to rethink our design to ensure that all subclasses honored the base class's expectations. It was a valuable lesson in maintaining consistency.
π Interface Segregation Principle (ISP)
Definition: Clients should not be forced to depend on interfaces they do not use.
Story Time:
We had an all-in-one Printer class that did everything. It was overkill for simple tasks, so we broke it down:
class Printer {
print() {
console.log('Printing document');
}
}
class Scanner {
scan() {
console.log('Scanning document');
}
}
class MultiFunctionPrinter {
print() {
console.log('Printing document');
}
scan() {
console.log('Scanning document');
}
}
const printer = new Printer();
printer.print();
const scanner = new Scanner();
scanner.scan();
const multiFunctionPrinter = new MultiFunctionPrinter();
multiFunctionPrinter.print();
multiFunctionPrinter.scan();
This approach allowed us to use only what we needed without being bogged down by unnecessary methods.
π Dependency Inversion Principle (DIP)
Definition: 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.
Story Time:
We built a notification system that could switch between email and SMS without changing the core logic:
class NotificationService {
constructor(sender) {
this.sender = sender;
}
sendNotification(message) {
this.sender.send(message);
}
}
class EmailSender {
send(message) {
console.log(`Sending email: ${message}`);
}
}
class SMSSender {
send(message) {
console.log(`Sending SMS: ${message}`);
}
}
const emailSender = new EmailSender();
const notificationService = new NotificationService(emailSender);
notificationService.sendNotification('Hello via Email');
const smsSender = new SMSSender();
const notificationServiceWithSMS = new NotificationService(smsSender);
notificationServiceWithSMS.sendNotification('Hello via SMS');
This design allowed us to add new notification methods easily. It was a great way to keep our high-level logic clean and adaptable.
By weaving these SOLID principles into our development process, we managed to create a more robust, maintainable, and scalable codebase. It's something every startup should aim for to ensure their software can grow with their ambitions.
For more insights on database design, schema creation, and how these principles can be applied, check out dynobird.com. Dynobird offers online database design tools that can make your database design process smoother and more efficient.
Happy coding! π₯
Top comments (0)