Dependency injection
This guide covers dependency injection and the container in Tinka. You will learn:
- How to register a service with a typed factory and resolve it
- The four kinds of binding and how long each value lives
- How to bind interfaces and choose an implementation
- How to check every binding before the App serves anything
- How to swap a service for a fake in tests
- How services are released when the App stops
Overview
Dependency injection means a piece of code receives what it depends on instead of building it. In Go you do this with constructor parameters: posts.NewService(db, logger) receives its database and its logger, so a test can pass others.
As an App grows, someone has to call all those constructors in the right order, decide which values are shared, and replace some of them in tests. The di package (github.com/mucolabs/tinka/di) is that someone: a container that knows how to build each service of the App, called a binding, and how long each built value lives.
A binding is a typed factory that asks for its own dependencies. There's no reflection and nothing to annotate: the compiler checks the wiring, and you can read it in the code.
Your first binding
Create the service
An
AvatarServicebuilds Gravatar URLs for users. It's plain Go: it knows nothing about the container.avatars.go package main import ( "crypto/md5" "encoding/hex" "strings" ) type AvatarService struct { Size string } func (s *AvatarService) URL(email string) string { sum := md5.Sum([]byte(strings.ToLower(email))) return "https://gravatar.com/avatar/" + hex.EncodeToString(sum[:]) + "?size=" + s.Size }Register it in the container
Register a factory for
*AvatarService. The factory's return type is the binding's key, andSingletonbuilds one value for the whole App.Validatebuilds every singleton now, so a mistake stops the App at boot, andMustMakereturns the service:main.go package main import ( "fmt" "log" "github.com/mucolabs/tinka/di" ) func main() { c := di.New() c.Singleton(func(r *di.Resolver) (*AvatarService, error) { return &AvatarService{Size: "200"}, nil }) if err := c.Validate(); err != nil { log.Fatal(err) } avatars := c.MustMake[*AvatarService]() fmt.Println(avatars.URL("ada@example.com")) }Use it in a handler
Resolve services once, at boot, and capture them in your handlers, so a request pays nothing for the container:
main.go avatars := c.MustMake[*AvatarService]() app := tinka.New(tinka.HTTPConfig{}) app.Get("/avatar", func(c *tinka.Ctx) (map[string]string, error) { return map[string]string{"url": avatars.URL(c.Query("email"))}, nil })
Depending on other bindings
A factory receives a *di.Resolver and asks it for what the service needs. MustMake keeps the factory short: when a dependency is missing, its panic becomes the factory's error, so it reaches Validate or Make as an ordinary error:
c.Value(dbConfig)
c.Singleton(func(r *di.Resolver) (*sql.DB, error) {
return sql.Open("pgx", r.MustMake[DatabaseConfig]().URL)
})
c.Singleton(func(r *di.Resolver) (*posts.Service, error) {
return posts.NewService(r.MustMake[*sql.DB](), r.MustMake[*slog.Logger]()), nil
})Use Make, which returns an error, when the factory wants to handle a missing dependency itself.
What can be bound
Any Go type can be a binding: a pointer to a struct, a struct, an interface or a configuration value. The key is the exact type, so *DB and DB are two bindings, and a factory that returns Cache, an interface, registers Cache. Because the type is the name, Tinka has no string aliases: there's nothing to misspell.
Bindings
The kind of binding decides how long a built value lives.
Registering the same type twice panics when the App boots, because the second registration would silently replace the first. Tests replace bindings with Swap.
Dependency injection during requests
A *di.Resolver makes values for one request. NewResolver creates one with its own values and its own scoped values, and Value gives it something that exists only for that request, such as the signed-in user. A Scoped binding is built once per resolver and can use those values:
c.Scoped(func(r *di.Resolver) (*Cart, error) {
return carts.For(r.MustMake[User]()), nil
})
res := c.NewResolver()
res.Value(user)
cart := res.MustMake[*Cart]() // built for this request, then reused within itA singleton can't depend on a scoped binding or on a request's value: it would keep the first request's value forever and serve it to every other user. Making one returns di.ErrScoped, and Validate reports it at boot.
The App creates these resolvers for you. Give it the container in HTTPConfig, and c.Make resolves through a resolver made for the request on first use. That resolver holds the request's *tinka.Ctx, so a scoped binding can read the request:
c.Scoped(func(r *di.Resolver) (*Cart, error) {
ctx := r.MustMake[*tinka.Ctx]()
return carts.For(ctx.Query("session")), nil
})
app := tinka.New(tinka.HTTPConfig{Container: c})
app.Get("/cart", func(ctx *tinka.Ctx) (*Cart, error) {
return ctx.Make[*Cart]()
})A request that never calls Make pays nothing for the container.
Interfaces as contracts
A service that depends on an interface works with any implementation, and the binding decides which one. Here business code depends on Payments, and one line in the wiring picks Stripe:
type Payments interface {
Charge(ctx context.Context, cents int64) error
}
c.Singleton(func(r *di.Resolver) (Payments, error) {
return stripe.New(r.MustMake[StripeConfig]()), nil
})To switch to another provider, change the factory. Code that calls MustMake[Payments]() doesn't change.
Choosing an implementation
A factory is Go code, so it chooses an implementation with an if:
c.Singleton(func(r *di.Resolver) (Search, error) {
if r.MustMake[SearchConfig]().Driver == "typesense" {
return typesense.New(), nil
}
return postgres.NewSearch(r.MustMake[*sql.DB]()), nil
})When two services need different implementations of one interface, give each its own factory and pass the implementation it needs to its constructor.
Resolution priority
Make looks in this order and uses the first match:
| Priority | Source | Registered with |
|---|---|---|
| 1 | Swaps | c.Swap in tests |
| 2 | Resolver values | res.Value during a request |
| 3 | The type's binding | Bind, Singleton, Scoped or Value |
A type with none of these fails with di.ErrNotRegistered, and an error message that names the chain of types being built:
di: cannot make *posts.Service: *sql.DB is not registered (*posts.Service → *sql.DB)Validating bindings at boot
Validate builds every singleton and returns every problem at once: a missing binding, a dependency cycle, a singleton that depends on request data, or a factory's own error. Call it after registering and before serving, so a misconfigured App stops at boot instead of failing on its first request. Bind and Scoped factories run only when made, because they may need a request's values.
Swapping dependencies in tests
Swap replaces a binding with a fake, everywhere and in every resolver, until Restore. Register the restore with t.Cleanup so it runs even when the test fails:
func TestCheckout(t *testing.T) {
c := newContainer()
c.Swap(func(*di.Resolver) (Payments, error) {
return &fakePayments{}, nil
})
t.Cleanup(func() { c.Restore[Payments]() })
// Code that makes Payments now receives the fake.
}A swap builds a new value on every Make.
Reacting to resolved bindings
OnResolve runs a function on every value the container builds of a type: once for a singleton, once per resolver for a scoped binding, and every time for Bind. Use it to configure what a package builds, or to log it. An error from the function fails the Make:
c.OnResolve(func(db *sql.DB, _ *di.Resolver) error {
db.SetMaxOpenConns(20)
return nil
})Shutting down
Shutdown releases the singletons the container built, in the reverse of the order they were built, so a service shuts down before what it depends on. It calls Shutdown(ctx) on a di.Shutdowner and Close on an io.Closer, such as *sql.DB:
if err := c.Shutdown(ctx); err != nil {
logger.Error("shutdown failed", "err", err)
}See also: HTTP context, Configuration & Environment, Logger, Routing