Chapter 8

Dependency Injection

Register constructors at startup and the container resolves them into handlers and controllers. Interface binding, request scope and shutdown closers.

Iris ships a small dependency injection container. You register values and constructor functions once, at startup; the container calls your constructors with matching arguments and hands the finished objects to your controllers, handlers and middleware. There is no code generation and no service locator scattered through request code: reflection runs once while the application wires itself, and everything that can go wrong goes wrong before the first request. This chapter explains how registration and resolution work, how controllers and handlers receive their dependencies, and how the same mechanism turns testing into a one-line swap.

Background

What a Dependency Is

Almost no piece of code works alone. A function that saves a user needs something that talks to storage; a handler that sends a welcome message needs a mail client; a service that checks permissions needs the permission rules. Each of those “somethings” is a dependency: a value your code needs to do its job but did not create itself.

The Hard-Wiring Problem

The tempting shortcut is to let each piece build or grab what it needs: a package-level var db *sql.DB that everything reads, or a constructor that opens its own database connection. This is hard-wiring, and it costs you twice. First, the code becomes untestable: you cannot exercise UserService logic without a real database on the network, because the service insists on connecting to one. Second, the code becomes rigid: swapping storage, pointing staging at different infrastructure, or running two instances with different configuration all mean editing the code instead of changing what you pass in. Global variables add a third cost: any function anywhere may read or mutate them, so a function’s signature no longer tells you what it actually depends on.

Inversion of Control, Without the Jargon

The fix is to turn the direction around: instead of code reaching out for what it needs, the needs are handed in from outside. That is all “inversion of control” means. Its most direct form is constructor injection: a plain function such as NewUserService(repo UserRepository) lists its needs as parameters and returns the assembled value. The caller decides what to pass. There are no annotations and no framework requirement, just function parameters doing what they always do.

What a container adds is bookkeeping. In a real application, building the object graph by hand means calling dozens of New functions in the right order and threading the results between them; every new parameter ripples through main. The Iris container does that threading for you: you list values and constructors once, and it matches each parameter to a previously registered result. The problem that disappears from your code is the hand-maintained wiring, not the constructors themselves.

Interfaces: the Swap Point

An interface in Go is a named list of methods. Any type that has those methods satisfies the interface automatically. There is no implements keyword and no registration step. When a constructor asks for an interface instead of a concrete type, it declares “I need something that can List” without caring what that something is. That is the swap point this chapter leans on: main passes the database-backed implementation, a test passes a ten-line in-memory fake, and the code in between cannot tell the difference.

One more term used below: reflection is Go’s ability to inspect types and call functions at run time, through the standard reflect package. The container uses it once, at startup, to read your constructors’ parameter lists and match them to registrations; no reflection runs while requests are being served.

Why Constructor Injection

A constructor that declares its dependencies as parameters is the seam of your application. Whoever calls the constructor decides what “storage” or “mailer” means in that context: main passes the production implementation, a test passes a fake, and the type in the middle cannot tell the difference.

Go
// Hidden dependency: the service builds its own storage.
// Tests cannot replace it and every caller pays for the real thing.
func NewUserService() *UserService {
    return &UserService{repo: connectToPostgres()}
}

// Constructor injection: the caller decides what storage means.
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

Constructor injection works without any framework. What the Iris container adds is the plumbing: it calls your constructors in the right order with the right arguments, so main does not degenerate into a hand-written pyramid of New calls that changes every time a signature grows a parameter.

A Complete Wired Application

The recommended v14 stack is iris.NewBuilder with Services for the dependencies and NewXxxAPI constructors for the controllers. The following program is complete; the rest of the chapter takes it apart.

Go
package main

import (
    stdContext "context"
    "sync"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/rest/errors"
)

// User is the resource served by this chapter's examples.
type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

// UserRepository is the seam between logic and storage. Constructors
// depend on this interface; main and the tests decide what implements it.
type UserRepository interface {
    List(ctx stdContext.Context) ([]User, error)
}

// MemUsers is an in-memory UserRepository: real storage for a small
// tool, the base fake for tests.
type MemUsers struct {
    mu    sync.RWMutex
    users []User
}

var _ UserRepository = (*MemUsers)(nil)

func NewMemUsers() *MemUsers {
    return &MemUsers{
        users: []User{
            {ID: "1", Name: "Ada"},
            {ID: "2", Name: "Linus"},
        },
    }
}

func (m *MemUsers) List(_ stdContext.Context) ([]User, error) {
    m.mu.RLock()
    defer m.mu.RUnlock()

    list := make([]User, len(m.users))
    copy(list, m.users)
    return list, nil
}

// UserService holds the business rules. It receives its repository
// through the constructor; it never builds one.
type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) List(ctx stdContext.Context) ([]User, error) {
    return s.repo.List(ctx)
}

// UsersAPI is the HTTP layer: a constructor that declares its
// dependencies and a Routing method that declares its routes.
type UsersAPI struct {
    svc *UserService
}

func NewUsersAPI(svc *UserService) *UsersAPI {
    return &UsersAPI{svc: svc}
}

func (api *UsersAPI) Routing(r iris.Router) {
    r.Get("/", api.list)
}

func (api *UsersAPI) list(ctx iris.Context) {
    users, err := api.svc.List(ctx)
    ctx.List(users, err)
}

// newApp assembles the application. The repository arrives as a
// parameter: main passes the real store, tests pass fakes.
func newApp(repo UserRepository) *iris.Application {
    return iris.NewBuilder().
        NoPrefix().
        AllowOrigin("none").
        Compression(false).
        LogRequests(false).
        NoHealth().
        Errors(errors.NewOptions()).
        Services(repo, NewUserService).
        RouterMiddlewares().
        Middlewares().
        API("/users", NewUsersAPI).
        Build()
}

func main() {
    app := newApp(NewMemUsers())
    app.Listen(":8080")
}

Two lines carry the injection. Services(repo, NewUserService) registers the repository value and then calls NewUserService with it, because the parameter type matches. API("/users", NewUsersAPI) asks the container to call NewUsersAPI the same way, with the *UserService created a moment earlier. Nothing in UsersAPI knows that MemUsers exists.

Container Mechanics

Every router owns a *dep.Container, reachable through DI(). RegisterDependency feeds it directly; the builder’s Services step feeds the same container with extra conveniences (next section). The manual form works on any application, including plain iris.New():

Go
app := iris.New()

if err := app.RegisterDependency(
    NewMemUsers,    // constructor: called now, output registered.
    NewUserService, // called with the *MemUsers created above.
); err != nil {
    log.Fatal(err)
}

app.Router("/users", NewUsersAPI)

The rules the container follows:

  • A plain value (struct pointer, client, config) is registered under its concrete type and returned as-is to whoever asks for that type.
  • A function is treated as a constructor and is called immediately, at registration time. Its parameters are resolved from the dependencies registered before it; its outputs become new dependencies. Singletons therefore exist before the first request and a broken graph surfaces at startup, not in production traffic.
  • A constructor may include an error output. A non-nil error aborts the registration and is returned by RegisterDependency (the builder’s Services panics with it). The error itself is never registered.
  • Lookup walks the registrations in reverse order, newest first. When two dependencies can satisfy the same type, the one registered later wins for every consumer registered after it.
  • Order matters because construction is eager: register a dependency before the constructors that consume it.

Those two directions are the whole mechanism. The list is built forwards and read backwards:

The dependency container Dependencies are appended in the order you register them and their constructors run immediately, while resolution walks the same list backwards so the newest registration that satisfies a type wins. Services(deps...) routes each argument by its shape nil is skipped. func() and func() error become a shutdown closer only. A value with Close() or Close() error becomes a dependency AND a closer. An iris.Configurator is applied to the application, not registered. A function taking iris.Context, context.Context, *http.Request or time.Time is request-scoped: the function itself is the dependency. Anything else: values by type, functions as constructors. REGISTRATION: APPENDED IN ORDER, LEFT TO RIGHT, EACH CONSTRUCTOR CALLED IMMEDIATELY [0] repo *MemUsers value, wrapped as func() *MemUsers [1] NewUserService func(UserRepository) *UserService, called now with [0] [2] NewUserCache func(UserRepository) *UserCache, called now with [0] build forwards resolve backwards RESOLUTION: RESOLVE(TYPE) WALKS THE LIST BACKWARDS, SO THE NEWEST REGISTRATION THAT FITS WINS Matching inside one dependency 1. exact type: outputs[requestedType] 2. an interface: outputs sorted by method count, descending, and the first that implements it wins Newest wins, but only for consumers registered after it: an override placed after its consumers does nothing. An error output goes back to the caller and is never registered. One exception to fail-fast: an unresolved *Struct input is not an error. The container news it up and binds its fields, failing only on a field it cannot bind.
Dependencies are appended in the order you register them and their constructors run immediately, while resolution walks the same list backwards so the newest registration that satisfies a type wins.

A singleton is a value created once and then shared, as opposed to built fresh for each use. Shared is literal here: the Go HTTP server handles requests concurrently, each on its own goroutine (a lightweight thread managed by the Go runtime), so many requests may call into the same instance at the same moment. “Safe for concurrent use” means the value tolerates that: it is read-only after construction, or it guards its mutable state with a mutex the way MemUsers does.

Singletons are the default and only lifetime for ordinary registrations: one instance, shared by every consumer and every request, so anything you register should be safe for concurrent use. The exception is the request-scoped function dependency, covered in its own section.

You rarely touch the container at request time, but it is available for manual work:

Go
container := app.DI()

// Resolve by exact type: pass a value of the registered output type
// (*UserService here) or a reflect.Type.
if v, ok := container.Resolve(new(UserService)); ok {
    svc := v.Interface().(*UserService)
    _ = svc
}

// Invoke a function with injected arguments and take its first result.
result, err := container.InvokeReturningFirst(
    func(svc *UserService) int {
        users, _ := svc.List(stdContext.Background())
        return len(users)
    })
if err != nil {
    log.Fatal(err)
}
count := result.(int)

Interface Resolution

NewUserService asks for the UserRepository interface, but the container holds a *MemUsers. Resolution bridges the gap:

  1. An exact type match wins immediately.
  2. When the requested type is an interface, the container checks each dependency’s outputs with Implements. Within a dependency the candidates are ordered by method-set size, largest first, so the most specific output is preferred.
  3. Across dependencies the reverse-order rule still applies: the newest registration that satisfies the interface wins.

(A method set is simply the collection of methods a type provides. “Largest first” means that when one registration produces several outputs that all satisfy the interface, the output with the most methods is treated as the most specific and wins.)

This rewards the standard Go advice: constructors return concrete types, consumers accept interfaces. Swapping implementations is a registration change, not a refactor:

Go
app.RegisterDependency(NewMemUsers) // development default.
// Registered later, wins for every consumer registered after it.
// NewPgUsers stands for any alternative implementation.
app.RegisterDependency(NewPgUsers)
app.RegisterDependency(NewUserService) // receives the Postgres store.

Because injection happens at registration time, an override placed after the consumers has no effect on them. Put replacements before the constructors that should see them.

Services and the Builder

Services(deps ...any) is the builder’s registration step. It forwards its arguments to the router’s container, in order, left to right, after routing a few shapes specially:

  • func() and func() error become shutdown closers, not dependencies.
  • A value with a Close() or Close() error method is registered as a dependency and as a shutdown closer. When the application shuts down gracefully, its Close runs without a separate Deferrables entry.
  • An iris.Configurator is applied to the application instead of being registered.
  • nil entries are skipped.
  • Everything else is registered exactly like RegisterDependency would.
Go
db, err := sql.Open("postgres", dsn) // *sql.DB has Close() error.
if err != nil {
    log.Fatal(err)
}

app := iris.NewBuilder().
    // ... prefix, CORS, compression, logging, health, errors ...
    Services(
        db,             // dependency, closed automatically on shutdown.
        NewUserRepo,    // constructor: receives the *sql.DB above.
        NewUserService, // constructor: receives the repository.
    ).
    RouterMiddlewares().
    Middlewares().
    API("/users", NewUsersAPI).
    Build()

The auto-closer applies to values you pass in, not to what constructors create internally. When a constructor opens a resource, construct it in main and pass the value, or register a cleanup through Deferrables.

Any registration error makes Services panic immediately. That is deliberate; see Fail Fast at Startup.

One more convenience: iris.NewBuilder registers the *iris.Application itself as a dependency, so a constructor that needs the application (for its logger, for example) can simply accept app *iris.Application.

Controllers and Dependencies

A controller is any type with a Routing(r iris.Router) method (Chapter 4). Dependencies reach it three ways.

Constructor Resolution

Pass the constructor itself to API or Router and the container calls it:

Go
// Builder style.
.API("/users", NewUsersAPI)

// Direct style, same resolution.
app.Router("/users", NewUsersAPI)

// Manual style: no container involved, you call the constructor.
app.Router("/users", NewUsersAPI(svc))

This is the recommended form. The constructor documents exactly what the controller needs, and an unsatisfiable parameter stops the program at startup with the type and the constructor’s file and line in the panic message.

Field Injection

When you pass a pointer to a struct, the router fills its exported, zero-valued fields from the container:

Go
// ReportsAPI relies on field injection instead of a constructor.
type ReportsAPI struct {
    Service *UserService // exported and zero: injected by type.

    // dep:"-" opts a field out of injection; set it yourself.
    Title string `dep:"-"`
}

func (api *ReportsAPI) Routing(r iris.Router) {
    r.Get("/", api.overview)
}

func (api *ReportsAPI) overview(ctx iris.Context) {
    users, err := api.Service.List(ctx)
    ctx.OK(iris.Map{
        "title": api.Title,
        "count": len(users),
    }, err)
}

// Registration: fields are bound at this moment.
app.Router("/reports", &ReportsAPI{Title: "overview"})

Fields that already hold a non-zero value are left untouched, and a field that cannot be resolved panics at startup. Prefer constructors for anything non-trivial; field injection is best for small controllers where a constructor would be ceremony.

Controller Helpers

Two helpers compose with dependency injection inside a Router or API call:

Go
// Override the inherited dependencies for the controllers that
// follow in the same call (a per-group swap).
app.Router("/preview", iris.Dependencies(previewService), NewUsersAPI)

// Reuse a controller instance already registered as a dependency.
app.Router("/admin", iris.ResolveController[AdminAPI]())

iris.Dependencies clones the group’s container, so the override never leaks to sibling groups. iris.ResolveController[T] panics when no *T dependency exists.

There is no conditional-registration helper. A feature flag is read where you build the router, and the branch is ordinary Go:

Go
if flags.BillingEnabled {
    app.Router("/billing", iris.ResolveController[BillingAPI]())
} else {
    app.Router("/billing", iris.Middleware(comingSoonHandler))
}

Dependency-Injected Handlers

Controllers are not the only consumers. HandlerFunc turns a function with dependency parameters into an ordinary iris.Handler: the outer function runs once at startup with injected arguments and returns the handler that serves requests.

Go
app.Get("/stats", app.HandlerFunc(func(svc *UserService) iris.Handler {
    return func(ctx iris.Context) {
        users, err := svc.List(ctx)
        ctx.OK(iris.Map{"count": len(users)}, err)
    }
}))

The outer function must take only registered dependency types, never the context, and must return an iris.Handler (standard http.Handler-style returns are converted when possible). The same shape works as middleware:

Go
app.Use(app.HandlerFunc(func(svc *UserService) iris.Handler {
    return func(ctx iris.Context) {
        // Inspect the request using svc, then continue the chain.
        ctx.Next()
    }
}))

HandlerFuncs maps a list of such functions at once, and the builder’s Middlewares and RouterMiddlewares steps accept them directly, mixed freely with plain handlers. HandlerFunc panics when a parameter cannot be resolved, when the function returns nil, or when the result is not a handler.

Request-Scoped Dependencies

Everything so far was a singleton, created once at startup. Some values only exist during a request. The container handles them with a different contract: a registered function whose inputs include a request type is not called at registration. Instead, the function itself becomes the dependency, injectable by its exact function type, and the consumer calls it with the live context when a request is in flight.

The input types that mark a function as request-scoped in v14:

  • iris.Context
  • the standard library’s context.Context
  • *http.Request
  • time.Time

(Router wiring types such as iris.Router and controller functions are also reserved internally.) Custom types can be added with dep.RegisterRequestDependencyType from github.com/kataras/iris/v14/core/dep, before any registration.

Go
// requestID is request-scoped: the container injects this function
// itself, not its result.
func requestID(ctx iris.Context) string {
    return ctx.Header("X-Request-ID")
}

// AuditAPI declares the function type as a dependency.
type AuditAPI struct {
    requestID func(iris.Context) string
    svc       *UserService
}

func NewAuditAPI(
    rid func(iris.Context) string,
    svc *UserService,
) *AuditAPI {
    return &AuditAPI{requestID: rid, svc: svc}
}

func (api *AuditAPI) Routing(r iris.Router) {
    r.Get("/", api.show)
}

func (api *AuditAPI) show(ctx iris.Context) {
    ctx.OK(iris.Map{"request_id": api.requestID(ctx)}, nil)
}

Wire it like any other pair: Services(requestID) followed by API("/audit", NewAuditAPI).

Two rules keep this honest. The container never resolves the inputs of a request-scoped function; the caller supplies all of them at call time. And a struct dependency may not contain a request-scoped field: the registration fails with an error telling you to use a function instead. Keeping per-request state on the context itself (ctx.Values(), Chapter 5) remains the simpler tool when a middleware already computed the value.

Startup Hooks and Shutdown Closers

Some services need a step after wiring but before traffic: warm a cache, prime a connection pool, load a ruleset. (A connection pool is a set of network connections kept open and reused, so requests skip the cost of dialing the database every time; priming it means opening those connections up front.) Implement iris.Initializer:

Go
// Initializer is implemented by services registered through Services
// (or RegisterDependency) that need a startup step.
type Initializer interface {
    Init(ctx context.Context) error
}

Build() runs Init exactly once for every singleton that implements it, after dependency injection completes, in registration order. A returned error stops the application from building. Internally the builder walks the container with DI().VisitSingletons, which visits each resolved singleton value once (request-scoped functions have no value to visit and are skipped).

A cache that warms itself at startup:

Go
// UserCache serves reads from memory after a one-time warm-up.
type UserCache struct {
    repo UserRepository

    mu    sync.RWMutex
    users []User
}

func NewUserCache(repo UserRepository) *UserCache {
    return &UserCache{repo: repo}
}

// Init implements iris.Initializer. It runs inside Build(), once,
// after injection: the app never starts with a cold cache.
func (c *UserCache) Init(ctx stdContext.Context) error {
    users, err := c.repo.List(ctx)
    if err != nil {
        return err // aborts the build.
    }

    c.mu.Lock()
    c.users = users
    c.mu.Unlock()
    return nil
}

func (c *UserCache) All() []User {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.users
}

Register it with the rest: Services(repo, NewUserService, NewUserCache). No extra call is needed; implementing the interface is the registration.

Deferrables is the mirror image at shutdown. It accepts func(), func() error and values with a Close method, and runs them when the server stops gracefully, together with the closers that Services collected from Close()-bearing values:

Go
.Deferrables(func() { log.Println("users: goodbye") }).

Startup order is registration order; use it when one service must warm up before another. The pair gives every service a full lifecycle: construct (constructor), start (Init), serve, stop (Close or a deferrable).

Fail Fast at Startup

The dependency system panics instead of limping:

  • Services panics when a registration fails: a constructor parameter that nothing provides, or a constructor that returned an error.
  • Router and API panic when a controller constructor cannot be satisfied or a field cannot be bound. The message names the missing type (and, for constructors, the function’s file and line).
  • HandlerFunc panics on an unresolvable parameter, a nil result or a non-handler result.
  • Build panics when an Init returns an error.

This is a feature. All of these run during startup, so a broken graph kills the process before it binds a port: the failure is caught by your first local run, your tests or your deploy’s health check, never by a user hitting a nil service at 3 a.m. When you want to handle a registration failure yourself, use app.RegisterDependency, which returns the error instead of panicking.

Testing with Fakes

A fake is a hand-written stand-in that satisfies the same interface as the real implementation: a slice instead of a database, a struct that always fails instead of a flaky network. Unlike mocks from a mocking library, fakes carry no expectation scripting. They are ordinary Go code, which keeps tests readable and refactor-proof.

The payoff of constructor injection is that newApp from this chapter’s program accepts the UserRepository interface. Tests build the same application as production, minus one argument:

Go
package main

import (
    stdContext "context"
    "fmt"
    "testing"

    "github.com/kataras/iris/v14/httptest"
)

func TestListUsers(t *testing.T) {
    app := newApp(NewMemUsers())
    e := httptest.New(t, app)

    e.GET("/users").Expect().
        Status(httptest.StatusOK).
        JSON().Array().Length().IsEqual(2)
}

// failingUsers embeds the interface and overrides one method;
// everything else is inherited from the wrapped repository.
type failingUsers struct {
    UserRepository
}

func (failingUsers) List(stdContext.Context) ([]User, error) {
    return nil, fmt.Errorf("pg: connection refused on 10.0.0.3:5432")
}

func TestListUsersFailure(t *testing.T) {
    app := newApp(failingUsers{NewMemUsers()})
    e := httptest.New(t, app)

    e.GET("/users").Expect().
        Status(httptest.StatusInternalServerError)
}

failingUsers is the whole mocking framework: interface embedding makes a one-method fake five lines, with no library and no expectations DSL. For a fake that only one route group should see, iris.Dependencies scopes the override without a second newApp variant. The full testing story, including httptest options and response assertions, is Chapter 16; the tutorial’s Bookshelf project applies this exact failingRepo pattern end to end.

Summary

  • Constructor injection is the design; the container is the plumbing. Constructors declare dependencies, Services (or RegisterDependency) provides them, API resolves controllers through their NewXxxAPI constructors.
  • Registration is eager: constructors run at registration time, in order, so list dependencies before their consumers. Lookup is newest first, which makes overriding a registration-order concern.
  • Interface parameters are matched by method set, so constructors return concrete types while consumers accept interfaces.
  • Services routes shapes: closers are collected from func()s and Close()-bearing values, Configurators are applied, the rest become dependencies. HandlerFunc brings injection to plain handlers and middleware.
  • Request-scoped dependencies are functions taking iris.Context, context.Context, *http.Request or time.Time; the function itself is injected and the consumer calls it with the live context.
  • iris.Initializer.Init runs once inside Build() for cache warm-up and priming; Deferrables and auto-registered closers run at shutdown.
  • Unresolvable dependencies panic at startup by design: the process refuses to serve with a broken graph.
  • Fakes are interface embeddings passed to your newApp seam; see Chapter 16.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.