Skip to content

๐Ÿบ Easy, fast and type-safe dependency injection for Go.

License

Notifications You must be signed in to change notification settings

elliotchance/dingo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

11 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

๐Ÿบ dingo

Easy, fast and type-safe dependency injection for Go.

Installation

go get -u github.com/elliotchance/dingo

Building the Container

Building or rebuilding the container is done with:

dingo

The container is created from a file called dingo.yml in the same directory as where the dingo command is run. This should be the root of your module/repository.

Here is an example of a dingo.yml:

services:
  SendEmail:
    type: '*SendEmail'
    interface: EmailSender
    properties:
      From: '"[email protected]"'

  CustomerWelcome:
    type: '*CustomerWelcome'
    returns: NewCustomerWelcome(@{SendEmail})

It will generate a file called dingo.go. This must be committed with your code.

Configuring Package

The root level package key describes the package name.

Default is the directory name is not enough because it may contain a command (package main). Find the first non-test file to get the real package name.

Configuring Services

The root level services key describes each of the services.

The name of the service follows the same naming conventions as Go, so service names that start with a capital letter will be exported (available outside this package).

All options described below are optional. However, you must provide either type or interface.

Any option below that expects an expression can contain any valid Go code. References to other services and variables will be substituted automatically:

  • @{SendEmail} will inject the service named SendEmail.
  • ${DB_PASS} will inject the environment variable DB_PASS.

arguments

If arguments is provided the service will be turned into a func so it can be used as a factory.

There is a full example in Mocking Runtime Dependencies.

error

If returns provides two arguments (where the second one is the error) you must include an error. This is the expression when err != nil.

Examples:

  • error: panic(err) - panic if an error occurs.
  • error: return nil - return a nil service if an error occurs.

import

You can provide explicit imports if you need to reference packages in expressions (such as returns) that do not exist in type or interface.

If a package listed in import is already imported, either directly or indirectly, it value will be ignored.

Example:

import:
  - 'github.com/aws/aws-sdk-go/aws/session'

interface

If you need to replace this service with another struct type in unit tests you will need to provide an interface. This will override type and must be compatible with returned type of returns.

Examples:

  • interface: EmailSender - EmailSender in this package.
  • interface: io.Writer - Writer in the io package.

properties

If provided, a map of case-sensitive properties to be set on the instance. Each of the properties is a Go expression.

Example:

properties:
  From: "[email protected]"
  maxRetries: 10
  emailer: '@{Emailer}'

returns

The expression used to instantiate the service. You can provide any Go expression here, including referencing other services and environment variables.

The returns can also return a function, since it is an expression. See type for an example.

scope

The scope defines when a service should be created, or when it can be reused. It must be one of the following values:

  • prototype: A new instance will be created whenever the service is requested or injected into another service as a dependency.

  • container (default): The instance will created once for this container, and then it will be returned in future requests. This is sometimes called a singleton, however the service will not be shared outside of the container.

type

The type returned by the return expression. You must provide a fully qualified name that includes the package name if the type does not belong to this package.

Example

type: '*github.com/go-redis/redis.Options'

The type may also be a function. Functions can refer to other services in the same embedded way:

type: func () bool
returns: |
  func () bool {
    return @{Something}.IsReady()
  }

Using Services

As part of the generated file, dingo.go. There will be a module-level variable called DefaultContainer. This requires no initialization and can be used immediately:

func main() {
	welcomer := DefaultContainer.GetCustomerWelcome()
	err := welcomer.Welcome("Bob", "[email protected]")
	// ...
}

Unit Testing

When unit testing you should not use the global DefaultContainer. You should create a new container:

container := NewContainer()

Unit tests can make any modifications to the new container, including overriding services to provide mocks or other stubs:

func TestCustomerWelcome_Welcome(t *testing.T) {
	emailer := FakeEmailSender{}
	emailer.On("Send",
		"[email protected]", "Welcome", "Hi, Bob!").Return(nil)
    
	container := NewContainer()
	container.SendEmail = emailer
    
	welcomer := container.GetCustomerWelcome()
	err := welcomer.Welcome("Bob", "[email protected]")
	assert.NoError(t, err)
	emailer.AssertExpectations(t)
}

Practical Examples

Mocking the Clock

Code that relies on time needs to be deterministic to be testable. Extracting the clock as a service allows the whole time environment to be predictable for all services. It also has the added benefit that Sleep() is free when running unit tests.

Here is a service, WhatsTheTime, that needs to use the current time:

services:
  Clock:
    interface: github.com/jonboulle/clockwork.Clock
    returns: clockwork.NewRealClock()

  WhatsTheTime:
    type: '*WhatsTheTime'
    properties:
      clock: '@{Clock}'

WhatsTheTime can now use this clock the same way you would use the time package:

import (
	"github.com/jonboulle/clockwork"
	"time"
)

type WhatsTheTime struct {
	clock clockwork.Clock
}

func (t *WhatsTheTime) InRFC1123() string {
	return t.clock.Now().Format(time.RFC1123)
}

The unit test can substitute a fake clock for all services:

func TestWhatsTheTime_InRFC1123(t *testing.T) {
	container := NewContainer()
	container.Clock = clockwork.NewFakeClock()

	actual := container.GetWhatsTheTime().InRFC1123()
	assert.Equal(t, "Wed, 04 Apr 1984 00:00:00 UTC", actual)
}

Mocking Runtime Dependencies

One situation that is tricky to write tests for is when you have the instantiation inside a service because it needs some runtime state.

Let's say you have a HTTP client that signs a request before sending it. The signer can only be instantiated with the request, so we can't use traditional injection:

type HTTPSignerClient struct{}

func (c *HTTPSignerClient) Do(req *http.Request) (*http.Response, error) {
	signer := NewSigner(req)
	req.Headers.Set("Authorization", signer.Auth())

	return http.DefaultClient.Do(req)
}

The Signer is not deterministic because it relies on the time:

type Signer struct {
	req *http.Request
}

func NewSigner(req *http.Request) *Signer {
	return &Signer{req: req}
}

// Produces something like "Mon Jan 2 15:04:05 2006 POST"
func (signer *Signer) Auth() string {
	return time.Now().Format(time.ANSIC) + " " + signer.req.Method
}

Unlike mocking the clock (as in the previous tutorial) this time we need to keep the logic of the signer, but verify the URL path sent to the signer. Of course, we could manipulate or entirely replace the signer as well.

Services can have arguments which turns them into factories. For example:

services:
  Signer:
    type: '*Signer'
    scope: prototype        # Create a new Signer each time
    arguments:              # Define the dependencies at runtime.
      req: '*http.Request'
    returns: NewSigner(req) # Setup code can reference the runtime dependencies.

  HTTPSignerClient:
  	type: '*HTTPSignerClient'
  	properties:
  	  CreateSigner: '@{Signer}' # Looks like a regular service, right?

Dingo has transformed the service into a factory, using a function:

type HTTPSignerClient struct {
	CreateSigner func(req *http.Request) *Signer
}

func (c *HTTPSignerClient) Do(req *http.Request) (*http.Response, error) {
	signer := c.CreateSigner(req)
	req.Headers.Set("Authorization", signer.Auth())

	return http.DefaultClient.Do(req)
}

Under test we can control this factory like any other service:

func TestHTTPSignerClient_Do(t *testing.T) {
	container := NewContainer()
	container.Signer = func(req *http.Request) *Signer {
		assert.Equals(t, req.URL.Path, "/foo")

		return NewSigner(req)
	}

	client := container.GetHTTPSignerClient()
	_, err := client.Do(http.NewRequest("GET", "/foo", nil))
	assert.NoError(t, err)
}