iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
😊

Explaining SOLID Principles (DIP: Dependency Inversion Principle)

に公開

Introduction

In software design, the SOLID principles are important guidelines for writing highly maintainable code.
In this article, I will explain the particularly famous "Dependency Inversion Principle (DIP)", even though there are already so many articles about it that it might feel late to the party.

Explaining SOLID Principles Series

Please check these out as well 😆

What is DIP: A Principle to Protect Software Value from Technical Details

To put DIP (Dependency Inversion Principle) in a single sentence:

Have You Ever Experienced This?

  • ❌ "When I changed the database from MySQL to PostgreSQL, I ended up having to rewrite the business logic..."
  • ❌ "Due to a change in an external API's specifications, application logic that shouldn't have needed changes was affected..."

The root cause is that the essential value of the software, which is the logic, depends on the technical means.

What DIP Solves

  • ✅ Technical choices (e.g., MySQL → PostgreSQL) do not affect the logic
  • ✅ Features can be switched with minimal changes

Through DIP, the technical means come to depend on the logic, which is the essential value of the software.
This allows you to gain the benefits mentioned above.

Why Does This Happen?

First of all, why does such a problem occur?
It lies in the nature of dependency, which is an indispensable property of software structure.

"Dependency Inversion Principle" is, literally, a principle regarding the direction of dependencies.
The reason the direction of dependencies is important is that it relates to the propagation of effects caused by changes.

Where there is a dependency, there is a "dependent" and a "dependency".
In this case, when the "dependency" is changed, the "dependent" will be affected.
For more details, please see "On software dependencies and the direction of change propagation" (Japanese article).

I will explain using a common API configuration example: controller/usecase/repository(repo). (The controller is mostly just there for context).
In such a design, the technical means become the "dependency", and the logic[1] becomes the "dependent".

As a result, the following structure is created:

To illustrate, let's use the cancellation process of a room reservation system as an example.
Let's assume a simple use case where we search for reservation data using an ID as a key and delete the data if the reservation status is "Reserved".

When written in code, it looks like this:

room/usecase module example (depends on room/repo/mysql)
package usecase

import (
	"log"

    // 💣 What if the DB becomes PostgreSQL and the implementation becomes `room/repo/postgresql`?
	"room/repo/mysql"
)

type ReservationUsecase struct {
    // 💥 package rewrite
    cli mysql.RoomReservationClient
}

func (u ReservationUsecase) Cancel(reservationID string) error {

	if roomReservation, err := u.cli.FindByID(reservationID); err != nil {
        switch err.(type) {
        // 💥 package rewrite
        case mysql.NotFoundRoomReservation :
            // Processing required for each error
            return err
        }
        return err
	}

    if roomReservation.Reserved() {
        if err := u.cli.Cancel(reservationID); err != nil {
            // Handling for errors
        }
    }

	return nil
}
room/repo/mysql module example
package mysql

import (
	"database/sql"
	"encoding/json"

    "github.com/go-sql-driver/mysql" dmysql
)

// ❗ Defined here because it would cause a circular reference otherwise, despite being a definition for the usecase's convenience
type (          
    NotFoundRoomReservation struct {Message string, Key string}
    InternalServerErr       struct {Message string, Key string}   
    // ...other error definitions
)

type RoomReservation struct {
	ID         string `db:"id"`
	Status     string `db:"status"`
}
    // ❗ Defined in the repo layer despite being obvious logic...
func (r RoomReservation) Reserved() bool { return r.Status == "reserved" }

type RoomReservationClient struct {
	db sql.DB
}

func NewRoomReservationClient(db sql.DB) RoomReservationClient {
	return RoomReservationClient{db: db}
}

func (c RoomReservationClient) FindByID(
    reservationID string
) (*RoomReservation, error) {
    query := `SELECT id, status FROM room_reservations WHERE id = ?`
    row := c.db.QueryRow(query, reservationID)
    
    var model RoomReservation
    if err := row.Scan(&model); err != nil {
        if err == sql.ErrNoRows {
            return nil, NotFoundRoomReservation{
                Message: err.Error(),
                Key: reservationID,
            }
        }
            
        if mysqlErr, ok := err.(*dmysql.MySQLError); ok {
            // Determine by MySQL error code
            switch mysqlErr.Number {
            case 2013:
                return nil, InternalServerErr{
                    Message: "Lost connection to MySQL server",
                    Key: reservationID,
                }
            // ...
            }
        }
    }
    
    return &RoomReservation{
        ID:     model.ID,
        Status: model.Status,
    }, nil
}

func (c RoomReservationClient) Cancel(reservationID string) error {
    // Omitted
}

The situation where "changing the repository implementation (client) that handles database processing from MySQL to PostgreSQL results in having to change the logic as well" is caused by this structure.

How to Solve It

Invert the dependencies!

That sounds cryptic, but what we're really doing is hiding implementation details using Interfaces (I/F).
The following diagram shows an example where new packages named room/entity and room/usecase/service have been added.

By having the usecase depend on the I/F defined in service, the logic no longer directly depends on technical implementations.
The Client is hidden behind the I/F. While this Client performs the actual processing, the usecase only sees the I/F.
The Client uses the I/F as a cloak.

The code is shown below.

room/entity module example

What is an entity?

If the usecase is the layer that uses business logic to fulfill user requirements through software, the entity is the representation of business knowledge as-is.

package entity

// ✅ Does not involve application logic or technical details at all
type RoomReservation struct {
    ID     string
    Status string
}
// Define business rules (naturally, it knows nothing about application logic or technical details)
func (r RoomReservation) Reserved() bool { return r.Status == "reserved" }
room/usecase/service module example
package service

// Does not hold technical information such as tags
type RoomReservationInput struct {
    ID     string
    Status string
}

// ✅ Only knows the specification for data retrieval (specific technical concerns are delegated to the implementation side)
type RoomReservationAccessor interface {
    FindByID(string) (*RoomReservationInput, error)
    Cancel(string) error
}
room/usecase module example (depends on entity and service)
package usecase

import (
	"log"

    // ✅ Nothing imported regarding repo -> usecase knows nothing about repo
    "room/entity"
    "room/usecase/service"
)

// Moved here since repo can now depend on usecase
type (          
    NotFoundRoomReservation struct {Message string, Key string}
    InternalServerErr       struct {Message string, Key string}   
    // ...other error definitions
)

type RoomReservationUsecase struct {
    // ✅ Hiding the Client implementation with an I/F!
    accessor service.RoomReservationAccessor
}

func NewRoomReservation(
    accessor service.RoomReservationAccessor,
) RoomReservationUsecase {
    return RoomReservationUsecase {
        accessor: accessor,
    }
}

func (u RoomReservationUsecase) Cancel(reservationID string) error {
    // ✅ The actual client processing happens behind the cloak of the accessor!
    input, err := u.accessor.FindByID(reservationID)
	if err != nil {
        switch err.(type) {
        // ✅ Error definitions are no longer affected by technical concerns
        case NotFoundRoomReservation:
            // Processing required for each error
            return err
        }
        return err
	}
    
    roomReservation := entity.RoomReservation{ID: input.ID, Status: input.Status}
    if roomReservation.Reserved() {
        if err := u.accessor.Cancel(roomReservation.ID); err != nil {
            // Omitted
        }
    }

	return nil
}
room/repo/mysql module example (depends on usecase and service)
package mysql

import (
    "database/sql"
    "encoding/json"

    "github.com/go-sql-driver/mysql" dmysql

    // Now depends on application logic
    "room/usecase"
    "room/usecase/service"
)

// ✅ Definition only for receiving external data (business logic moved to appropriate module)
type RoomReservation struct {
	ID     string `db:"id"`
	Status string `db:"status"`
}

// Implement usecase.RoomReservationAccessor (Implicit in Go)
type RoomReservationClient struct {
	db sql.DB
}

func NewRoomReservationClient(db sql.DB) RoomReservationClient {
	return RoomReservationClient{db: db}
}

func (c RoomReservationClient) FindByID(
    reservationID string,
// ✅ Depends on the logic layer
) (*service.RoomReservationInput, error) {
    query := `SELECT id, status FROM room_reservations WHERE id = ?`
    row := c.db.QueryRow(query, reservationID)
    
    var model RoomReservation
    if err := row.Scan(&model); err != nil {
        if err == sql.ErrNoRows {
            // ✅ Depends on logic
            return nil, NotFoundRoomReservation{
                Message: err.Error(), Key: reservationID,
            }
        }
            
        if mysqlErr, ok := err.(*dmysql.MySQLError); ok {
            // Determine by MySQL error code
            switch mysqlErr.Number {
            case 2013:
                // ✅ Depends on logic
                return nil, InternalServerErr{
                    Message: "Lost connection to MySQL server",
                    Key: reservationID,
                }
            // ...
            }
        }
    }
    // ✅ Depends on logic
    return &service.RoomReservationInput{
        ID:     model.ID,
        Status: model.Status,
    }, nil
}

func (c RoomReservationClient) Cancel(reservationID string) error {
    // Omitted
}

While the definitions in the diagram are finished, we need to assemble them to make them work. Here is an example of assembling during server initialization.

server (server initialization process)
package server

import (
    // Omitted
)

func server() {
    var conf Config
    err := envconfig.Process("", &conf)
    if err != nil {
        log.Fatal("Failed to load environment variables")
    }

    var accessor service.RoomReservationAccessor
    if conf.Env == "Prod" {
        // Production code connects to the actual DB (MySQL)
        dsn := fmt.Sprintf(
            "%s:%s@tcp(%s:%d)/%s", 
            conf.DBUser, conf.DBPassword, conf.DBHost, conf.DBPort, conf.DBName,
        )
        db, _ := sql.Open(conf.DBDriver, dsn)
        accessor = mysql.NewRoomReservationClient(db) // ✅ MySQL client passed to usecase using accessor as a cloak
    } else {
        accessor = mock.MockRoomReservationClient() // ✅ Mock client passed to usecase using accessor as a cloak
    }

    // Assembly flow
    roomReservationUsecase := usecase.NewRoomReservation(accessor) // ✅ Switch accessor behavior via the provided client implementation without changing usecase
    roomReservationController := controller.NewRoomReservation(roomReservationUsecase)
    // Register controller methods to API handlers and start server
}

What Happens as a Result

✅ Technical choice changes do not affect logic

If you look at the example of the room/usecase package after practicing DIP, you will see that there are no detailed descriptions of the DB client at all.
This means that the logic, which is the usecase, does not depend on technical choices at all and is not affected by them.
If, for example, the DB changes to PostgreSQL, you only need to add a DB client.

room/repo/postgresql
package postgresql

import (
    "database/sql"
    "encoding/json"

    "github.com/lib/pq"

    "room/usecase"
    "room/usecase/service"
)

type RoomReservation struct {
	ID     string `db:"id"`
	Status string `db:"status"`
}

type RoomReservationClient struct {
	db sql.DB
}

func NewRoomReservationClient(db sql.DB) RoomReservationClient {
	return RoomReservationClient{db: db}
}

// ✅ Data retrieval specification does not change due to the I/F
func (c RoomReservationClient) FindByID(
    reservationID string,
) (*service.RoomReservationInput, error) {
    // ✅ Placeholder "$1" is specific to PostgreSQL
    query := `SELECT id, status FROM room_reservations WHERE id = $1`
    row := c.db.QueryRow(query, reservationID)
    
    var model RoomReservation
    if err := row.Scan(&model); err != nil {
        if err == sql.ErrNoRows {
            return nil, NotFoundRoomReservation{
                Message: err.Error(), Key: reservationID,
            }
        }
        // ✅ Handled with PostgreSQL-specific error codes
        if pqErr, ok := err.(*pq.Error); ok {
            if pqErr.Code == "23505" {
                return nil, InternalServerErr{
                    Message: "Lost connection to PostgreSQL server",
                    Key: reservationID,
                }
            }
        }
    }

    return &service.RoomReservationInput{
        ID:     model.ID,
        Status: model.Status,
    }, nil
}

Changes become easier to test

Adding modules in this way is effective not only for switching the DB itself but for other things as well.

For example, if you find a useful third-party library and want to try it out.
Instead of rewriting existing code, you can define a new module and swap it in.

✅ Features can be switched with minimal changes

How do we switch the client to PostgreSQL as mentioned earlier?
The answer is simple: you just need to change the server code. You don't have to touch the logic at all.

server (using room/repo/postgresql)

Excerpt of only necessary parts

    var accessor service.RoomReservationAccessor
    if conf.Env == "Prod" {
        dsn := fmt.Sprintf(
            "%s:%s@tcp(%s:%d)/%s",
            conf.DBUser, conf.DBPassword, conf.DBHost, conf.DBPort, conf.DBName,
        )
        db, _ := sql.Open(conf.DBDriver, dsn)
        accessor = postgresql.NewRoomReservationClient(db) // ✅ By changing only this part, you can adapt to DB changes
    } else {
        accessor = mock.MockRoomReservationClient()
    }

    roomReservationUsecase := usecase.NewRoomReservation(accessor)

Why DIP is important

This is saying the same thing as the Open-Closed Principle (OCP), one of the SOLID principles.
If you correctly achieve DIP, changes become easy without having to touch every part of the code, and OCP is achieved.

A Small Question

How to decide the direction of dependency?

Have you ever had a question like the following?

In common dependency directions (like usecase -> entity), there's no problem in deciding, but if there's a unique dependency between modules, how should I think about the direction?

Personally, I follow this idea:

I mentioned that there are two entities in a dependency:

  • The dependent
  • The dependency

In the case of a usecase and an entity, there is the dependent (usecase) and the dependency (entity).
For the software, which one plays a more essential role?
Is it the usecase that describes the workflow? Or the entity that describes the business rules?
In this relationship, the entity is more essential.

Therefore, the direction of dependency becomes usecase -> entity.
The same applies to technical implementations like repo or controller depending on the usecase (repo/controller -> usecase).

If you're unsure about the direction of dependency during development, focus on this:

And then you can decide if it's necessary to invert the dependency.

Conclusion

By separating technical dependencies from logic through DIP, you can gain the following benefits:

  • ✅ Technical choice changes do not affect logic
  • ✅ The scope of impact from changes is limited
  • ✅ Features can be switched with minimal changes (also related to OCP)

In summary, DIP can be described as follows:

脚注
  1. Usecase is also called application logic and is a type of logic ↩︎

  2. In Clean Architecture, an entity represents the layer for business logic; in DDD, it is called a "domain". ↩︎

Discussion