SOLID Principles: Dependency Inversion

7 minute read Published: 2023-07-29


SOLID constitutes five design principles focused on making object-oriented designs more maintainable, understable, and flexible. The principles of SOLID are:

Single-responsibility principle

Open-closed principle

Liskov substitution principle

Interface segregation principle

Dependency inversion principle

In this post, I'm going to cover the final principle, Dependency Inversion.

What is Dependency Inversion

In a few words, the Dependency Inversion principle (DIP) can be described as:

Depend on abstractions, not concretions

In many object-oriented languages, abstractions would often refer to an Interface and concretions on a Class implementation of it. There are several benefits that come from this:

Decoupling of components: DIP encourages the decoupling of high-level modules from low-level modules. By introducing abstractions and interfaces, it allows components to interact with each other without needing to know specific implementation details. This reduces the tight coupling between modules, making the code more flexible and easier to maintain.

Reusability: With DIP, components depend on abstractions rather than concrete implementations. This promotes reusability as multiple implementations can be created for the same interface, allowing different components to use them interchangeably.

Testability: By programming to interfaces rather than concrete classes, unit testing becomes easier. Mocking or stubbing interfaces during testing becomes straightforward, enabling more effective and isolated testing of individual components.

Encourages a stable architecture: Following DIP leads to a more stable architecture as changes to low-level modules or concrete implementations are less likely to affect higher-level modules. This principle facilitates the "Open/Closed Principle" (OCP) by allowing the system to be easily extended with new functionalities without modifying existing code.

Inversion of control (IoC): DIP is often associated with IoC containers that manage the creation and resolution of dependencies. By using an IoC container, the responsibility for managing object instantiation and dependency resolution is shifted from the application code to the container, simplifying the overall design and promoting a more modular and maintainable structure.

While there are many benefits to following DIP, I would still warn that there are definitely some downfalls to over-applying this pattern. However, it generally leaves your project in a significantly better state for future changes and growth.

Example

This example dives into an application of the dependency inversion principle, but it inadvertently will also showcase a couple of other patterns closely related to this, including the general pattern of Inversion of Control and the Dependency Injection pattern, whose functionality is often provided by some application framework like Spring.

Here, we'll have some application which manipulates users in the system. We will have a UserService and a UserRepository, and the UserRepository will be an interface as opposed to a specific implementation. The UserRepository is implemented by multiple classes, and in this case we have an implementation backed by MySQL and another by Redis. However, the UserService itself has no knowledge of which implementation it is interacting with, and never should. It only cares about the contract in which it interacts with the instance it has a reference to.

To start off, I'll define a core model I won't include in the graph below but will be necessary regardless. The User!

class User {
    constructor(
        public readonly id: string,
        public readonly email: string,
    )
}

Now, we can define our abstraction around interacting with the Users in the system. In my case, I would be defining the UserRepository as a collection-like interface (no necessary persist() calls on the Repository to push updates to the data store).

interface UserRepository {
    nextId(): Promise<string>;
    add(user: User): Promise<void>;
    delete(user: User): Promise<void>;
    findByEmail(id: string): Promise<User>;
}

Now, I haven't actually implemented this repository yet, but I can already start to envision my domain service, where I'll provide functionality for creating a User with their email.

class UserService {
    constructor(private readonly userRepository: UserRepository) {}

    async createNewUser(email: string): Promise<void> {
        this.userRepository.findByEmail(email);

        const user = new User(this.userRepository.nextId(), email);

        await this.userRepository.add(user);
    }
}

Great! We're going to be creating user's with this service, now let's really implement a UserRepository that we can get started with. I'm going to use a simple in-memory backend like Redis here. I might lose everything when I restart Redis but I just want to test the system out locally for now.

class RedisUserRepository implements UserRepository {
    private static readonly PREFIX = 'user';

    constructor(private readonly redis: Redis) {}

    async nextId(): Promise<string> {
        return uuid();
    }

    async add(user: User): Promise<void> {
        await this.redis.set(
            this.calculateKey(user),
            this.serializeUser(user),
        );
    }

    async delete(user: User): Promise<void> {
        await this.redis.del(this.calculateKey(user));
    }

    async findByEmail(email: string): Promise<User> {
        const matchingKeys = await this.redis.scan(`0 MATCH user::*::${this.base64Encode(email)}`);
        if (matchingKeys.length === 0) {
            throw new NotFoundException();
        }

        if (matchingKeys.length > 1) {
            throw new MultipleUsersForSameEmailException();
        }

        const user = await this.redis.get(matchingKeys[0]);

        if (typeof user === 'string') {
            return this.deserializeUser(user);
        }

        return user;
    }

    private calculateKey(user: User): string {
        const { id, email } = user;

        return `${RedisUserRepository.PREFIX}::${this.base64Encode(id)}::${this.base64Encode(email)}`;
    }

    private base64Encode(text: string): string {
        return new Buffer(text).toString('base64');
    }

    private deserializeUser(user: string): User {
        return JSON.parse(user);
    }

    private serializeUser(user: User): string {
        return JSON.serialize(user);
    }
}

Now we need to actually instantiate all of these things. Here I'll build out my application container that creates instances of these components and wires it all together. Many people prefer to use a Dependency Injection framework to accomplish this, but I'm keeping this post example simple:

import { createClient } from 'redis';

class ApplicationContainer {
    private static redis;

    static async bootstrap(): Promise<void> {
        await this.startUp();
        await this.main();
        await this.shutDown();
    }

    static async startUp(): Promise<void> {
        this.redis = createClient();
        this.redis.on('error', err => console.error('Redis Client Error', err));
        await this.redis.connect();
    }

    static async main(): Promise<void> {
        const userRepository = new RedisUserRepository(this.redis);
        const userService = new UserService(userRepository);

        await userService.createNewUser('username@example.com');
    }

    static async shutDown(): Promise<void> {
        await this.redis.disconnect();
    }
}

new ApplicationContainer().bootstrap();

Everything is working great so far. I'd like to build out an implementation now that backs itself on the same data store the rest of our production services run on- MySQL! Let's see what that looks like:

class MySQLUserRepository implements UserRepository {
    constructor(private readonly connection: MySQL) {}

    async nextId(): Promise<string> {
        return uuid();
    }

    async add(user: User): Promise<void> {
        const { id, email } = user;

        await this.connection.execute('INSERT INTO `Users` VALUES (?, ?);', [id, email]);
    }

    async delete(user: User): Promise<void> {
        await this.connection.execute('DELETE FROM `Users` WHERE `id` = ?;', user.id);
    }

    async findByEmail(email: string): Promise<User> {
        const [users] = await this.connection.queryRows(
            'SELECT * FROM `Users` ORDER BY `id` ASC;',
        );

        if (users.length === 0) {
            throw new NotFoundException();
        }

        if (users.length > 1) {
            throw new MultipleUsersForSameEmailException();
        }

        return users[0];
    }
}

Now, we'll update the Application Container to create the MySQLUserRepository instance and pass that in instead. All set to go!

import mysql from 'mysql2/promise';


class ApplicationContainer {
    private static mysql;

    static async bootstrap(): Promise<void> {
        await this.startUp();
        await this.main();
        await this.shutDown();
    }

    protected static async startUp(): Promise<void> {
        this.mysql = await mysql.createConnection({host:'localhost', user: 'root', database: 'test'});
    }

    protected static async main(): Promise<void> {
        const userRepository = new MySQLUserRepository(this.mysql);
        const userService = new UserService(userRepository);

        await userService.createNewUser('username@example.com');
    }

    protected static async shutDown(): Promise<void> {
        await this.mysql.close();
    }
}

new ApplicationContainer().bootstrap();

As you can see from this, none of our logic needed to change around the actual business logic layer of our code. This is also why Dependency Inversion is seen as one way to facilitate Inversion of Control, as we are passing dependencies from the top down as opposed to calling or creating external modules within our domain logic.

Here is a graph of how these interfaces, class implementations, and the application container relate to each other:

┌──────────────────────┐
│                      │
│     Application      │    passes instance to
│     Container        ├────────────────────────┐
│                      │                        │
└──────────────┬───────┘                        │
               │                                │
               │                                │
               │                     ┌──────────▼──────────┐
               │                     │      UserService    │
               │                     │                     │
               │                     └──────────┬──────────┘
               │                                │
               │                                │
               │                                │
          instantiates                   interacts with
               │                                │
               │                                │
               │                                │
               │                     ┌──────────┴──────────┐
               │                     │   UserRepository    │
               │                     │                     │
               │                     └──────────┬──────────┘
               │                                │
               │                    ┌───────────┴─────────────┐
               │                    │      implements         │
               │                    │                         │
               │                    │                         │
               │         ┌──────────┴──────────┐   ┌──────────┴──────────┐
               │         │ MySQLUserRepository │   │ RedisUserRepository │
               └─────────►                     │   │                     │
                         └─────────────────────┘   └─────────────────────┘