-
Notifications
You must be signed in to change notification settings - Fork 0
Scopes
A Scope is a bounded lifetime. It resolves like the container, memoises
scoped instances for its own duration, and releases them on Close.
scope := container.NewScope()
defer scope.Close()
var uow *UnitOfWork
_ = scope.Resolve(&uow)NewScope cannot fail and takes no arguments. Scopes are children of the
container, not of each other — there is no nesting.
Every scoped instance the scope built that has a Close() error method.
That is the whole rule, and the exclusions matter:
Closed by Scope.Close()? |
|
|---|---|
Scoped instances with Close() error
|
yes |
| Scoped instances without one | no — nothing to call |
| Singletons resolved through the scope | no — they outlive the scope |
| Transients resolved through the scope | no — the caller owns them |
Instances registered with Instance
|
no — you built them, you close them |
io.Closer is not required as such; the check is structural, for any
Close() error method.
-
Idempotent.
defer scope.Close()alongside an explicit call closes each instance exactly once; the second call returnsnil. -
Total. Every closer is closed even if an earlier one fails. Returning on
the first error would leave the remainder open, turning one failing
Closeinto a leak of all the others. Failures are combined witherrors.Join, soerrors.Isworks across them. -
Terminal. After
Close, everyResolveandResolveAllon that scope returnsErrScopeClosed. The scope is not reusable.
if err := scope.Close(); err != nil {
log.Printf("scope cleanup: %v", err) // one or more joined failures
}Both are safe to call concurrently, and the loser of the race does not leak:
- A factory that finishes constructing after
Closehas run has its result closed immediately and the resolve returnsErrScopeClosed. The orphan is not handed back to a caller that would have no way to release it. - Two goroutines resolving the same scoped type run the factory at most twice and keep one value; the redundant one is closed, unless the factory happened to return the identical value.
The factory itself always runs with the scope's lock released, so a factory may resolve further dependencies through the same scope without deadlocking.
The framework creates one scope per HTTP request and closes it when the handler
returns. route.Context.Resolver() is that scope. So in a rex application you
rarely call NewScope yourself — you register the right lifetime and let the
request boundary do the work:
app.Container().Scoped(func(r rextension.Resolver) *Tx {
var db *Database
_ = r.Resolve(&db)
return db.Begin() // *Tx has Close() error → rolled back if not committed
})
func handler(ctx route.Context) {
var tx *Tx
_ = ctx.Resolver().Resolve(&tx)
// ... tx.Close() runs when the request ends
}Nothing restricts a scope to a request. A worker loop, a batch job or a
consumer session is a perfectly good scope — the only requirement is that
something eventually calls Close, and that the scope is not so long-lived that
"per scope" has quietly become "forever". If it is, that dependency wanted to be
a singleton.
dix — Dependency Injection eXperience · MIT · © 2026 Kryovyx · pre-1.0, interfaces may change
Concepts
Reference
Practice
Ecosystem