The most popular explanation of dependency injection

This article explains what dependency injection (also known as inversion of control) is and how it improves the code that defines business logic.

Services and dependencies

Services can be classes you write, or classes from import libraries. For example, it could be a logger or a database connection. Therefore, you can write a service that can run independently without any external help, but you may soon reach the point where one service will have to use the code of another service.

Let's look at a small example.

We will create an EmailSender. This class will be used to send e-mail. It must write the information of the e-mail sent in the database and record the possible errors.

EmailSender will rely on three other services: SmtpClient for sending e-mail, EmailRepository for interacting with databases, and Logger for recording errors.

How do we usually achieve this?

1. The three dependencies of EmailSender are declared as attributes. The idea of dependency injection is that EmailSender shouldn't be responsible for creating its dependencies, they should be injected from outside. For EmailSender, the configuration details should be unknown.

interface SmtpClientInterface {
    send(toName: string, toEmail: string, subject: string, message: string)
}

interface EmailRepositoryInterface {
    insertEmail(address: string, email: Email, status: string)
    updateEmailStatus(id: number, status: string)
}

interface LoggerInterface {
    error(message: string)
}

class EmailSender {
    client: SmtpClientInterface
    repo: EmailRepositoryInterface
    logger: LoggerInterface
    
    send(user: User, email: Email) {
        try {
            this.repo.insertEmail(user.email, email, "sending")
            this.client.send(user.email, user.name, email.subject, email.message)
            this.repo.updateEmailStatus(email.id, "sent")
        } catch(e) {
            this.logger.error(e.toString())
        }
    }
}

2. With setter, you can add setSmtpClient (), setEmailRepository (), and setLogger () methods to the EmailSender.

// inject dependencies with setters
sender = new EmailSender()
sender.setSmtpClient(client)
sender.setEmailRepository(repo)
sender.setLogger(logger)

3. Set dependencies in constructors. It ensures that once an object is created, it works as expected and does not forget any dependencies.

class EmailSender {
    // ... dependencies and other attributes ...
    
    constructor(client: SmtpClientInterface, repo: EmailRepositoryInterface, logger: LoggerInterface) {
        this.client = client
        this.repo = repo
        this.logger = logger
    }
    
    // ... methods ...
}

Dependency Injection and Decoupling

The key concept of dependency injection is decoupling, which should be injected from outside. Services should not bind their dependencies to specific implementations, they will lose reusability. Therefore, this will make them more difficult to maintain and test.

For example, if dependencies are written to constructors like this.

constructor() {
    this.client = new SmtpClient()
}

It's not good. Your service will only use a specific Smtp client, and its configuration can't be changed (once you change a Smtp client, you need to modify the business code, which is not good).

What should be done:

constructor(client: SmtpClientInterface) {
    this.client = client
}

In this way, you are free to use the implementation you want.

Nevertheless, the constructor's parameters do not necessarily have to be interfaces:

constructor(client: SmtpClient) {
    this.smtp = smtp
}

Normally this approach is enough and the interfaces are great, but they make your code hard to read. If you want to avoid over-designed code, it might be a good way to start with classes. Then replace it with interfaces if necessary.

summary

The main advantage of dependency injection is decoupling. It can greatly improve code reusability and testability. The disadvantage is that the creation of services will be far away from your business logic, which will make your code more difficult to understand.

How to Write REST API in Go with DI

To further illustrate the advantages of DI, we will use DI to write the REST API in Go.

Now suppose we want to develop a car management project, we need to provide several API s to add, delete and check (CRUD) operations on cars.

API description

The function of the api is to manage the list of cars. The api implements the following basic CRUD operations:

The request and response body is coded with json. The api handles the following error codes:

  • 400 - Bad Request : the parameters of the request are not valid
  • 404 - Not Found : the car does not exist
  • 500 - Internal Error : an unexpected error occurred (eg: the database connection failed)

Project structure

The project structure is very simple:

├─ app
│   ├─ handlers
│   ├─ middlewares
│   └─ models
│       ├─ garage
│       └─ helpers
│
├─ config
│   ├─ logging
│   └─ services
│
└─ main.go 
  • The main.go file is the entry point for the application. Its purpose is to create a Web server that can handle api routing.
  • app/handler and app/middlewares, as their names say, define the location of the application's handlers and middleware. They represent the controller part of the MVC application, and that's all.
  • app/models/garage contains business logic. In other words, it defines what cars are and how to manage them.
  • app/models/helpers are composed of functions that assist the handler. ReadJSONBody function can decode the body of HTTP request, while JSONResponse function can write json response. The package also includes two error types: ErrValidation and ErrNotFound. They are used to facilitate error handling in http handlers.
  • In the config/logging directory, logger is defined as a global variable. A recorder is a special object. That's because you need to install a recorder in your application as soon as possible. And you want to keep it until the application stops.
  • In config/services, you can find service definitions that depend on injection containers. They describe how services are created and how they should be shut down.

Model

We first define the data structure of car in the model.

// Car is the structure representing a car.
type Car struct {
    ID    string `json:"id" bson:"_id"`
    Brand string `json:"brand" bson:"brand"`
    Color string `json:"color" bson:"color"`
}

It represents a very simple car with only two fields, a brand and a color. Car is a structure stored in a database. This structure is also used in requests and responses.

The structure of CarManager and CRUD operation of business logic layer.

type CarManager struct {
    Repo   *CarRepository
    Logger *zap.Logger
}

// GetAll returns the list of cars.
func (m *CarManager) GetAll() ([]*Car, error) {
    cars, err := m.Repo.FindAll()

    if cars == nil {
        cars = []*Car{}
    }

    if err != nil {
        m.Logger.Error(err.Error())
    }

    return cars, err
}

// Get returns the car with the given id.
// If the car does not exist an helpers.ErrNotFound is returned.
func (m *CarManager) Get(id string) (*Car, error) {
    car, err := m.Repo.FindByID(id)

    if m.Repo.IsNotFoundErr(err) {
        return nil, helpers.NewErrNotFound("Car `" + id + "` does not exist.")
    }

    if err != nil {
        m.Logger.Error(err.Error())
    }

    return car, err
}

// Create inserts the given car in the database.
// It returns the inserted car.
func (m *CarManager) Create(car *Car) (*Car, error) {
    if err := ValidateCar(car); err != nil {
        return nil, err
    }

    car.ID = bson.NewObjectId().Hex()

    err := m.Repo.Insert(car)

    if m.Repo.IsAlreadyExistErr(err) {
        return m.Create(car)
    }

    if err != nil {
        m.Logger.Error(err.Error())
        return nil, err
    }

    return car, nil
}

// Update updates the car with the given id.
// It uses the values contained in the given car fields.
// It returns the updated car.
func (m *CarManager) Update(id string, car *Car) (*Car, error) {
    if err := ValidateCar(car); err != nil {
        return nil, err
    }

    car.ID = id

    err := m.Repo.Update(car)

    if m.Repo.IsNotFoundErr(err) {
        return nil, helpers.NewErrNotFound("Car `" + id + "` does not exist.")
    }

    if err != nil {
        m.Logger.Error(err.Error())
        return nil, err
    }

    return car, err
}

// Delete removes the car with the given id.
func (m *CarManager) Delete(id string) error {
    err := m.Repo.Delete(id)

    if m.Repo.IsNotFoundErr(err) {
        return nil
    }

    if err != nil {
        m.Logger.Error(err.Error())
    }

    return err
}

CarManager is a data structure used by handlers to handle CRUD operations. Each method corresponds to an http handle. CarManager needs a CarRepository to execute mongo queries.

Structure of CarRepository and Operation of Specific DB Layer

package garage

import mgo "gopkg.in/mgo.v2"

// CarRepository contains all the interactions
// with the car collection stored in mongo.
type CarRepository struct {
    Session *mgo.Session
}

// collection returns the car collection.
func (repo *CarRepository) collection() *mgo.Collection {
    return repo.Session.DB("dingo_car_api").C("cars")
}

// FindAll returns all the cars stored in the database.
func (repo *CarRepository) FindAll() ([]*Car, error) {
    var cars []*Car
    err := repo.collection().Find(nil).All(&cars)
    return cars, err
}

// FindByID retrieves the car with the given id from the database.
func (repo *CarRepository) FindByID(id string) (*Car, error) {
    var car *Car
    err := repo.collection().FindId(id).One(&car)
    return car, err
}

// Insert inserts a car in the database.
func (repo *CarRepository) Insert(car *Car) error {
    return repo.collection().Insert(car)
}

// Update updates all the caracteristics of a car.
func (repo *CarRepository) Update(car *Car) error {
    return repo.collection().UpdateId(car.ID, car)
}

// Delete removes the car with the given id.
func (repo *CarRepository) Delete(id string) error {
    return repo.collection().RemoveId(id)
}

// IsNotFoundErr returns true if the error concerns a not found document.
func (repo *CarRepository) IsNotFoundErr(err error) bool {
    return err == mgo.ErrNotFound
}

// IsAlreadyExistErr returns true if the error is related
// to the insertion of an already existing document.
func (repo *CarRepository) IsAlreadyExistErr(err error) bool {
    return mgo.IsDup(err)
}

CarRepository is just a wrapper for mongo queries. Car Repository here can be specific Car mongo Repository or CarPsg Repository, etc.

Separating database queries in Repository makes it easy to list all interactions with the database. In this case, it is easy to replace the database. For example, you can use postgres instead of mongo to create another repository. It also gives you the opportunity to create a mock repository for testing.

Service Dependency Configuration

The following configures dependencies for each service, such as car-manager dependencies on car-repository and logger

The logger is in the App scope. It means it is only created once for the whole application. The Build function is called the first time to retrieve the service. After that, the same object is returned when the service is requested again.

logger is within the scope of App. This means that it is created only once for the entire application. The Build function is called for the first time to retrieve the service. Later, when the service is requested again, the same object is returned.

The statement is as follows:

var Services = []di.Def{
    {
        Name:  "logger",
        Scope: di.App,
        Build: func(ctn di.Container) (interface{}, error) {
            return logging.Logger, nil
        },
    },
    // other services
}

Now we need a mongo connection. The first thing we need is connection pooling. Each http request then uses the pool to retrieve its own connection.

Therefore, we will create two services. mongo-pool in App range and mongo in Request range:

 {
        Name:  "mongo-pool",
        Scope: di.App,
        Build: func(ctn di.Container) (interface{}, error) {
            // create a *mgo.Session
            return mgo.DialWithTimeout(os.Getenv("MONGO_URL"), 5*time.Second)
        },
        Close: func(obj interface{}) error {
            // close the *mgo.Session, it should be cast first
            obj.(*mgo.Session).Close()
            return nil
        },
    },
    {
        Name:  "mongo",
        Scope: di.Request,
        Build: func(ctn di.Container) (interface{}, error) {
            // get the pool of connections (*mgo.Session) from the container
            // and retrieve a connection thanks to the Copy method
            return ctn.Get("mongo-pool").(*mgo.Session).Copy(), nil
        },
        Close: func(obj interface{}) error {
            // close the *mgo.Session, it should be cast first
            obj.(*mgo.Session).Close()
            return nil
        },
    },

The Mongo service is created in each request, and it uses the mongo-pool service to get the database connection. The Mongo service can use the mongo-pool service in the Build function thanks to the container's Get method.

Note that closing the mongo connection is also important in both cases. This can be done using the defined "close" field. The Close function is called when the container is deleted. It occurs at the end of each http request for the request container and when the program for the App container stops.

Next comes Car Repository. This depends on the mongo service. Because mongo connections are within Request range, CarRepository cannot be within App range. It should also be within the Request range.

 {
        Name:  "car-repository",
        Scope: di.Request,
        Build: func(ctn di.Container) (interface{}, error) {
            return &garage.CarRepository{
                Session: ctn.Get("mongo").(*mgo.Session),
            }, nil
        },
    },

Finally, we can write CarManager definitions. Like CarRepository, CarManager should be within Request because of its dependency.

{
        Name:  "car-manager",
        Scope: di.Request,
        Build: func(ctn di.Container) (interface{}, error) {
            return &garage.CarManager{
                Repo:   ctn.Get("car-repository").(*garage.CarRepository),
                Logger: ctn.Get("logger").(*zap.Logger),
            }, nil
        },
    },

Based on these definitions, dependency injection containers can be created in the main.go file.

Handlers

The role of http handlers is simple. It must parse incoming requests, retrieve and invoke appropriate services, and write formatted responses. All processing procedures are roughly the same. For example, GetCarHandler looks like this:

func GetCarHandler(w http.ResponseWriter, r *http.Request) {
    id := mux.Vars(r)["carId"]

    car, err := di.Get(r, "car-manager").(*garage.CarManager).Get(id)

    if err == nil {
        helpers.JSONResponse(w, 200, car)
        return
    }

    switch e := err.(type) {
    case *helpers.ErrNotFound:
        helpers.JSONResponse(w, 404, map[string]interface{}{
            "error": e.Error(),
        })
    default:
        helpers.JSONResponse(w, 500, map[string]interface{}{
            "error": "Internal Error",
        })
    }
}

mux.Vars is just a way to retrieve carId parameters from URL s using the gorilla/mux routing library.

mux.Vars just uses gorilla / mux (the routing library for the project) to retrieve the card parameter from the URL.

The interesting part of the handler is how to retrieve the CarManager from the dependency injection container. This is done through di.Get (r,'car-manager'). To this end, containers should be included in http.Request. You must implement it using middleware.

Middlewares

The api uses two middleware.

The first is Panic Recovery Middleware. It is used to recover and record errors from possible emergencies in the handler. This is very important because if CarManager cannot be retrieved from the container, di.Get (r, "car-manager") may panic.

// PanicRecoveryMiddleware handles the panic in the handlers.
func PanicRecoveryMiddleware(h http.HandlerFunc, logger *zap.Logger) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                // log the error
                logger.Error(fmt.Sprint(rec))

                // write the error response
                helpers.JSONResponse(w, 500, map[string]interface{}{
                    "error": "Internal Error",
                })
            }
        }()

        h(w, r)
    }
}

The second middleware allows di. Get (r,'car-manager'). (* garage. CarManager) to work by injecting di.Container into http.Request.

package di

import (
    "context"
    "net/http"
)

// ContainerKey is a type that can be used to store a container
// in the context.Context of an http.Request.
// By default, it is used in the C function and the HTTPMiddleware.
type ContainerKey string

// HTTPMiddleware adds a container in the request context.
//
// The container injected in each request, is a new sub-container
// of the app container given as parameter.
//
// It can panic, so it should be used with another middleware
// to recover from the panic, and to log the error.
//
// It uses logFunc, a function that can log an error.
// logFunc is used to log the errors during the container deletion.
func HTTPMiddleware(h http.HandlerFunc, app Container, logFunc func(msg string)) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // create a request container from tha app container
        ctn, err := app.SubContainer()
        if err != nil {
            panic(err)
        }
        defer func() {
            if err := ctn.Delete(); err != nil && logFunc != nil {
                logFunc(err.Error())
            }
        }()

        // call the handler with a new request
        // containing the container in its context
        h(w, r.WithContext(
            context.WithValue(r.Context(), ContainerKey("di"), ctn),
        ))
    }
}

// C retrieves a Container from an interface.
// The function panics if the Container can not be retrieved.
//
// The interface can be :
// - a Container
// - an *http.Request containing a Container in its context.Context
//   for the ContainerKey("di") key.
//
// The function can be changed to match the needs of your application.
var C = func(i interface{}) Container {
    if c, ok := i.(Container); ok {
        return c
    }

    r, ok := i.(*http.Request)
    if !ok {
        panic("could not get the container with C()")
    }

    c, ok := r.Context().Value(ContainerKey("di")).(Container)
    if !ok {
        panic("could not get the container from the given *http.Request")
    }

    return c
}

// Get is a shortcut for C(i).Get(name).
func Get(i interface{}, name string) interface{} {
    return C(i).Get(name)
}

For each http request. Subcontainers for a given application container are created. It is injected into context.Context of http.Request, so it can be retrieved using di.Get. Subcontainers are deleted at the end of each request. The logFunc function is used to record errors that may occur during the deletion of subcontainers.

Main

The main.go file is the entry point for the application.

First, make sure that the logger can write anything before the end of the program.

defer logging.Logger.Sync()

Dependency injection containers can then be created:

// create a builder
builder, err := di.NewBuilder()
if err != nil {
    logging.Logger.Fatal(err.Error())
}

// add the service definitions
err = builder.Add(services.Services...)
if err != nil {
    logging.Logger.Fatal(err.Error())
}

// create the app container, delete it just before the program stops
app := builder.Build()
defer app.Delete()

The last interesting thing is this part:

m := func(h http.HandlerFunc) http.HandlerFunc {
    return middlewares.PanicRecoveryMiddleware(
        di.HTTPMiddleware(h, app, func(msg string) {
            logging.Logger.Error(msg)
        }),
        logging.Logger,
    )
}

m function combines two middleware. It can be used to apply middleware to processing programs.

The rest of the main file is just the configuration of gorilla mux router (multiplexer router) and the creation of Web servers.

The complete code for Main.go is shown below.

package main

import (
    "context"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gorilla/mux"
    "github.com/sarulabs/di"
    "github.com/sarulabs/di-example/app/handlers"
    "github.com/sarulabs/di-example/app/middlewares"
    "github.com/sarulabs/di-example/config/logging"
    "github.com/sarulabs/di-example/config/services"
)

func main() {
    // Use a single logger in the whole application.
    // Need to close it at the end.
    defer logging.Logger.Sync()

    // Create the app container.
    // Do not forget to delete it at the end.
    builder, err := di.NewBuilder()
    if err != nil {
        logging.Logger.Fatal(err.Error())
    }

    err = builder.Add(services.Services...)
    if err != nil {
        logging.Logger.Fatal(err.Error())
    }

    app := builder.Build()
    defer app.Delete()

    // Create the http server.
    r := mux.NewRouter()

    // Function to apply the middlewares:
    // - recover from panic
    // - add the container in the http requests
    m := func(h http.HandlerFunc) http.HandlerFunc {
        return middlewares.PanicRecoveryMiddleware(
            di.HTTPMiddleware(h, app, func(msg string) {
                logging.Logger.Error(msg)
            }),
            logging.Logger,
        )
    }

    r.HandleFunc("/cars", m(handlers.GetCarListHandler)).Methods("GET")
    r.HandleFunc("/cars", m(handlers.PostCarHandler)).Methods("POST")
    r.HandleFunc("/cars/{carId}", m(handlers.GetCarHandler)).Methods("GET")
    r.HandleFunc("/cars/{carId}", m(handlers.PutCarHandler)).Methods("PUT")
    r.HandleFunc("/cars/{carId}", m(handlers.DeleteCarHandler)).Methods("DELETE")

    srv := &http.Server{
        Handler:      r,
        Addr:         "0.0.0.0:" + os.Getenv("SERVER_PORT"),
        WriteTimeout: 15 * time.Second,
        ReadTimeout:  15 * time.Second,
    }

    logging.Logger.Info("Listening on port " + os.Getenv("SERVER_PORT"))

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            logging.Logger.Error(err.Error())
        }
    }()

    // graceful shutdown
    stop := make(chan os.Signal, 1)

    signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

    <-stop

    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    logging.Logger.Info("Stopping the http server")

    if err := srv.Shutdown(ctx); err != nil {
        logging.Logger.Error(err.Error())
    }
}

Conclusion

As can be seen from the example above, the business layer code and its dependencies are decoupled. If you want to change the dependencies, you need only modify the dependency configuration file of the service instead of changing the code of the business layer.

Dependency injection will help make the project easier to maintain. Using the sarulabs/di framework allows you to separate service definitions from business logic. The declaration takes place in a single place, which is part of the application configuration. These services can be acquired in handles by using container storage in requests.

Reference resources

How to write a REST API in Go with DI
About design patterns: What are the disadvantages of using dependency injection?

Tags: Go Database Session github JSON

Posted on Sat, 05 Oct 2019 22:40:31 -0700 by coditoergosum