Skip to content

Dependency Injection

wiki edited this page Sep 4, 2026 · 1 revision

Dependency injection

type Resolver interface {
	Resolve(target any) error
	ResolveAll(target any) error
}

type Container interface {
	Resolver
	Singleton(factory any) error
	Scoped(factory any) error
	Transient(factory any) error
	Instance(v any) error
	Unbind(v any) (bool, error)
}

type Scope interface {
	Resolver
	Close() error
}

Declared in rextension/di and re-exported by rextension, so extension code writes rextension.Resolver and never imports an implementation.

Do not import dix

This is the point of the package. The contract mirrors dix, which implements it — but an extension that names dix.Container acquires a dependency it uses in zero Go files, purely because the framework hands one back. Six extensions in this ecosystem carried exactly that.

The practical consequence, beyond a tidier module graph: a change to dix's Container interface stops reaching extension code — including test mocks, which is precisely the churn adding Unbind caused.

Why di is its own package. It is a leaf; it imports nothing. Both rextension and rextension/route need these types, and rextension already imports rextension/route, so declaring them in either one would be an import cycle.

Resolver is what most code wants

A health check, a route handler and a factory all need to read dependencies, not register them. Ask for the narrowest thing that works:

func (c *DBCheck) Check(ctx context.Context, r rextension.Resolver) error {
	var db *Database
	if err := r.Resolve(&db); err != nil {
		return err
	}
	return db.Ping(ctx)
}

Container is for publishing

An extension that offers something to the rest of the application registers it in OnInitialize:

func (e *Extension) OnInitialize(ctx context.Context, r rextension.Rex) error {
	return r.Container().Singleton(func() *myext.Registry {
		return e.registry
	})
}

Register the concrete type and let interface matching find it. Registering an interface type makes that interface the registered identity, which is rarely what you want.

Registering the same type twice returns dix.ErrAlreadyRegistered — so an extension that might be registered twice, or that replaces a default, uses Unbind first:

_, _ = r.Container().Unbind(oldValue) // keys on the exact dynamic type
_ = r.Container().Instance(newValue)

Unbind does not close what it removes, and singletons already handed out are unaffected.

Scope is the request boundary

The framework creates one scope per request and closes it when the handler returns. In a handler, ctx.Resolver() is that scope:

func handler(ctx rxroute.Context) {
	var uow *UnitOfWork
	if err := ctx.Resolver().Resolve(&uow); err != nil {
		rextension.WriteProblem(ctx.ResponseWriter(), ctx.Request(),
			500, rextension.ProblemInternal, "the request could not be completed")
		return
	}
	// uow.Close() runs when the request ends
}

Anything registered Scoped with a Close() error method is released there. An extension does not create scopes and does not close them.

Lifetimes, briefly

Kind Built Closed by the container
Singleton once, lazily, shared no
Scoped once per scope yes, on Scope.Close
Transient every resolve no
Instance by you no

Factories take either no arguments or a single Resolver, and return exactly one value. For a Scoped factory, the Resolver handed in is the scope — use it, never a captured container, or the factory's own scoped dependencies will be resolved from the root and fail.

Full detail: dix → Lifetimes.

Clone this wiki locally