Easy, fast and type-safe dependency injection for Go.
- Installation
- Building the Container
- Configuring Package
- Configuring Services
- Using Services
- Unit Testing
- Practical Examples
go get -u github.com/elliotchance/dingo
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: '"hi@welcome.com"'
CustomerWelcome:
type: '*CustomerWelcome'
returns: NewCustomerWelcome(@{SendEmail})
It will generate a file called dingo.go
. This must be committed with your
code.
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.
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 namedSendEmail
.${DB_PASS}
will inject the environment variableDB_PASS
.
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.
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.
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'
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 theio
package.
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: "hi@welcome.com"
maxRetries: 10
emailer: '@{Emailer}'
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.
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.
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()
}
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", "bob@smith.com")
// ...
}
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",
"bob@smith.com", "Welcome", "Hi, Bob!").Return(nil)
container := NewContainer()
container.SendEmail = emailer
welcomer := container.GetCustomerWelcome()
err := welcomer.Welcome("Bob", "bob@smith.com")
assert.NoError(t, err)
emailer.AssertExpectations(t)
}
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)
}
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, "https://accionvegana.org/accio/0ITbvNmLiVHa0l2Z6MHc0/foo")
return NewSigner(req)
}
client := container.GetHTTPSignerClient()
_, err := client.Do(http.NewRequest("GET", "https://accionvegana.org/accio/0ITbvNmLiVHa0l2Z6MHc0/foo", nil))
assert.NoError(t, err)
}