Dependency Injection Container: Lifetimes, Scopes and Disposal
MediumAI-assisted coding · AI-Assisted Debugging
Platform-Core DI Container: Scopes, Validation and Decorators
You've joined platform-core, the team that owns the in-house dependency-injection container every backend service boots from. It works like .NET's or Spring's, only tiny, and in this exercise it is driven by operations instead of real classes: you register services, resolve them, and get back instance ids such as PgDb#2, which is how ops reads incident logs.
Staging is misbehaving (Part 1). Two features are queued behind the fix: a validator that stops bad registrations before they reach production (Part 2), and a rework for teams that need more than one implementation per service (Part 3). Part 4 is a performance stretch.
Codebase
| File | What it does |
|---|---|
di/api.py | run(operations) entry point and the Dispatcher that routes each operation |
di/container.py | Container: registration, resolution (construction), scopes, disposal |
di/registry.py | ServiceRegistry: what has been registered |
di/graph.py | Pre-flight checks that run before anything is built |
di/store.py | InstanceStore: instance numbering, the singleton and scoped caches, which instances are alive |
di/scopes.py | ScopeTable: scopes that have been opened |
di/disposal.py | The order things are closed in |
di/models.py | Registration, Instance, Scope |
di/errors.py | DIError and every error message |
di/config.py | Lifetime names, the id format, name pattern |
tests/ | Existing unit and end-to-end tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh container and returns one result per operation. An operation is [name, *args]. Errors are returned as "error: <message>" strings, never raised. Names (services, implementations) match [A-Za-z_][A-Za-z0-9_]*; scope ids are non-empty strings chosen by the caller; the root scope (the container itself) is written null.
| Operation | Arguments | Returns | Errors, checked in this order |
|---|---|---|---|
register | service, impl, lifetime, deps | true | invalid arguments, invalid lifetime '<x>', duplicate registration for '<service>' |
open_scope | scope_id | true | invalid arguments, scope '<id>' already exists |
resolve | service, scope_id | the instance id | see below |
owned | scope_id | ids the scope would dispose, oldest first | invalid arguments, unknown scope '<id>' |
dispose_scope | scope_id | ids closed, in closing order | invalid arguments, unknown scope '<id>', scope '<id>' is disposed |
dispose | none | ids closed, in closing order | none |
registerisinvalid argumentsunlessserviceandimplare valid names,lifetimeis a string anddepsis a list of valid names; only then is the lifetime itself checked.lifetimeis"singleton","scoped"or"transient";depsis a list of service names (it may be empty and may repeat a name).implis what the id is made from:register("Db", "PgDb", ...)yields ids likePgDb#2.- The dispatcher checks, in order: the operation is a non-empty list starting with a string (else
invalid arguments), the name is known (unknown operation), the argument count is exact (invalid arguments), the container has not been disposed (container disposed, for every operation afterdispose), then the operation's own checks. resolvechecks, in order:serviceis a string andscope_idisnullor a non-empty string (invalid arguments); the scope exists (unknown scope '<id>') and is not disposed (scope '<id>' is disposed); then the registration problems described in the Parts below.no registration for '<name>'is the error for an unregistered service, andno registration for '<name>' (required by '<service>')for an unregistered dependency, where<service>is the service that lists it.- Also available (used by Part 4; both already work):
["register_ladder", prefix, depth, width, lifetime]registersdepthlayers ofwidthservices named<prefix><layer>_<column>, each layer depending on every service of the next layer (the last layer has no dependencies), returning how many it registered;["resolve_each", scope_id, rounds]makesrounds(an integer >= 1) passes, each resolving every service that has an unkeyed registration in the order the services were first registered, returning[succeeded, failed, last_successful_id_or_null]; failed resolves are counted, not reported, and a badscope_idis the usual scope error.
Worked example
[["register", "Config", "FileConfig", "singleton", []],
["register", "Db", "PgDb", "singleton", ["Config"]],
["register", "Session", "HttpSession", "scoped", ["Db"]],
["register", "Handler", "OrderHandler", "transient", ["Session", "Db"]],
["open_scope", "req-1"],
["resolve", "Handler", "req-1"],
["resolve", "Handler", "req-1"],
["owned", "req-1"],
["owned", null],
["dispose_scope", "req-1"]]returns
[true, true, true, true, true,
"OrderHandler#4", "OrderHandler#5",
["HttpSession#3", "OrderHandler#4", "OrderHandler#5"],
["FileConfig#1", "PgDb#2"],
["OrderHandler#5", "OrderHandler#4", "HttpSession#3"]]Part 1 — Staging incident: scoped state, numbering and shutdown (INC-4502)
Goal: make lifetimes, instance numbering, ownership and disposal behave as specified below.
The incident write-up lists four symptoms: (a) two concurrent requests shared one Session; (b) instance numbers in the logs do not match creation order, so a dependency shows a *higher* number than the thing that uses it; (c) draining one request also shut down the shared Db; (d) shutdown closed dependencies before the objects that still used them. tests/ has failing tests for some of these.
Rules
- Lifetimes. *Singleton*: one instance per container, shared by every scope and the root. *Scoped*: one instance per scope; a different scope gets a different one. *Transient*: a new instance on every resolve and every time it is injected (a service listing a transient twice gets two instances). A cached instance is reused without building anything new.
- Numbering. Numbers start at 1, are container-wide and are never reused. Building an instance is depth-first: its dependencies are built first, in the order
depslists them, and only then is the instance itself numbered. So a dependency always has a lower number than its dependents, and the resolved service gets the highest number of the new instances. (In the example:FileConfig#1,PgDb#2,HttpSession#3, thenOrderHandler#4.) A dependency that is already cached uses no number. - Ownership. Every instance has one owner, which disposes it: the container (root) or one scope.
- a singleton is owned by the container, and so is everything built while constructing it, including transients, even when the resolve came from inside a scope;
- a scoped instance is owned by the scope it was created for;
- a transient is owned by whoever is resolving it: the scope of the
resolve, or the container forresolve(..., null). A transient built as a dependency of a scoped or transient service has the same owner as its parent.
- Disposal.
dispose_scope(s)closes everythingsowns and returns the ids newest first (highest number first), then the scope is closed: any laterresolve,dispose_scopeon it fails withscope '<id>' is disposed,ownedon it returns[], and its id can't be opened again.dispose_scope(null)isinvalid arguments.dispose()closes every still-open scope, most recently opened first (each scope's instances newest first), then the container's own instances newest first, and returns one flat list. Disposed instances never appear again. - Scoped from the root. Resolving a scoped service from
null, directly or as a dependency, fails withscoped service '<service>' cannot be resolved from the root scope.
For now, captive dependencies and cycles are not checked (Part 2): don't worry about graphs that contain them. Registering a service twice is duplicate registration (Part 3 changes that).
Part 2 — Validation: cycles and captive dependencies (PLAT-2210)
Goal: a resolve must refuse a bad dependency graph with an exact, actionable message, and must do so before building anything.
A *captive dependency* is a singleton that (directly or through other services) holds on to a scoped service: the scoped instance would outlive its scope. Today these either silently work or recurse until the MAX_RESOLUTION_DEPTH stopgap trips. Replace that stopgap: a graph that is not cyclic must resolve whatever its depth.
Rules
- A resolve first *validates* the graph reachable from the requested service. It walks depth-first, dependencies in declared order, and the first problem it meets is the error. At each service it checks, in this order:
- the service has a registration, else
no registration for '<name>' (required by '<service>')(or without the "required by" for the service you asked for); - cycle: the service is already on the current path. Error
circular dependency: A -> B -> C -> A, listing the path from the first occurrence of the repeated service, then the repeated service again (a self-dependency isA -> A); - captive: the service is scoped and some singleton is above it on the current path. Error
captive dependency: <chain>where the chain starts at the nearest singleton above it and runs down to the scoped service, each entry writtenName (lifetime), joined with->, e.g.captive dependency: Mailer (singleton) -> Templates (transient) -> Session (scoped); - the service is scoped and the resolve is from the root:
scoped service '<name>' cannot be resolved from the root scope. - The same service reached by two paths (a diamond) is not a cycle.
- Only if the whole walk is clean is anything built. A failed resolve creates no instance, uses no instance number and changes no cache;
ownedis unchanged by it. - Validation looks at the registrations only, never at what is already cached: a cached singleton whose graph is invalid is still an error. (Part 1's rules for what is cached and numbered are unchanged.)
Example
[["register", "Session", "HttpSession", "scoped", []],
["register", "Templates", "TemplateCache", "transient", ["Session"]],
["register", "Mailer", "SmtpMailer", "singleton", ["Templates"]],
["register", "A", "ImplA", "transient", ["B"]],
["register", "B", "ImplB", "transient", ["C"]],
["register", "C", "ImplC", "transient", ["A"]],
["open_scope", "r"],
["resolve", "Mailer", "r"],
["resolve", "A", null],
["resolve", "C", "r"]]returns [true, true, true, true, true, true, true, "error: captive dependency: Mailer (singleton) -> Templates (transient) -> Session (scoped)", "error: circular dependency: A -> B -> C -> A", "error: circular dependency: C -> A -> B -> C"].
Part 3 — Requirements change: many registrations, keys and decorators (PLAT-2288)
Goal: teams need several implementations of one service (plugins, strategies), named variants, and wrappers such as logging or retry. The "one registration per service" assumption that Parts 1 and 2 were built on no longer holds. Everything from Parts 1 and 2 must keep working, now per registration.
Rules
- Registering again appends.
duplicate registrationno longer exists:registerof a service that already has registrations adds another one. The latest registration of a service is the one a plainresolveand a plain dependency refer to ("last wins"); earlier ones stay reachable throughresolve_all. Registration order is the order of theregistercalls. - References and keys. A *reference* is
NameorName[key](keymatches[A-Za-z0-9_-]+).register'sservice, every entry ofdeps,resolve's andresolve_all's first argument are references. A reference only matches registrations made with exactly the same name and key (Cachenever matchesCache[redis]and vice versa). Inregisteranddecoratea malformed reference (or dependency) isinvalid arguments; inresolve/resolve_alla string that is not a well-formed reference givesno registration for '<text as given>'. In messages and chains a keyed registration is writtenCache[redis]. - Caches follow registrations. A singleton is one instance per *registration*, a scoped instance one per (scope, registration). Registering a newer implementation never touches instances already built from older ones: they stay alive and are disposed as before. Cycle detection and "already on the path" also mean *the same registration*, so an older registration of
Handlerthat depends onHandler(which resolves to the newest one) is not a cycle. ["resolve_all", reference, scope_id]returns, in registration order, the ids of every registration that matches the reference, each resolved as an ordinary resolve of that registration (the argument and scope checks are the same asresolve). If nothing matches:no registration for '<label>'. Every registration is validated (in order, first failure reported) before any is built; then they are built in order, so the numbers run continuously across the list.["decorate", service, impl, deps]returnstrue(errors:invalid argumentswhenserviceis not a plain name,implis not a name ordepsare not well-formed references). It wraps every registration ofservice, keyed or not, whether registered before or after the call. Several decorators apply in the order of thedecoratecalls, the first being innermost.
- resolving builds in *stages*: the registration's own dependencies, then the registration's instance; then the first decorator's dependencies, then the first decorator's instance; and so on. Each instance gets the next number when it is built, and the id returned is the outermost decorator's (
<decorator impl>#<n>). - every instance of the stack has the lifetime, owner and disposal behaviour of the registration, and the outermost instance is the one that is cached. Decorators are read when an instance is *created*: an already-cached singleton or scoped instance is returned as it is and is never re-wrapped.
- validation (Part 2) treats the dependencies of the registration followed by those of each current decorator, in that order, as the registration's dependencies. A decorator that depends on its own service is therefore a cycle (
Db -> Db); a decorator that depends on a scoped service makes a singleton registration captive, and the chain names the decorated service, e.g.Db (singleton) -> RequestCtx (scoped). A missing dependency of a decorator is reported as required by the decorated service. Validation still ignores caches (Part 2, rule 4), so a decorator added after a singleton was cached can make a resolve of it fail even though no new instance would be built.
Example
[["register", "Sink", "ConsoleSink", "singleton", []],
["register", "Sink", "FileSink", "singleton", []],
["register", "Sink[audit]", "AuditSink", "singleton", []],
["register", "Clock", "SysClock", "singleton", []],
["register", "Mailer", "SmtpMailer", "transient", ["Sink[audit]"]],
["decorate", "Mailer", "RetryMailer", ["Clock"]],
["resolve", "Sink", null],
["resolve_all", "Sink", null],
["resolve", "Mailer", null],
["resolve", "Sink[audit]", null]]returns [true, true, true, true, true, true, "FileSink#1", ["ConsoleSink#2", "FileSink#1"], "RetryMailer#6", "AuditSink#3"] (Mailer builds AuditSink#3, SmtpMailer#4, then the decorator's SysClock#5, then RetryMailer#6).
Part 4 — Stretch: validation at scale (PLAT-2301)
Goal: an SRE load test registers a few hundred services in a deep layered graph and resolves them thousands of times; the whole thing must finish in a few seconds.
Rules
- Behaviour is exactly as in Parts 1 to 3, including that validation ignores caches, so a resolve of a singleton that is already cached is still validated against the current registrations.
- After any
registerordecorate, the next resolves must reflect the new registrations; a verdict (success or error) obtained before the change must never be reused after it. - The same service can be fine from a scope and an error from the root, so a remembered verdict must know which one it was for.
- Hidden tests use
register_ladderandresolve_eachwith up to 100 layers of 5 services; a layered graph like that has an astronomical number of distinct paths from its top to its bottom.
Notes
di/scopes.pyis known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour.
- Parts 1-3 is a strong result. Part 4 is a stretch.
Example 1
- Input:
- [["register","Config","FileConfig","singleton",[]],["register","Db","PgDb","singleton",["Config"]],["register","Session","HttpSession","scoped",["Db"]],["register","Handler","OrderHandler","transient",["Session","Db"]],["open_scope","req-1"],["resolve","Handler","req-1"],["resolve","Handler","req-1"],["owned","req-1"],["dispose_scope","req-1"]]
- Output:
- [true,true,true,true,true,"OrderHandler#4","OrderHandler#5",["HttpSession#3","OrderHandler#4","OrderHandler#5"],["OrderHandler#5","OrderHandler#4","HttpSession#3"]]
- Explanation
- Resolving Handler builds FileConfig#1, PgDb#2 and HttpSession#3 before OrderHandler#4. The second resolve reuses the singleton and scoped instances and creates only a new transient. Disposing the scope closes what it owns, newest first; the singletons stay.
Example 2
- Input:
- [["register","Session","HttpSession","scoped",[]],["register","Templates","TemplateCache","transient",["Session"]],["register","Mailer","SmtpMailer","singleton",["Templates"]],["open_scope","r"],["resolve","Mailer","r"]]
- Output:
- [true,true,true,true,"error: captive dependency: Mailer (singleton) -> Templates (transient) -> Session (scoped)"]
- Explanation
- A singleton would hold on to a scoped service through a transient, so the resolve is refused before anything is built.
Guidelines
- Python 3 standard library only
- Every operation returns exactly one value; errors are returned as "error: <message>" strings, never raised
- Output must be fully deterministic: instance numbers, list orders and error text are all specified
- Part 4 inputs register hundreds of services and perform thousands of resolves from a handful of operations