SOLI[D] - Dependency Inversion Principle

Subscribe to receive new articles. No spam. Quality content.

This is the last article on SOLID principles and today we're going to talk about Dependency Inversion Principle.

Definition

A. High-level modules should not depend on low-level modules. Both should depend on abstractions.

B. Abstractions should not depend on details. Details should depend on abstractions.

It sounds really good. But as usually it's hard to come up with some good example for this principle. So I want to share my story and show how this principle helped me to architect one of my projects.

Example

I'm interested in Domain-Driven Design, Clean Architecture and Hexagonal approach. Important part of those buzzwords is layered architecture. I wanted to create a hexagon which would look like this:

Hexagonal architecture

I know that there is a lot going on on that picture, so let's focus on "Storage" piece of that hexagon:

Hexagonal architecture

I have 3 layers:

  1. Domain. It's my core domain where I want to have Entities and Repositories.
  2. Application. That's the level where my UseCases and app-related code live.
  3. Infrastructure. Level for HTTP, Databases, etc.

You can see on that picture that all dependencies (arrows) go from outer level to inner levels. That's how layered architecture should be designed. Outer levels depend on inner ones. The problem I had was related to those dependencies. Let's say that we have ActiveRecord (application level) with MySQL database (infrastructure level), how would Repository look like?

Possible implementation could look like this:

class UserRepository
  def all
    User.all
  end

  def find(id:)
    User.find(id)
  end
end

Do we have any problem with this implementation? Yes, we do. Because in this case repository depends on User - ActiveRecord object from application level. It's coupled to application level, so arrow goes FROM Repository TO Application level. But we need exactly opposite direction. We should reverse that dependency. Application level should depend somehow on Domain level. Also it's a good example of rigid system, because we can't switch from ActiveRecord data source to InMemory without changing code.

Now we know that we want to reverse dependency and we know that SOLID can help:

Abstractions should not depend on details. Details should depend on abstractions.

In this example Repository should behave as abstraction and each particular data source is just a detail of implementation.

To make system more flexible we will start from dependency injection. We're going to inject data source into repository:

class UserRepository
  def initialize(data_source:)
    @data_source = data_source
  end

  def all
    data_source.all
  end

  def find(id:)
    data_source.find(id)
  end

  private

  attr_reader :data_source
end

Now when we initialize a new repository, we can dynamically define which data source to use. It can be InMemory data source, ActiveRecord model, anything that responds to all and find methods. Now we're really following this rule:

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Our high level repository doesn't depend on specific data source implementation. Both of those depend on abstraction - on the list of methods that each data source should implement. Jim Weirich calls it "Protocol" in his talk about SOLID.

In my case repository is responsible for wrapping results from data source into entities as well, so code of repo can look like this:

class UserRepository
  # ...
  def all
    data_source.all.map { |u| User.new(u) }
  end

  def find(id:)
    User.new(data_source.find(id))
  end
  # ...
end

Where User is not an ActiveRecord model, but Plain Old Ruby Object. I use dry-struct gem to define data structure of entity.

Using dependency injection we managed to get the proper dependencies flow.

Data source implementation can be as simple as this:

module InMemory
  class User
    def all
      [
        { first_name: 'Phil', last_name: 'Taylor' },
        { first_name: 'Adrian', last_name: 'Lewis' },
      ]
    end

    def find(_id)
      { first_name: 'Phil', last_name: 'Taylor' }
    end
  end
end

We can easily use it with this code:

UserRepository.new(data_source: InMemory::User.new)

Having that flexibility we can dynamically set InMemory data source for tests and database implementation for any other environment

data_source = Rails.env.test? ? InMemory::User.new : Database::User.new
UserRepository.new(data_source: data_source)

Usually I have repository registry which defines all required repositories and data sources basing on environment, but that's the topic for another article :)

Thanks for reading, I hope you like the SOLID principles and see how to use it in day-to-day work.

Read more about SOLID Principles in case if you missed it:

Subscribe to receive new articles. No spam. Quality content.

Comments