SOLID Principles: The Ingredients for a Five-Star Software Application
You’ve likely heard the term SOLID thrown around in tutorials, courses or interviews. You may already be employing some of these principles as they can occur naturally if you have a feel for clean, modular code. But I’m hoping to flesh it out a bit more here and make it more intuitive and straightforward to understand. I hate jargon and will go out of my way to avoid it.
SOLID Principles Explained
SOLID is an acronym that represents a set of five best practices for designing and organizing code, allowing it to be more maintainable, scalable, and robust.
S — Single Responsibility Principle (SRP)
- Meaning: A class, component, or service should have only one reason to change. Sounds vague? Think of your classes like a specialist worker who’s damn good at one specific job. Imagine telling your kid he’s got to sweep the floor for a week or you won’t buy him the Millennium Falcon LEGO set he’s wanted. While you may give him different rooms to sweep, you’ll never ask him to mop as part of your contract.
- Why it Matters: By keeping things focused, you make the code easier to understand, test, and modify.
O — Open/Closed Principle (OCP)
- Meaning: Software entities should be open for extension but closed for modification. After you’ve bought your son the LEGO set for a week of successful sweeping and the soles of your feet resemble pummeled pork loin from stepping on errant pieces, he wants you to buy an add-on to the craft in the form of a sidecar for Yoda. If it adheres to OCP (yeah, you know me), the extension can be built on top of the existing structure without you having to dig into its innermost recesses to dis- and reassemble.
- Why it Matters: You can add new features without altering existing code, reducing the chance of introducing new bugs. Ask me how many times I’ve violated this one. Or don’t. I’m too fragile.
L — Liskov Substitution Principle (LSP)
- Meaning: If a class is a subtype of another, you should be able to use it interchangeably without your program having a meltdown. “Subtypes” might be new to you, so I’ll explain: a subtype is a class inheriting properties from another class. A “Vehicle” can be a general class, and “Car,” “Motorcycle,” and “Truck” can be subtypes of “Vehicle.”
- Why it Matters: This ensures a derived class doesn’t bruk up the functionality of the base class, making the code more predictable and reliable.
I — Interface Segregation Principle (ISP)
- Meaning: Don’t force a class to implement rubbish it doesn’t need. Or, in other words, it stops you from falling into the lazy-ass trap of “It’s easier to implement a big interface than create a bunch of small ones.” Don’t have hamburger ingredients if you’re a pizza-exclusive joint.
- Why it Matters: It ensures that a class only has to deal with what’s relevant, making the code cleaner and more efficient.
D — Dependency Inversion Principle (DIP)
- Meaning: High-level modules shouldn’t depend on low-level modules; both should depend on abstractions. This principle made no bombaclaat sense to me for years. Right up to the point when I was trying to buy a need iPad at the Apple Store and realized Apple is intentionally and greedily violating the DIP. Its entire business model violates this principle, without any damn consequence. Does this product need the 30-pin connector? Lightning connector? MagSafe? Whatever gets the most money out of you, my friend! We’ll discuss later how to avoid this in your code. But right now, I need to take a breather to calm down.
- Why it Matters: By relying on abstractions, you make the components more interchangeable, allowing for more flexibility and easier maintenance.
Let’s take a look at these in more detail. If you haven’t noticed, you can see that I’m on a restaurant tip. I should stop writing when I’m hungry. We’re running Gordon Ramsay’s new restaurant and bar, the Infinite Loop Lounge. Let’s explore SOLID principles in the context of our wonderful new restaurant.
Let’s take a closer look. I’m clearly on a restaurant kick and I intend on following this kick all the day down to the point the metaphor collapses under its own weight. I should stop writing when I’m hungry. Welcome to Gordon Ramsay’s new restaurant, the Infinite Loop Lounge.
Single Responsibility Principle (SRP)
When we talk about the Single Responsibility Principle, we’re saying that a class should have only one reason to change. Confused? Let me break it down: this principle is all about making sure that each class only has one job. Like a talented sommelier specializing in wine, each class must do what it does best.
OK, Mr. Manager. Your first real issue is here .You hired your best friend from high school, Dexter. And Dexter is a catastrophe. Instead of attentively serving his tables, he’s bouncing behind the line, working the fryer, and even diving into the dish pit. Your boy Dexter is a mess.
Look at this class:
class Server {
waitTables() {
console.log(`Waiting tables`);
// Code here
}
washDishes() {
console.log(`Washing dishes`);
// Code here
}
prepAppies() {
console.log(`Cooking some appetizers`);
// Code here
}
}
The Server class is violating the SRP, and in spectacular fashion, too. A server prepping appetizers? Slinking up to tables with his fingers pruned from washing pots and pans, neck burned from splashing grease asking the table of 12 if they would like dessert. A server should do what servers do best: serve tables.
But fear not, after a quick talk with the fool, you’ve got this under control:
class Server {
waitTables() {
console.log(`Waiting tables`);
// Code here
}
}
class Dishwasher {
washDishes() {
console.log(`Washing dishes`);
// Code here
}
}
class PrepCook {
prepAppies() {
console.log(`Cooking some appetizers`);
// Code here
}
}
Looking better. We’ve cleaned up the chaos and turned it into three distinct classes, each handling its specific role in the restaurant. Dexter can now focus on being the best waiter, leaving the washing and cooking to those who specialize in those tasks. But if he ever needs to slide into the dishpit, he can implement the Dishwasher class too.
Open/Closed Principle (OCP)
This principle holds that a class should be like a tantalizing secret recipe — open for extension, but strictly closed for modification. It means your ex-girlfriend’s recipe for jerk braised short ribs on curry mashed potatoes that she inexplicably refuses to make for you after you cheated on her, can have some new ingredients added to it, but you can’t remove an element. You might be able to add a dash of cayenne to the short ribs for a kick but you can’t switch out the potatoes for quinoa.
Anyway, word has reached Gordon Ramsay that you’ve temporarily retired your POS system. He crashes through the door and kicks the door down to your office. “You donkey!” he bellows. “Where’s the Point Of Sale (POS) system?”
It’s been acting up like an overcooked soufflé, so you ditched it. Old school’s the way, right? Let’s examine that rebellious system:
class Entree {
price: number;
constructor(price: number) {
this.price = price;
}
}
class PointOfSaleSystem {
calculateTotal(entree: Entree) {
return entree.price;
}
}
It looks good. It will handle paying for an entree. But wait! Your beautiful artistry managing this place has convinced Gordon to shell out for a liquor license and import a world-class pastry chef from Cologne. Time to upscale the menu:
class Entree {
price: number;
constructor(price: number) {
this.price = price;
}
}
class Dessert {
price: number;
constructor(price: number) {
this.price = price;
}
}
class Beverage {
price: number;
constructor(price: number) {
this.price = price;
}
}
// Adding these to the POS
class PointOfSaleSystem {
calculateTotal(entree: Entree, dessert?: Dessert, beverage?: Beverage) {
let total = entree.price;
if (dessert) total += dessert.price;
if (beverage) total += beverage.price;
return total;
}
}
Can you spot the error? The sin against culinary code? Every new dish means tampering with the existing POS class. No bueno, my friend. You’ve violated the Open Closed Principle.
So let’s roll up our sleeves and fix this mess:
interface MenuItem {
price: number;
}
class Entree implements MenuItem {
price: number;
constructor(price: number) {
this.price = price;
}
}
class Dessert implements MenuItem {
price: number;
constructor(price: number) {
this.price = price;
}
}
class Beverage implements MenuItem {
price: number;
constructor(price: number) {
this.price = price;
}
}
class PointOfSaleSystem {
calculateTotal(items: MenuItem[]) {
return items.reduce((total, item) => total + item.price, 0);
}
}
const entree = new Entree(15);
const dessert = new Dessert(5);
const beverage = new Beverage(3);
const pointOfSaleSystem = new PointOfSaleSystem();
const total = pointOfSaleSystem.calculateTotal([entree, dessert, beverage]);
console.log(`Total cost: $${total}`); // Total cost: $23
Now, the POS system comes blinking back to life, drawing a grim smile from Chef Ramsay. His lips a gleaming scimitar on a craggy rockface.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. Keeping with your bustling kitchen, where the CookingAppliance
class reigns supreme:
export class CookingAppliance {
constructor(public temperature: number) {}
startCooking(): void {
// Fire up the burners
}
stopCooking(): void {
// Extinguish the flames
}
}
The CookingAppliance class should encompass every device in the kitchen responsible for cooking food. But every master chef needs a trusty stove. Enter the Stove
class, extending the tried-and-true CookingAppliance
, with the ability to sizzle, fry, and boil:
export class Stove extends CookingAppliance {
constructor() {
super(8);
}
fry() {
// Crisping to perfection
}
boil() {
// A rolling boil awaits
}
}
What does this mean in the kitchen? It means that where a CookingAppliance
can whip up a meal, the Stove
can slide right in without missing a beat:
function testCookingAppliance(cookingAppliance: CookingAppliance): void {
cookingAppliance.startCooking();
cookingAppliance.stopCooking();
}
const stove = new Stove();
testCookingAppliance(stove); // The stove takes over, flawlessly
Just like a sous-chef taking over from the head chef, the Stove
fits right into the role of a CookingAppliance
. It doesn't just extend the class; it honors its legacy.
That’s the elegance of the Liskov Substitution Principle. Whether it’s a cooking appliance, a stove, or the next futuristic kitchen gadget, the promise remains the same: seamless substitution, perfect dishes, every time.
Interface Segregation Principle (ISP)
The Interface Segregation Principle states that clients should not be forced to depend on methods that they do not use. In other words, a class should only implement an interface with properties and methods that are relevant to its clients. Let’s take a look at your restaurant's PaymentService
.
export interface Payment {
paymentType: 'Cash' | 'Credit' | 'Debit';
usageFee?: number;
processPayment(amount: number): void;
authorizePin?(pin: string): boolean; // Optional for debit
giveChange?(amount: number): number; // Optional for cash
}
class CashPaymentService implements Payment {
paymentType = 'Cash';
processPayment(amount: number) { /* Code for cash payment */ }
giveChange(amount: number): number { /* Code for change */ return amount; }
}
class CreditPaymentService implements Payment {
paymentType = 'Credit';
usageFee = 0.02;
processPayment(amount: number) { /* Code for credit payment */ }
}
class DebitPaymentService implements Payment {
paymentType = 'Debit';
processPayment(amount: number) { /* Code for debit payment */ }
authorizePin(pin: string): boolean { /* Code to authorize pin */ return true; }
}
In the above example, the Payment interface has several properties that don’t apply to all paymentTypes. For instance, the CashPaymentService is implementing a usageFee and authorizePin property. Why? It doesn’t ever use them so it should never have to implement them.
Let’s take a look at this example while obeying ISP.
// General payment interface
export interface Payment {
processPayment(amount: number): void;
}
// Specific interfaces for each payment type
export interface CashPayment extends Payment {
giveChange(amount: number): number;
}
export interface CreditPayment extends Payment {
usageFee?: number;
}
export interface DebitPayment extends Payment {
authorizePin(pin: string): boolean;
}
// Implementation for each payment type
class CashPaymentService implements CashPayment {
processPayment(amount: number) { /* Code for cash payment */ }
giveChange(amount: number): number { /* Code for change */ return amount; }
}
class CreditPaymentService implements CreditPayment {
usageFee = 0.02;
processPayment(amount: number) { /* Code for credit payment */ }
}
class DebitPaymentService implements DebitPayment {
processPayment(amount: number) { /* Code for debit payment */ }
authorizePin(pin: string): boolean { /* Code to authorize pin */ return true; }
}
This example adheres to ISP by separating the interfaces and only including the relevant methods and properties for each payment type, preventing any client from depending on methods it doesn’t need.
Dependency Inversion Principle (DIP)
I went overboard explaining this earlier in the article so let’s just jump right into our example. Picture this: Your waiter has just taken the customer’s payment, and now the race begins to process that order and see the money land in your bank account.
class CashPayment {
processCashPayment(amount: number) { /* Code for cash payment */ }
}
class CreditPayment {
processCreditPayment(amount: number) { /* Code for credit payment */ }
}
class OrderManagementSystem {
processOrder(amount: number, paymentMethod: 'Cash' | 'Credit') {
if (paymentMethod === 'Cash') {
const cashPayment = new CashPayment();
cashPayment.processCashPayment(amount);
} else {
const creditPayment = new CreditPayment();
creditPayment.processCreditPayment(amount);
}
}
}
In the above example, the OrderManagementSystem is dependent on the payment method, which is a lower level module than the OrderManagementSystem. The system has to concern itself with what kind of payment is being passed and based on that, funnel the payment to the proper processing method. This is fine as long as your application stays small. But as you add more payment options, it could result in the Processor losing its mind trying to keep up with every little thing we might pass it. In a perfect program, it won’t care what’s being passed. Its job is to process the payment, whatever that payment might be.
Here’s a way of doing implementing a payment system that doesn’t force the Order Processor to be dependent on its inputs.
// Payment interface
export interface Payment {
processPayment(amount: number): void;
}
// Concrete implementations
class CashPayment implements Payment {
processPayment(amount: number) { /* Code for cash payment */ }
}
class CreditPayment implements Payment {
processPayment(amount: number) { /* Code for credit payment */ }
}
class OrderManagementSystem {
processOrder(amount: number, paymentMethod: Payment) {
paymentMethod.processPayment(amount);
}
}
Now, the OrderManagementSystem doesn’t care if you’re paying with cash, credit, or a Bored Ape photo. Its mission is crisp and clear: process the payment. The details? You’d better holla at them other classes because the OrderManagementSystem is doing what it do and it ain’t doing no more.
Now that we’ve reached the end of this, let’s spill some programmer’s tea. Following all the SOLID principles isn’t mandatory. However, following some of them is mandatory for elegant systems. Each principle is just coaching you to keep your classes lean, mean, and agile. Now, here comes the heresy: I’d venture to say that just adhering to one or two of these principles will likely make you stumble into implementing the rest — without you even know you’re doing it. Or close enough that you don’t need to get into the nitty-gritty of all five principles. Sure, you can master them if you’re planning a lifelong affair with coding. But if not, don’t sweat it. You’ll still whip up some damn good code. Eventually, it’ll come as naturally as a perfectly seared steak (not naturally at all, my steaks end up scorched). Bon appétit, programmer! May your code always be as delightful as a grilled cheese cooked on a radiator.