Dependency Inversion Principle in Go: What It Is and How to Use It

cover
12 May 2024

Intro

In this article, we will discuss the Dependency Inversion principle. In short, we'll talk about what it is and examine this principle using a simple Go application as an example.

What is the Dependency Inversion Principle?

The Dependency Inversion Principle (DIP) is one of the five SOLID principles of object-oriented programming (OOP), first introduced by Robert C. Martin. It states:

  1. High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).

  2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

It’s a very well-known principle in the world of OOP design, but if you've never encountered it before, it might seem unclear at first glance, so let's break down this principle using a specific example.

Example

Let's consider how the implementation of the DI principle could look in Go. We'll start with a simple example of an HTTP application with a single endpoint/book, which returns information about a book based on its ID. To retrieve information about the book, the application will interact with an external HTTP service.

Project Structure

cmd - folder with Go commands. The main function will reside here.

internal - folder with internal application code. All our code will reside here.

Example of Spaghetti Code Without DI

main.go simply starts the HTTP server.

package main

import (
   "log"
   "net/http"

   "example.com/books/internal/app/httpbookapi"
)

func main() {
   http.Handle("/book", &httpbookapi.Handler{})

   log.Print("server listening at 9090")
   log.Fatal(http.ListenAndServe(":9090", nil))
}

Here is the code for handling our HTTP endpoint:

package httpbookapi

import (
   "encoding/json"
   "fmt"
   "net/http"

   "example.com/books/internal/model"
)

type Handler struct {
}

func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
   var (
      ctx  = request.Context()
      id   = request.URL.Query().Get("id")
      book model.Book
   )

   url := fmt.Sprintf("http://localhost:8080/book?id=%s", id)
   req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   if err != nil {
      http.Error(writer, err.Error(), http.StatusInternalServerError)
      return
   }

   resp, err := http.DefaultClient.Do(req)
   if err != nil {
      http.Error(writer, err.Error(), http.StatusInternalServerError)
      return
   }
   defer resp.Body.Close()

   if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
      http.Error(writer, err.Error(), http.StatusInternalServerError)
      return
   }
   
   book.Price = 10.12
   if book.Title == "Pride and Prejudice" {
      book.Price += 2
   }

   writer.Header().Add("Content-Type", "application/json")
   if err := json.NewEncoder(writer).Encode(book); err != nil {
      http.Error(writer, err.Error(), http.StatusInternalServerError)
      return
   }
}

As you can see, currently all the code is directly inside the handler (excluding the Book model). In the handler, we create an HTTP client and make a request to an external service. Then we assign some price to the book. Here, I believe it's evident to any developer that this is not the best design, and the code for calling the external service needs to be extracted from the handler. Let's do that.

The First Step of Improvement

As a first step, let's move this code to a separate place. To do this, we'll create a file called internal/pkg/getbook/usecase.go, where the logic for retrieving and processing our book will reside, and internal/pkg/getbook/types.go, where we'll store the necessary getbook types.

usecase.go code

package getbook

import (
   "context"
   "encoding/json"
   "fmt"
   "net/http"
)

type UseCase struct {
   bookServiceClient *http.Client
}

func NewUseCase() *UseCase {
   return &UseCase{}
}

func (u *UseCase) GetBook(ctx context.Context, id string) (*Book, error) {
   var (
      book Book
      url  = fmt.Sprintf("http://localhost:8080/book?id=%s", id)
   )

   req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   if err != nil {
      return nil, err
   }

   resp, err := u.bookServiceClient.Do(req)
   if err != nil {
      return nil, err
   }
   defer resp.Body.Close()

   if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
      return nil, err
   }

   book.Price = 10.12
   if book.Title == "Pride and Prejudice" {
      book.Price += 2
   }

   return &book, nil
}

types.go code

package getbook

type Book struct {
   ID     string  `json:"id"`
   Title  string  `json:"title"`
   Author string  `json:"author"`
   Price  float64 `json:"price"`
}

The new handler code:

package httpbookapi

import (
   "encoding/json"
   "net/http"

   "example.com/books/internal/pkg/getbook"
)

type Handler struct {
   getBookUseCase *getbook.UseCase
}

func NewHandler(getBookUseCase *getbook.UseCase) *Handler {
   return &Handler{
      getBookUseCase: getBookUseCase,
   }
}

func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
   var (
      ctx = request.Context()
      id  = request.URL.Query().Get("id")
   )

   book, err := h.getBookUseCase.GetBook(ctx, id)
   if err != nil {
      http.Error(writer, err.Error(), http.StatusInternalServerError)
      return
   }

   writer.Header().Add("Content-Type", "application/json")
   if err := json.NewEncoder(writer).Encode(book); err != nil {
      http.Error(writer, err.Error(), http.StatusInternalServerError)
      return
   }
}

As you can see, the handler code has become much cleaner, but now, it's much more interesting for us to take a look at getbook/usecase.go

type UseCase struct {
   bookServiceClient *http.Client
}

The UseCase has a dependency in the form of *http.Client, which we're currently not initializing in any way. We could pass *http.Client into the NewUseCase() constructor or create *http.Client directly within the constructor. However, let's once again recall what the DI principle tells us.

High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces)

However, with this approach, we've done just the opposite. Our high-level module, getbook, imports the low-level module, HTTP.

Introducing Dependency Inversion

Let's think about how we could fix this. To start, let's create a file called internal/pkg/bookserviceclient/client.go. This file will contain the implementation of HTTP requests to the external service and the corresponding interface.

package bookserviceclient

import (
   "context"
   "fmt"
   "io"
   "net/http"
)

type Client interface {
   GetBook(ctx context.Context, id string) ([]byte, error)
}

type client struct {
   httpClient *http.Client
}

func NewClient() Client {
   return &client{
      httpClient: http.DefaultClient,
   }
}

func (c *client) GetBook(ctx context.Context, id string) ([]byte, error) {
   var (
      url = fmt.Sprintf("http://localhost:8080/book?id=%s", id)
   )

   req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   if err != nil {
      return nil, err
   }

   resp, err := c.httpClient.Do(req)
   if err != nil {
      return nil, err
   }
   defer resp.Body.Close()

   b, err := io.ReadAll(resp.Body)
   if err != nil {
      return nil, err
   }

   return b, nil
}

Next, we need to update our UseCase so that it starts using the interface from the bookserviceclient package.

package getbook

import (
   "context"
   "encoding/json"
  
   "example.com/books/internal/pkg/bookserviceclient"
)

type UseCase struct {
   bookClient bookserviceclient.Client
}

func NewUseCase(bookClient bookserviceclient.Client) *Usecase {
   return &UseCase{
      bookClient: bookClient,
   }
}

func (u *UseCase) GetBook(ctx context.Context, id string) (*Book, error) {
   var (
      book Book
   )

   b, err := u.bookClient.GetBook(ctx, id)
   if err != nil {
      return nil, err
   }

   if err := json.Unmarshal(b, &book); err != nil {
      return nil, err
   }

   book.Price = 10.12
   if book.Title == "Pride and Prejudice" {
      book.Price += 2
   }

   return &book, nil
}

It seems like things have improved significantly, and we've addressed the dependency issue of useсase on the low-level module. However, it's not quite there yet. Let's take it a step further. Right now, to declare dependencies, useсase is using an interface from the low-level module. Can we improve this? What if we declare the interfaces we need in pkg/getbook/types.go?

This way, we would remove explicit dependencies on low-level modules. That is, our high-level module would declare all the interfaces necessary for its operation, thus, removing all dependencies on low-level modules. At the top level of the application (main.go), we would then implement all the interfaces required for useсase to function.

Also, let's recall exported and unexported types in Go. Do we need to make useсase interfaces exported? These interfaces are only needed to specify the dependencies required by this package for its operation, so it's better not to export them.

Final Code

usecase.go

package getbook

import (
   "context"
   "encoding/json"
)

type UseCase struct {
   bookClient bookClient
}

func NewUseCase(bookClient bookClient) *UseCase {
   return &UseCase{
      bookClient: bookClient,
   }
}

func (u *UseCase) GetBook(ctx context.Context, id string) (*Book, error) {
   var (
      book Book
   )

   b, err := u.bookClient.GetBook(ctx, id)
   if err != nil {
      return nil, err
   }

   if err := json.Unmarshal(b, &book); err != nil {
      return nil, err
   }

   book.Price = 10.12
   if book.Title == "Pride and Prejudice" {
      book.Price += 2
   }

   return &book, nil
}

types.go

package getbook

import "context"

type bookClient interface {
   GetBook(ctx context.Context, id string) ([]byte, error)
}

type Book struct {
   ID     string  `json:"id"`
   Title  string  `json:"title"`
   Author string  `json:"author"`
   Price  float64 `json:"price"`
}

client.go

package bookserviceclient

import (
   "context"
   "fmt"
   "io"
   "net/http"
)

type Client struct {
   httpClient *http.Client
}

func NewClient() *Client {
   return &Client{
      httpClient: http.DefaultClient,
   }
}

func (c *Client) GetBook(ctx context.Context, id string) ([]byte, error) {
   var (
      url = fmt.Sprintf("http://localhost:8080/book?id=%s", id)
   )

   req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   if err != nil {
      return nil, err
   }

   resp, err := c.httpClient.Do(req)
   if err != nil {
      return nil, err
   }
   defer resp.Body.Close()

   b, err := io.ReadAll(resp.Body)
   if err != nil {
      return nil, err
   }

   return b, nil
}

main.go

package main

import (
   "log"
   "net/http"

   "example.com/books/internal/app/httpbookapi"
   "example.com/books/internal/pkg/bookserviceclient"
   "example.com/books/internal/pkg/getbook"
)

func main() {
   bookServiceClient := bookserviceclient.NewClient()
   useCase := getbook.NewUsecase(bookServiceClient)
   handler := httpbookapi.NewHandler(useCase)

   http.Handle("/book", handler)

   log.Print("server listening at 9090")
   log.Fatal(http.ListenAndServe(":9090", nil))
}

Summary

In this article, we've explored how to implement the Dependency inversion principle in Go. Implementing this principle can help prevent your code from becoming spaghetti and make it easier to maintain and read. Understanding the dependencies of your classes and how to declare them correctly can greatly simplify your life when further supporting your application.