Mastering SOLID: A Mnemonic Approach to Object-Oriented Principles



This content originally appeared on DEV Community and was authored by NomadCode

In the early days of my development career, SOLID principles would crop up in conversations with developers of all backgrounds, often referenced as the cornerstones of OOP the acronym took on almost mythical status to me as a younger developer. I assumed all senior developers were wizards who knew the magic words without effort, and like a dutiful apprentice I would read the manuscripts of my forebears and aspire to be accepted into the halls of the greats.

Experience has taught me that though references to SOLID principles would often be bandied around frequently, in reality, code bases would often fall foul of multiple key principles. I’m sure the majority of developers reading this have been tasked with refactoring spaghetti code more than once.

There are valid scenarios where SOLID principles simply do not or cannot apply. Perhaps a developer is constrained from pursuing them, they may be working with a codebase they did not architect and do not have permission to sufficiently refactor. Or perhaps they are coding in the functional style by necessity or design. It’s key to understand these are Object Oriented Principles at their core.

Most commonly used modern languages are multi-paradigm by design. Often I will naturally mix object oriented and functional paradigms depending on the stack I am working with, or to suit the particular task at hand. Libraries or frameworks can steer you towards a certain style; in modern React, for example, most components are just functions and hooks encourage composition over inheritance. Commonly used React frameworks such as NextJS lean into this style. React also of course supports class components and inheritance, the choice of preferred style then is left down to the developer.

Conversely, Angular naturally supports a more opinionated, object oriented approach through its use of classes and dependency injection, though its templating system leans towards the declarative and functional. Backend frameworks such as NestJS or Laravel also lean towards OOP for the most part and there are many examples in between.

It should be clear then that there is no right or wrong way to write software and we often mix and match approaches. However, I would say there is such a thing as good or bad code, regardless of the paradigm. I would quantify good code then as that which is easy to understand, easy to maintain, and easy to scale or extend.

SOLID principles help us achieve that and I believe they are worth understanding regardless of your preferred coding style. While most of them do not directly translate to a more functional style in the literal sense, I believe it is possible to abstract the spirit of some of these techniques into most types of programming.

The purpose of this article then is to remove the veil and de-mystify these concepts for those who still have difficulty seeing through the fog of words or perhaps need a refresher.

We will endeavour to embed the meaning of “The Liskov Substitution Principle” into our minds in such a way that it instantly makes sense without first mistaking it for a fundamental law of physics.

With that preface out of the way let’s get into the first letter in the acronym.

S – Single Responsibility Principle (SRP)

A class should be responsible to only one “actor” or user group, meaning there should only be one reason for that class to change

In my mind the easiest to understand and remember. Often SRP is confused with “only has one job”, and while that is often true and not a bad rule of thumb, the real intention of the principle is that a class (or method) should only be changed by one type of actor. In addition my personal interpretation of this principle is also includes that a class or method should also only be responsible for a single type of domain data, wherever possible.

Let’s say for example you have the class BusinessManager which runs some common corporate logic:

//business manager has too many responsibilities
class BusinessManager {
  processEmployeePayrolls(): void {
    console.log("Processing employee payrolls...");
  }

  generateCustomerInvoices(): void {
    console.log("Generating customer sales invoices...");
  }
}

This class is responsible for two distinct concerns: employee payrolls and customer invoicing. It could be affected by changes requested by two separate system actors (HR and Finance teams), or by the activity of two different data domains (employees/customers).

It would be beneficial to separate this logic into their own specific classes, giving them clear responsibility over their domains:

//employee manager handles payroll
class EmployeeManager {
  processPayrolls(): void {
    console.log("Processing employee payrolls...");
  }
}

//customer manager handles invoice generation
class CustomerManager {
  generateInvoices(): void {
    console.log("Generating customer sales invoices...");
  }
}

In reality it is far more likely you would have a payroll service and an invoice service, which would accept an employee class or a customer class appropriately, however for the sake of explaining our principle we’ll keep it simple.

Why is this important?

As we briefly mentioned already these principles are designed to make code more maintainable. That means, reducing friction during change. This applies whether you are a solo developer or working as part of a team.

In our first example if the HR department and the Finance department both request changes to their logic and developers were assigned to each task, the developers are going to have to reconcile merge conflicts when they submit their work, leading to wasted time and increased error rates.

Even when working alone touching a file that has fingers in too many pies is a recipe for a dog’s dinner, but more on that in a moment. As these classes become more grotesque the chances of you accidentally breaking one part of the system while modifying another increases exponentially.

How do I remember? – Don’t stress the dog

Derren Brown famously beat 9 chess masters while himself confessing to be a complete novice. While a Grandmaster in his own field and a truly impressive feat, what he did provides insight into the world of mnemonic devices. Derren “simply” memorised the moves of the chess players and played them back to his opponents. In effect the masters were playing each other, not Derren.

The man himself talks about one of the simpler mnemonic devices he uses in this short clip: “The Method of Loci”. As he says in the video, the trick is to combine something you want to remember, with something your brain already knows (paraphrased). He talks of using a journey, a walk, a drive, with some key points along the way and “assigning” (visually imprinting) the item you want to remember onto that location in your mind. The more bizarre the image you create the better. We actually did this technique in a psychology class in sixth form college and the results were staggering, everyone had total recall.

By all means go ahead and create a Loci journey of your own for these principles. However I find it more beneficial to abstract the basis of the technique, (combining two pieces of information and then connecting them to form a new memory), then using that to imprint our learning. The idea is to create a metaphorical image, the concept is virtually identical minus the journey.

For example, when I say to myself “Single Responsibility Principle”, what is the first thing that comes to mind? My dog, Bow. I am not married and have no children, he is my “single responsibility” (if only!). The logical correctness of the connection is irrelevant, clearly I have more responsibilities than my dog, what is important however is that the first thing I thought of when I read those words was, Bow.

So in a similar style to Derren, I place an “S” on the back of my dog in my mind’s eye. I then envisage Bow wearing a shirt, working at a white collar company stressed with paperwork to process from multiple departments. HR wants something. Finance wants something. Where are the dog treats!? I remember that this poor pooch has too much to do and too many people vying for his attention. We must abstract, we must hire more Beagles (god forbid). So he can have just one, single responsibility.

SRP is about respecting boundaries. Code is easier to understand and modify when each part has only one job and one master. – Don’t stress the dog.

O – Open/Closed Principle (OCP)

Classes should be open for extension, but closed for modification.

In the spirit of reducing friction during change we want to add new functionality by extending current code rather than modifying it. We want to architect our code in a “plug and play” manner. Have you ever seen code with many if/else blocks or perhaps a switch statement with many cases? These are often good candidates for refactor towards an OCP compliant approach.

Let’s look at a simple example to make this clearer:


//invoice generator, requires direct modification for extension
class InvoiceGenerator {
    generate(invoice: string, format: string): void {
        if (format === "PDF") {
            console.log("Generating PDF: " + invoice);
        } else if (format === "MD") {
            console.log("Generating Markdown: #" + invoice);
        }
    }
}

When you look at the code above, ask yourself, do I need to modify this class to add a new generator format, or update how a format is generated? Clearly the answer is yes. You would be adding more limbs to this many-armed if/else monster and finding it returns late at night to terrorise your dreams.

You could argue this code also violates the single responsibility principle; while there may not be multiple system actors initiating change for invoices, there are still multiple reasons for this code to change. It is handling too many formats inside a single class.

What we need here is a modular approach where the InvoiceGenerator doesn’t care what format it’s being given, as long as it can be sure there is a format. This of course is where OOP shines.

Let’s abstract our formats into their own classes and define an interface that ensures each formatter provides its own format() method:

//common interface for formatters
interface InvoiceFormatter {
  format(invoice: string): string;
}

//pdf formatter
class PDFFormatter implements InvoiceFormatter {
  format(invoice: string): string {
    return `PDF: ${invoice}`;
  }
}

//markdown formatter
class MarkdownFormatter implements InvoiceFormatter {
  format(invoice: string): string {
    return `# ${invoice}`;
  }
}

//invoice generator can now be extended without being modified
class InvoiceGenerator {
  constructor(private formatter: InvoiceFormatter) {}

  generate(invoice: string): void {
    console.log(this.formatter.format(invoice));
  }
}

By doing this we can now add new formats to the system without touching our InvoiceGenerator. We have made it open for extension, but closed for modification.

Why is this important?

The less code you have to change the less opportunity to introduce errors or unintentional side-effects. Commits for future formats will be in their own isolated classes, allowing for easier testing, increased comprehension from other team members and more confidence in the code.

While our example here may be very simple, you can easily imagine how adhering to this principle can pay dividends as the scale of a codebase increases.

How do I remember? – It’s cold outside the van

Continuing our approach of mentally imprinting to something we are familiar with. I sit back, and say to myself “Open / Closed”, noting the first thing that comes to mind. I like to call this technique “first come first serve imprinting”; harnessing the natural mental connection between a phrase and your first reaction to it, and then hi-jacking that to imprint a new concept onto an already strong neural link.

I often work from my campervan, for me the first thing that came to mind was the constant opening and closing of van doors to regulate the internal temperature, it’s often cold outside. My task then is to create a metaphor for extension / modification involving my campervan and it’s temperature.

Luckily this is fairly straightforward, when building my camper ensuring that I had the correct access to extend the vans functionality with new appliances, wiring, heating upgrades (etc…) without having to tear apart and re-build internal structure of the van was of serious concern.

In effect, I built interfaces into the vehicle. Common sockets for new appliances, common access points with connectors for heating and wiring upgrades, allowing the underlying structures to remain un-modified.

It’s not always going to be the case that the first thought that comes to mind is going to provide you with an easy metaphor for a concept, you may have to keep searching to the second, third or fourth thought that you can mould to your mental narrative. That’s ok, the very process of finding the right metaphor is going to help embed those connections.

L – The Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without altering the correctness of the program

The name of the principle pays homage to it’s creator Barbara Liskov, a pioneering mind in the emerging days of computer science. Her work built upon foundations laid by Ole-Johan Dahl and Kristen Nygaard, credited with creating the first true OOP language, Simula in the 60’s. The phrase Object Oriented Programming was then coined by Alan Kay in the early 70’s and was later popularised by Smalltalk.

Shortly after, Barbara pioneered work on abstract data types, iterators, parameterised types and other fundamentals with CLU, a programming language she created with her students at MIT in 1973. These features became templates for programming powerhouses we see today in Java and C++, and of course are commonly found in most programming languages used to this day.

Defined by such an influential figure it should be no surprise that The Liskov Substitution Principle strikes at the heart of some of OOP’s most powerful features, Inheritance, Interfaces, and Polymorphism.

For the benefit of junior readers I’ll quickly explain that inheritance is when a subclass derives from a base class; automatically receiving its accessible fields and methods while being free to add or override behaviour.

An interface defines a contract, a set of methods and sometimes properties that any class which implements the interface promises to supply.

Polymorphism translates to “many forms”, it allows instances of different concrete classes to be treated equally through their common super-type, be that via an interface or base class.

With LSP the key question to ask is can you replace the super-type with the sub-type, without breaking the system or experiencing unintentional side-effects?

If you can’t then you’re violating LSP. Lets dive into some code to make this clearer:

//common file storage interface
interface IFileStorage {
  save(path: string, data: string): void;
  read(path: string): string;
}

//generic file storage class implements common interface
class FileStorage implements IFileStorage {
  save(path: string, data: string): void {
    console.log(`Saved to path: ${path}`);
  }

  read(path: string): string {
    return `Reading from path: ${path}`;
  }
}

//local file storage class extends parent file storage and adds functionality
class LocalStorage extends FileStorage { 
  clearCache(cachePath = "/var/cache"): void {
    console.log(`Clearing cache at ${cachePath}`);
  }
}

//read only storage class extends common interface, but throws exception on save
class ReadOnlyStorage extends FileStorage {
  save(_path: string, _data: string): void {
    throw new Error("Cannot save: storage is read-only");
  }
}

//persistance function accepts common interface
function persistUserData(storage: IFileStorage, userData: string) {
  storage.save("/users/user.json", userData);
}

//usage
persistUserData(new LocalStorage(),   "{ name: 'Joe' }"); // no problem
persistUserData(new ReadOnlyStorage(),"{ name: 'Bow'   }"); // runtime error

The above example has an initial FileStorage class which implements the IFileStorage interface*,* two methods are defined, read() and save().

We later decide a LocalStorage class would be a useful addition for handling data with specific cache considerations, no issues here.

Our imaginary application grows when a new permissions system is added to accommodate some heavily restricted users, we decide that these users should only instantiate a ReadOnlyStorage class. We continue with our pattern of extending FileStorage, overwriting the save method to throw an exception where that logic should not apply.

*At first glance this might seem acceptable but closer inspection reveals that our *persistUserData() method is typed to accept any class that implements IFileStorage as it’s first parameter. **There is nothing to prevent the exception being thrown at run time and no compile time error to warn us.

A developer may be tempted to write further code inside persistUserData() to perform a type check on the storage parameter to prevent the exception:

function persistUserData(storage: IFileStorage, userData: string) {
  if (storage instanceof ReadOnlyStorage) { //we are now adding edge case checks
    console.log("Skipping save for read-only storage.");
    return;
  }

  storage.save("/users/user.json", userData);
}

This further breaks LSP and introduces tight coupling between the persistUserData() method and the ReadOnlyStorage concrete class, which should feel as incorrect as it sounds. Things are starting to taste like spaghetti.

So then what would be a more LSP compliant and generally SOLID approach? Conforming to LSP is often about understanding when extension is really the best way to go or if we should use other techniques at our disposal:

//lean interface handles only reading
interface IReadableStorage {
  read(path: string): string;
}

//lean interface handles only writing
interface IWriteableStorage {
  save(path: string, data: string): void;
}

//common file storage class implements both interfaces it requires
class FileStorage implements IReadableStorage, IWriteableStorage {
  save(path: string, data: string): void {
    console.log(`Saved to path: ${path}`);
  }

  read(path: string): string {
    return `Reading from path: ${path}`;
  }
}

//local storage class extends common file storage class and adds functionality as before
class LocalStorage extends FileStorage {
  clearCache(cachePath = "/var/cache"): void {
    console.log(`Clearing cache at ${cachePath}`);
  }
}

//read only storage class implements just the interface it needs
class ReadOnlyStorage implements IReadableStorage {
  read(path: string): string {
    return `Reading from read-only path: ${path}`;
  }
}

//persistance function accepts only writeable types
function persistUserData(store: IWriteableStorage, json: string) {
  store.save("/users/user.json", json);
}

//usage
persistUserData(new LocalStorage(), "{ name: 'Joe' }"); //no problem
persistUserData(new ReadOnlyStorage(),"{ name: 'Bow'   }"); //compile time error

Here we have defined two distinct interfaces fit for purpose: IWriteableStorage and IReadableStorage. Our FileStorage class implements both these contracts and the LocalStorage class continues to extend FileStorage, adding its caching functionality as before. LocalStorage still adheres to LSP; it could be used anywhere in place of it’s parent FileStorage without undesirable consequences.

However, the ReadOnlyStorage class is no longer derived from FileStorage, it’s now a specialist class fit for it’s specific purpose implementing only the IReadableStorage interface.

The persistUserData() method is now correctly typed to only accept a class which implements IWriteableStorage, presenting us with a compile time error and some angry red lines in our IDE if we provide it with the wrong type.

We have conformed to LSP here by segregating our interfaces which will be the topic of our next principle, it should be becoming clearer that all of the solid principles compliment each other and the solution to one is often to ensure adherence to the others.

Why is this important?

Polymorphism is one of the most powerful promises of object oriented programming. We want to be able to write generic, re-useable code as much as possible. We want to be able to add functionality without breaking current logic. LSP ensures this is possible by maintaining behavioural compatibility between sub-type and super-type and forcing us to truly consider if extension is the best tool for the job.

It helps eliminate the need for edge-case checks into our codebase, methods can remain focused on their own concerns without accounting for unexpected behaviour from derived types.

LSP ensures your abstractions stay trustworthy, when a method says that it accepts any implementation of a type, it really should be able to. Violating LSP can lead to bloated if/else blocks and fragile logic that quickly leads to maintenance hell.

How do I remember? – The substitute teacher

For me the only real word in this principle I can leverage for a first come first serve technique is substitute. Initially I thought of football and player substitutions, but I decided my second impulse to think of an old substitute teacher would better serve this metaphor. In no small part due to the fact teachers instruct classes. My reluctance to recall the educational train wrecks of my younger years will have to step aside in the name of learning later in life, the irony.

Mrs Derby was my year 9 substitute teacher for history class, standing in for Mr Smith while he was sick. Though these events occurred over 25 years ago I remember vividly that while I was truly fond of Mrs Derby, she lacked the teaching prowess and pupil control of her senior and those substitute classes often devolved into adolescent anarchy.

If we consider that Mr Smith was of type Teacher, and Mrs Derby was of sub-type SubstituteTeacher. They shared many common properties and abilities, however, in this hierarchy LSP has been broken. It is clear that Mrs Derby’s implementation of teachHistory() and controlChildren() were throwing unexpected errors and unintentional behaviour. It was not possible to substitute Mrs Derby for Mr Smith. The Liskov Substitution Principle was broken. (So too was 3 months of history class).

I – Interface Segregation Principle (ISP)

Clients should not be forced to depend on methods they do not use

This is an extremely simple principle on the face of it, in short it’s saying, “keep interfaces lean”. In other words make sure the interface is not too generic and that its methods are relevant to all implementing classes. We’ve already seen examples of how failing to follow this principle can lead to falling foul of LSP, SRP and so on. We’ll go over a brief example but we shan’t labour the point in this section:

//fat interface handles logic for multiple concerns
interface IPaymentGateway {
  processPayment(amount: number): void;
  processRefund(transactionId: string): void;
  generateRecurringSubscription(details: object): void;
  logCashPaymentTillId(tillId : number, transactionId : number) : void
}

//stripe gateway has to throw exception for logging till ID
class StripePaymentGateway implements IPaymentGateway {
  processPayment(amount: number): void {
    console.log(`Debit: Collected payment of $${amount}`);
  }

  processRefund(transactionId: string): void {
    console.log(`Credit: Refunded payment for ${transactionId}`);
  }

  generateRecurringSubscription(details: object): void {
    console.log(`Stripe: Generated subscription with details ${JSON.stringify(details)}`);
  }

   logCashPaymentTillId(tillId : number, transactionId : number) : void {
      throw new Error("Cash Till Logging not supported for Stripe payments");
  }
 }

//cash gateway has to throw exceptions for refunds and subscriptions
class CashPaymentGateway implements IPaymentGateway {
  processPayment(amount: number): void {
    console.log(`Cash: Collected payment of $${amount}`);
  }

  processRefund(transactionId: string): void {
    throw new Error("Refunds not supported for cash payments");
  }

  generateRecurringSubscription(details: object): void {
    throw new Error("Recurring subscriptions not supported for cash payments");
  }

  logCashPaymentTillId(tillId : number, transactionId : number) : void {
      console.log(`Logging cash with transactionId ${transactionId} to tillID ${tillId}`);
  }
}

Here we have a classic example of a fat interface, both CashPaymentGateway and StripePaymentGateway are forced to implement methods they don’t require. generateRecurringSubscription() and ProcessRefund() make no sense for a cash gateway and logCashPaymentTillId() makes no sense for a Stripe transaction.

It’s time we segregated those interfaces.

//lean interface for processing payments
interface IPaymentProcessor {
  processPayment(amount: number): void;
}

//lean interface for refunds
interface IRefundProcessor {
  processRefund(transactionId: string): void;
}

//lean interface for subscriptions
interface IRecurringSubscriptionProcessor {
  generateRecurringSubscription(details: object): void;
}

//lean interface for logging cash transactions
interface ICashLogger {
  logCashPaymentTillId(tillId: number, transactionId: number): void;
}

//stripe gateway implements the lean interfaces it needs
class StripePaymentGateway implements IPaymentProcessor, IRefundProcessor, IRecurringSubscriptionProcessor {
  processPayment(amount: number): void {
    console.log(`Stripe: Collected payment of $${amount}`);
  }

  processRefund(transactionId: string): void {
    console.log(`Stripe: Refunded payment for ${transactionId}`);
  }

  generateRecurringSubscription(details: object): void {
    console.log(`Stripe: Generated subscription with details ${JSON.stringify(details)}`);
  }
}

//cash gateway implements the lean interfaces it needs
class CashPaymentGateway implements IPaymentProcessor, ICashLogger {
  processPayment(amount: number): void {
    console.log(`Cash: Collected payment of $${amount}`);
  }

  logCashPaymentTillId(tillId: number, transactionId: number): void {
    console.log(`Cash: Logged transaction ${transactionId} to till ID ${tillId}`);
  }
}

I’ll refrain from teaching you to suck eggs and assume that the improvements above are very obvious; each class implements it’s own lean, targeted interface defining only the methods they require. Common interfaces can be mixed and matched to ensure consistency and code re-use while maintaining separation of concerns.

Why is this important?

By now I hope the reasons for these improvements are becoming crystal clear. In this particular scenario without ISP we are creating more work for ourselves or other developers, forcing them to implement methods they do not need.

Moreover we are increasing the likelihood of developer error or cross-fire; we have too much functionality in one class and multiple developers will begin to trip up over each other’s commits. Testing becomes more troublesome with more methods to fake. You can see that violating ISP often leads to violating SRP (Single Responsibility Principle) and LSP as we’ve already covered.

How do I remember? – Segregate the fuel types!

Focusing on “Segregation”, my mind jumps to the multiple jerry cans of fuel I have stored in the front of my van. Two cans of petrol that I use for a back-up generator, a life saver when the sun hides behind it’s cloudy neighbours, and a can of diesel that serves as reserve for the main fuel tank.

These two fuel types are in cans with clearly defined interfaces, petrol cans are red and diesel is black.

If I did not have this system of clearly defined interfaces, I may find my generator trying to implement a fuel type it has no use for, or worse yet, my van may start throwing exceptions when I tell it to implement petrol. That’s a bad day I hope I never see.

The clearly defined, lean, types for these two jerry cans of fuel help maintain the longevity of both systems that require fuel.

This metaphor serves nicely as my mnemonic imprint for this principle. The inherent fear present in the metaphor, which is quite literally nightmare fuel if you’ll excuse the pun is a great enforcer for my desire to never break the Interface Segregation Principle – Segregate the fuel types!

D – Dependency Inversion Principle

Rule 1. High-level modules must not depend on low-level modules, both must depend on abstractions.
Rule 2. Abstractions must not depend on details, details depend on abstractions.

Dependency Inversion is really about making sure that all dependencies are abstractions (usually interfaces), not concrete implementations. Not to be confused with dependency injection, which is a technique for achieving inversion of control, which supports dependency inversion.

We are trying to de-couple our application and make sure classes don’t care how their dependencies are created, or what their concrete type is, only that they follow a contract (an interface) we expect.

Lets take a look at a very simple example to help make this crystal clear:

// low-level repository, fetching data from our DB
// (or returning an array in this dummy example…)
class ProductRepository {
  getAllProducts(): string[] {
    return ["TV", "Laptop", "Phone"];
  }

  getPromotionalProducts(): string[] {
    return ["TV"];
  }
}

// high-level business-logic layer
// (filtering not defined in this example for brevity)
class ProductService {
  constructor(private repo: ProductRepository) {} // depends on a concrete implementation

  listProducts(): string[] {
    // further high level business logic...
    return this.repo.getAllProducts();
  }
}

Here we have two classes, ProductService which is dependant on the concrete implementation of ProductRepository. Is this the end of the world? Probably not for small projects, but as codebases and functionality grow we can see how tightly coupled our classes can become. Lets add in a couple more services that make use of the ProductRepository and see how things start to escalate:

// low-level repo
class ProductRepository {
  getAllProducts(): string[] {
    return ["TV", "Laptop", "Phone"];
  }

  getPromotionalProducts(): string[] {
    return ["TV"];
  }
}

//high-level product service
class ProductService {
  constructor(private repo: ProductRepository) {} //dependant on a concrete implementation

  listProducts(): string[] {
    return this.repo.getAllProducts();
  }
}

//high-level promotions service
class PromotionsService {
  constructor(private repo: ProductRepository) {} //dependant on a concrete implementation

  runPromotions(): void {
    const promos = this.repo.getPromotionalProducts();
    console.log("Promoting:", promos);
  }
}

//high-level reporting service
class ReportingService {
  constructor(private repo: ProductRepository) {} //dependant on a concrete implementation

  generateReport(): void {
    const report = this.repo.getAllProducts();
    console.log("Reporting:", report);
  }
}

Now we have three high-level classes using our ProductRepository. Fast-forward three months from launch and your team lead tells you that a new product API has been built and we want to swap it in for testing in a staging environment.

There are two things you could do, create a new ProductAPIRepository and update all three of these classes to expect the new concrete type. Might be fine, three isn’t so bad. But then, what if there are dozens, surely not more than twenty? It happens. Then you realise other developers are working on those twenty files in their own feature branches, hello merge conflicts.

Alternatively, you could re-write all of the code in your current ProductRepository to talk to the API and not the database. Hmm, ok.. But then if things don’t go well in testing you’ll be reverting commits and merges trying to get your old code back before the boss finds out you got rid of it all.

What’s worse, two other developers have pushed changes to the staging and dev branches which have been merged and reverting merges will remove their code, you best hope they still have those feature branches! Now you’re scrambling through individual commits trying to cherry pick code and put things back. Or maybe you kept a ProductRepository.old ?

There must be a better way… Enter dependency inversion:

interface IProductRepository {
  getAllProducts(): string[];
}

//product repository now implements an abstraction
class ProductRepository implements IProductRepository {
  getAllProducts(): string[] {
    return ["TV", "Laptop", "Phone"];
  }
}

//high-level product service
class ProductService {
  constructor(private repo: IProductRepository) {} // now dependent on an abstraction

  listProducts(): string[] {
    return this.repo.getAllProducts();
  }
}

The only difference is now ProductRepository implements the IProductRepository interface, and in turn the ProductService expects to be passed any class that implements this contract. On the face of it not much has changed, but lets evaluate what happens in our hypothetical API update scenario.

We are now given a third option: we can write a new concrete class, ProductAPIRepository and have it implement IProductRepository, the exposed repository methods will be identical to the old class, thanks to your interface, only we are getting data from an API this time, instead of our DB:

class ProductAPIRepository implements IProductRepository {
  getAllProducts(): string[] {
    // Simulate fetching from an API
    return ["Online TV", "Online Laptop", "Online Phone"];
  }
}

//swap in our new repo, without affecting the rest of the system
const service = new ProductService(new ProductAPIRepository());

console.log(service.listProducts());

Our service no longer cares which concrete implementation it’s given only that it implements IProductRepository. This is just one simple benefit of the Dependency Inversion Principle, we can easily swap in new implementations of an interface and switch back to previous versions without fuss. In reality you would likely have a ProductRepositoryProvider that acts as a factory to provide the correct repository implementation based on whatever criteria you have, this way you would only change one line of code to switch out the repo and inject into each of your services.

Dependency Inversion Principle – Rule 2 (Abstractions must not depend on details, details depend on abstractions)

What we’ve covered above pertains to Rule 1 of The Dependency Inversion Principle (High-level modules must not depend on low-level modules, both must depend on abstractions).

Rule 2 is talking about our interfaces themselves. Defining an interface is all well and good but if you bring concrete types that you do not control into the mix, you are also falling foul of DIP:

import type { ShopifyProduct } from "@shopify/shopify-api";

interface IProductRepository { 
  getAll(): Promise<ShopifyProduct[]>
}

Here you can see we have created an interface to appease Rule 1, but we’ve fallen foul of Rule 2. Our interface is tied to a specific concrete type from an external vendor. Even if this was an internal type to our system we should be returning an interface:

//product interface
export interface IProduct {
  id: string;
  name: string;
}

//product repository interface
export interface IProductRepository { 
  getAll(): Promise<IProduct[]>; //methods return abstract domain interface
}

import type { ShopifyProduct } from "@shopify/shopify-api";

//helper function to map external data to local domain format
function toProduct(sp: ShopifyProduct): IProduct {
  return { id: String(sp.id), name: sp.title };
}

//shopify repo maps third party format to local domain interface
export class ShopifyRepo implements IProductRepository {
  async getAll(): Promise<Product[]> {
    const raw: ShopifyProduct[] = await fetchShopifyProducts();
    return raw.map(toProduct);
  }

  async function fetchShopifyProducts(): Promise<ShopifyProduct[]> {
      // Simulate API call
      return [];
    }

}

The key code here is our repository interface returns a promise of IProduct[], and the toProduct() function used by our repository, which maps the external data type provided by the ShopifySDK to our local domain data type. Therefore fulfilling the contract to our abstract type IProduct.

Why is this important?

In addition to the benefits discussed during our Rule 1 deep dive: other benefits include testability. We’ve already seen how much easier it is to plug-and-play new implementations into a DIP compliant class, so it should be easy to see how inserting fakes or mocks into tests becomes considerably more straightforward. This allows you to write truly isolated tests without having to worry about dependency chains that can require complex setups and sometimes real infrastructure (like a database or third party API).

Conforming to DIP also helps ensure alignment with other principles in the SOLID family such as the Open/Closed principle and the Single Responsibility Principle.

Maintainability is improved for reasons we have already discussed and having an application more loosely coupled encourages separation of concerns.

How do we remember? The dependable plug adapter

I’ll be honest, this metaphor didn’t come as easily as the others; It required some mental gymnastics but I arrived at the notion that I have a dependency on my universal plug adapter.

Being an entirely nomadic developer I often travel between Europe and the UK and in the past I have travelled to meet clients in America. The adapter is a life-saver for enabling me to conduct work with any power source and much like our example interface it allows me to plug-and-play external devices.

If we consider that my laptop is the high-level service, then the adapter is the interface and the wall socket is the concrete implementation of the power source.

My laptop doesn’t care which concrete implementation of the power source it’s provided, only that it conforms to an interface my laptop can understand. That is dependency inversion.

Let’s wrap this up

And so there we have it, my mnemonic device for understanding and remembering SOLID is as follows:

  • S – Single Responsibility Principle – Don’t stress the dog!
  • O – Open/Closed Principle – It’s cold outside the van
  • L – Liskov Substitution Principle – The substitute teacher
  • I – Interface Segregation Principle – Segregate the fuel types!
  • D – Dependency Inversion Principle – The dependable plug adapter

If you found this article helpful I encourage you to create your own mnemonic device for SOLID and I would love to hear what you come up with. If you have other techniques or methods that help you remember abstract concepts I would love to hear about those too.

Happy coding – Joe


This content originally appeared on DEV Community and was authored by NomadCode