Skip to content

Dependency Injection

wiki edited this page Sep 4, 2026 · 1 revision

Dependency injection

rex embeds a dix container and creates one scope per request.

app.Container().Singleton(func() *Database {
	return &Database{DSN: os.Getenv("DSN")}
})

func listUsers(ctx route.Context) {
	var repo *UserRepo
	if err := ctx.Resolver().Resolve(&repo); err != nil {
		rextension.WriteProblem(ctx.ResponseWriter(), ctx.Request(),
			500, rextension.ProblemInternal, "the request could not be completed")
		return
	}
	_ = ctx.JSON(200, repo.FindAll(ctx))
}

ctx.Resolver() is the request scope. Anything registered Scoped is built at most once per request and closed when the handler returns.

Lifetimes

Built Closed by the framework
Singleton once, lazily, shared no — close it in your own shutdown
Scoped once per request yes, if it has Close() error
Transient every resolve no
Instance by you no

Full detail: dix → Lifetimes.

Use the resolver you are handed

A factory takes either nothing or a single Resolver. For a Scoped registration, the resolver handed in is the scope:

app.Container().Scoped(func(r rextension.Resolver) *UnitOfWork {
	var db *Database
	if err := r.Resolve(&db); err != nil {
		panic(err)
	}
	return db.Begin()
})

Closing over app.Container() instead would resolve from the root, and any scoped dependency would fail with dix.ErrScopedFromRoot. The captured form works right up until one of its dependencies becomes scoped, and then fails everywhere at once.

Request-scoped cleanup

The single most useful pattern the request scope enables: make the safe outcome the default one.

type Tx struct {
	tx        *sql.Tx
	committed bool
}

func (t *Tx) Commit() error { t.committed = true; return t.tx.Commit() }

func (t *Tx) Close() error { // called when the request ends
	if t.committed {
		return nil
	}
	return t.tx.Rollback()
}

app.Container().Scoped(func(r rextension.Resolver) *Tx { … })

A handler that returns early — a validation failure, a recovered panic — rolls back without anyone remembering to.

Registration is not sealed

The container may be written to at any time, but in practice do it before Run or from an extension's OnInitialize. A registration made after the application is serving is invisible to any resolve that already happened.

Registering the same type twice returns dix.ErrAlreadyRegistered. To replace something — a default an extension installed, say — unbind first:

_, _ = app.Container().Unbind(oldLogger)
_ = app.Container().Instance(newLogger)

Unbind keys on the exact dynamic type of the value passed, does not close what it removes, and does not affect singletons already handed out.

Container typing

app.Container() returns rextension.Container, not dix.Container. That is why extensions do not need dix in their go.mod, and it means the container implementation is swappable without touching extension code.

Application code may import dix — for the sentinel errors, or to build a container directly in a test — but it does not have to.

Verifying wiring at startup

Resolution errors otherwise surface on the first request that needs the missing piece. Move that to boot:

func verify(c rextension.Container) error {
	// Use a scope, or every scoped dependency reports ErrScopedFromRoot.
	type scoper interface{ NewScope() dix.Scope }
	s := c.(scoper).NewScope()
	defer s.Close()

	var users *UserService
	return s.Resolve(&users)
}

Or, more simply, keep the wiring in one function and make it return an error:

if err := errors.Join(
	app.Container().Instance(cfg),
	app.Container().Singleton(newUserRepo),
	app.Container().Scoped(newUnitOfWork),
); err != nil {
	return fmt.Errorf("wiring: %w", err)
}

Common mistakes

A singleton holding a scoped value. It outlives every scope, so it pins the first request's transaction forever. Resolve the scoped value at the point of use.

Registering an interface type. Singleton(func() Logger { … }) makes Logger the registered identity. Register the concrete type and let interface matching find it.

Two implementations of one interface. Resolve then fails with ErrAmbiguousResolution rather than picking one at random — Go randomises map iteration, so "first match" would differ between process starts. Register one, or use ResolveAll.

Clone this wiki locally