-
Notifications
You must be signed in to change notification settings - Fork 0
Lifetimes
Every registration has exactly one lifetime, and a type may hold exactly one
registration. Registering the same type twice — even under a different lifetime —
returns ErrAlreadyRegistered.
| Kind | Constructed | Shared with | Closed by the container? |
|---|---|---|---|
| Singleton | Once, lazily, on first resolve | Everything, forever | No |
| Scoped | Once per Scope
|
Resolves within the same scope |
Yes — Scope.Close()
|
| Transient | On every Resolve call |
Nothing | No |
| Instance | By you, before registration | Everything, forever | No |
c.Singleton(func() *Database { return openDB() })Construction is lazy and exactly once, even when several goroutines resolve it at the same moment. The practical consequence is worth stating plainly: a singleton factory may depend on anything registered before the first resolve, not merely on what was registered before the factory itself. Registration order between singletons does not matter.
Nothing closes a singleton. If it owns a connection pool, close it yourself at shutdown — the container has no shutdown of its own.
c.Scoped(func(r dix.Resolver) *UnitOfWork { ... })One instance per Scope, memoised for that scope's lifetime, and closed when
the scope closes if it has a Close() error method. This is the lifetime for
anything request-bound: a transaction, a per-request cache, a correlation ID.
Two rules follow from "per scope":
-
Resolving a scoped type from the root container fails with
ErrScopedFromRoot. It does not fall back to building one. A scoped value built from the root has no owning scope, so nothing would ever close it — which is exactly the leak scopes exist to prevent. -
A scoped factory taking a
Resolverreceives the scope, not the root container. Its own scoped dependencies therefore land in the same scope rather than failing.
c.Transient(func() *Encoder { return newEncoder() })A fresh value on every Resolve, memoised nowhere. Use it for cheap, stateful,
short-lived objects.
Transients are never closed, not even when resolved from a scope. The scope tracks scoped instances only. If a transient owns a resource, the caller owns closing it.
c.Instance(&Config{Env: "prod"})Registers a value you already built. v must not be nil and must not be a
function — a function would almost certainly be a factory passed to the wrong
method, so it is rejected as ErrInvalidFactory rather than registered as a
value of func type.
The registration key is v's dynamic type. c.Instance(myLogger) where
myLogger is a *SlogLogger held in a Logger variable registers
*SlogLogger, and it will satisfy a Logger resolve by
interface matching.
- Stateless, shared, expensive to build → Singleton
- Bound to one request or one unit of work, owns something closeable → Scoped
- Cheap, stateful, must not be shared → Transient
- Already built, or built by configuration code → Instance
Because a type holds only one registration, correcting one means removing the old one first:
_, _ = c.Unbind(defaultLogger) // *slogLogger
_ = c.Instance(configuredLogger) // *myLogger — a different concrete typeUnbind reports whether anything was removed. It does not close what it
removes, and singletons already constructed and handed out are unaffected —
holders keep the value they were given.
dix — Dependency Injection eXperience · MIT · © 2026 Kryovyx · pre-1.0, interfaces may change
Concepts
Reference
Practice
Ecosystem