Skip to content
Dependency Injection

Dependency Injection

The di package is the core of the Salusa framework. Dependencies are registered as factories on a DependencyProvider and are either resolved directly with Resolve or filled implicitly into structs whose fields carry an inject tag.

Registering dependencies

Registration happens on a context.Context; the context determines which DependencyProvider receives the registration. TestDependencyProviderContext returns a background context carrying a fresh provider, handy in tests and in init in the generated template:

ctx := di.TestDependencyProviderContext()
di.RegisterSingleton(ctx, func() *Config {
	return loadConfig()
})

The Register family:

functionbehavior
Register(ctx, factory)calls the factory on every resolve
RegisterWith[T, W](ctx, factory)fills a W with dependencies, then calls the factory on every resolve
RegisterSingleton(ctx, factory)builds once at registration time, returns the same value on every resolve
RegisterLazySingleton(ctx, factory)builds at most once, on the first resolve
RegisterLazySingletonWith[T, W](ctx, factory)fills a W with dependencies, then builds at most once
RegisterValue(ctx, t, factory)registers a factory for a type known only dynamically (reflect.Type)

NewDependencyProvider() creates an independent provider that can resolve itself and the surrounding context.Context. Registering the same type twice replaces the previous factory.

Resolving dependencies

Resolve[T] builds a value of type T from the provider carried by ctx:

cfg, err := di.Resolve[*Config](ctx)

If T is a fillable struct, its inject fields are filled recursively instead of being built by a factory.

Filling structs

A struct whose fields carry an inject tag can be filled with Fill. The first tag value names the dependency and is passed to the factory, which lets one factory serve dependencies of the same type under different names. The remaining values are flags; optional leaves the field as its zero value when the dependency is not registered:

type handler struct {
	Config *Config `inject:""`
	DB     *sql.DB `inject:"db,optional"`
	Cache  *redis.Client
}

filled, err := di.Fill(ctx, &handler{})

Fields without an inject tag are left untouched. A dependency that is not registered and is not optional produces an error wrapping di.ErrNotRegistered.

Dependency-aware factories

RegisterWith and RegisterLazySingletonWith pass a filled struct to the factory, so a dependency can declare what it needs without resolving anything itself:

type mailerDeps struct {
	Config *Config   `inject:""`
	Logger *slog.Logger `inject:""`
}

di.RegisterLazySingletonWith(ctx, func(deps mailerDeps) (*Mailer, error) {
	return NewMailer(deps.Config, deps.Logger)
})

Wrapping functions

PrepareFunc turns a function whose extra parameters are dependencies into a function with a fixed signature. PrepareFuncCtx is the same, resolving the extra parameters from the provider carried by the context passed at call time:

func send(ctx context.Context, user *User) error { ... }

sendUser := di.PrepareFuncCtx[func(ctx context.Context, user *User) error](send)

If filling the extra parameters fails and the wrapped function does not return an error, the call panics.

Validation

DependencyProvider.Validate checks the registered factories for missing dependencies and dependency cycles. Validator returns a DIValidator for a root type that verifies every inject field has a registered factory:

err := dp.Validate(ctx)

The kernel calls this on startup so a misconfigured provider fails fast.