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.

Registering directly on a provider

Every function in the Register family also exists as a method on DependencyProvider, with the same behavior and the same type parameters minus the leading ctx. This is the form to use when you hold a provider directly and do not want to thread a context through — for example when assembling a provider in a test:

dp := di.NewDependencyProvider()
dp.RegisterSingleton(func() *Config { return loadConfig() })
dp.RegisterLazySingleton(func() (*DB, error) { return openDB() })
dp.RegisterWith(func(ctx context.Context, tag string, deps mailerDeps) (*Mailer, error) {
	return NewMailer(deps.Config, deps.Logger)
})
methodbehavior
Register(factory)calls the factory on every resolve
RegisterWith[T, W](factory)fills a W with dependencies, then calls the factory on every resolve
RegisterSingleton(factory)builds once at call time, returns the same value on every resolve
RegisterLazySingleton(factory)builds at most once, on the first resolve
RegisterLazySingletonWith[T, W](factory)fills a W with dependencies, then builds at most once
RegisterValue(t, factory)registers a factory for a type known only dynamically (reflect.Type)
RegisterFactory(factory)registers an already-built Factory, the escape hatch the others delegate to

The package-level functions are thin wrappers that pull the provider off ctx and call the matching method, so di.Register(ctx, f) and ctx’s provider’s Register(f) are equivalent.

Note The method that accepts a prebuilt Factory is named RegisterFactory. It was previously named Register on *DependencyProvider; a bare dp.Register(someFactory) call from older code now needs to become dp.RegisterFactory(someFactory). Calls that passed a factory function rather than a Factory never compiled against the old signature, so they are unaffected.

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)

ValidatorFor[T](ctx) is the generic form of Validator, returning a DIValidator for the type T.

A type can declare and validate its own dependencies by embedding di.Uses[T]. Embedding the zero-size Uses[T] gives the struct a Validate(ctx) method that reports every inject field of T that has no registered factory, so a handler can be validated without a central list:

type Bar struct {
	di.Uses[*Foo]
}

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