Skip to content
wiki edited this page Sep 4, 2026 · 1 revision

Scopes and cleanup

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.

What Close closes

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.

Close semantics

  • Idempotent. defer scope.Close() alongside an explicit call closes each instance exactly once; the second call returns nil.
  • Total. Every closer is closed even if an earlier one fails. Returning on the first error would leave the remainder open, turning one failing Close into a leak of all the others. Failures are combined with errors.Join, so errors.Is works across them.
  • Terminal. After Close, every Resolve and ResolveAll on that scope returns ErrScopeClosed. The scope is not reusable.
if err := scope.Close(); err != nil {
	log.Printf("scope cleanup: %v", err) // one or more joined failures
}

Racing Resolve against Close

Both are safe to call concurrently, and the loser of the race does not leak:

  • A factory that finishes constructing after Close has run has its result closed immediately and the resolve returns ErrScopeClosed. 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.

Scopes in rex

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
}

Long-lived scopes

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.

Clone this wiki locally