Lifetimes & Scopes
Wireup controls how long instances live and when they're shared through lifetimes and scopes.
Why Scopes?¶
Some resources need isolation. For example:
- Database transactions should be independent per request
- User context should not leak between requests
- Temporary objects should be created fresh each time
Scopes solve this by providing isolated contexts with automatic cleanup. When a scope ends, Wireup automatically releases resources created within that scope.
Lifetimes¶
Wireup provides three lifetime options that control instance creation and sharing. Configure the lifetime using the
@injectable decorator.
Quick Reference¶
| Lifetime | Instance Creation | Shared Within | Retrieved From | Best For |
|---|---|---|---|---|
| Singleton | Once per container | Entire application | Root or scoped | Configuration, database connections, caching |
| Scoped | Once per scope | Current scope only | Scoped only | Request state, transactions, user sessions |
| Transient | Every resolution | Never shared | Scoped only | Stateless objects, temporary objects |
Container Access Rules
The root container is the one you create during setup via wireup.create_sync_container or
wireup.create_async_container. A scoped container is created from it using container.enter_scope().
- Root container (
container.get()) can only retrieve singletons - Scoped container (
scope.get()) can retrieve singletons, scoped, and transient dependencies - Scoped containers automatically look up singletons from the root container
Singleton (Default)¶
One instance is created and shared across the entire application:
@injectable # lifetime="singleton" is the default
class Database:
def __init__(self): ...
# Same instance everywhere
db1 = container.get(Database) # Instance created
db2 = container.get(Database) # Reuses instance
assert db1 is db2 # True
Tip
Singletons are lazy by default. See Eager Initialization to initialize them at startup.
Scoped¶
One instance per scope, shared within that scope:
@injectable(lifetime="scoped")
class RequestContext:
def __init__(self):
self.request_id = uuid.uuid4()
with container.enter_scope() as scope1:
ctx1 = scope1.get(RequestContext)
ctx2 = scope1.get(RequestContext)
assert ctx1 is ctx2 # Same instance within scope
with container.enter_scope() as scope2:
ctx3 = scope2.get(RequestContext)
assert ctx1 is not ctx3 # Different instance in different scope
Transient¶
Creates a new instance on every resolution:
@injectable(lifetime="transient")
class MessageBuilder:
def __init__(self):
self.timestamp = time.time()
with container.enter_scope() as scope:
builder1 = scope.get(MessageBuilder)
builder2 = scope.get(MessageBuilder)
assert builder1 is not builder2 # Always different instances
Cleanup Timing¶
- Singleton cleanup happens when
container.close()is called (application shutdown) - Scoped cleanup happens when the scope exits (end of request/context)
- Transient cleanup happens when the scope that created them exits
Working with Scopes¶
Scopes provide isolated contexts. This is useful for things like database sessions or user context that should only exist for a short duration (like a single HTTP request).
graph TD
Root["Root Container<br/>(Singletons)"]
Root -->|enter_scope| Scope1
Root -->|enter_scope| Scope2
Root -->|enter_scope| Scope3
subgraph Scope1["Request 1"]
S1_scoped["Scoped deps"]
S1_transient["Transient deps"]
end
subgraph Scope2["Request 2"]
S2_scoped["Scoped deps"]
S2_transient["Transient deps"]
end
subgraph Scope3["Request 3"]
S3_scoped["Scoped deps"]
S3_transient["Transient deps"]
end
Each call to enter_scope() creates an isolated scoped container with its own scoped and transient dependencies.
When using Integrations (like FastAPI, Flask, Django), scopes are handled automatically. A new scope is created for every incoming request and closed when the request finishes.
@app.get("/users/me")
def get_current_user(auth_service: Injected[AuthService]):
return auth_service.get_current_user()
The @wireup.inject_from_container automatically enters a new scope before the function runs
and closes it afterwards, ensuring cleanup is performed.
@wireup.inject_from_container(container)
def process_order(order_service: Injected[OrderService]):
return order_service.process()
For granular control, you can manage scopes manually using container.enter_scope().
Synchronous
container = wireup.create_sync_container(injectables=[RequestService])
with container.enter_scope() as scope:
# Resolve dependencies from this specific scope
service = scope.get(RequestService)
service.process()
# When the block exits, the scope is closed and cleanup runs.
Asynchronous
container = wireup.create_async_container(injectables=[RequestService])
async with container.enter_scope() as scope:
service = await scope.get(RequestService)
service.process()
Resource Cleanup¶
Scoped containers ensure that resources are released when the scope exits. This simplifies resource management for things like database transactions or file handles.
See Resources & Cleanup for details on creating cleanable resources using generator factories.
Lifetime Dependency Rules¶
Dependencies have restrictions on what they can depend on to prevent Scope Leakage:
- Singletons can only depend on other singletons and config.
- Scoped can depend on singletons, scoped, and config.
- Transient can depend on any lifetime and config.
Concurrent Access¶
Scopes are typically accessed by a single thread or asyncio task (e.g., one web request). By default, Wireup does not use locks for scoped dependencies, optimizing for this common pattern.
When to Enable Locking¶
If you need to share a scope across multiple concurrent tasks, such as, parallelizing work within a request while
sharing a common context, enable concurrent_scoped_access when creating the container:
container = wireup.create_async_container(
injectables=[...],
concurrent_scoped_access=True, # Safe for shared scopes
)
Note
This is an advanced use case. Most applications don't need this.
Provided Instances¶
You can provide pre-created instances when entering a scope. Those instances can be created manually, or fetched from another scope first. A common advanced use case is fan-out:
import asyncio
async def run_worker(
container: wireup.AsyncContainer,
provided: dict[object, object],
item_id: str,
) -> None:
async with container.enter_scope(provided) as worker_scope:
worker = await worker_scope.get(WorkerService)
return await worker.process(item_id)
async def main():
async with root_container.enter_scope() as parent_scope:
request_ctx = await parent_scope.get(RequestContext)
tenant_scope = await parent_scope.get(TenantScope)
tasks = []
for item_id in ("a", "b", "c"):
provided = {
RequestContext: request_ctx,
TenantScope: tenant_scope,
}
tasks.append(run_worker(container, provided, item_id))
await asyncio.gather(*tasks)
Guidelines for what to share:
- Good candidates:
RequestContext, tenant/account context, auth claims, correlation/tracing metadata, immutable per-request flags. - Usually avoid sharing: DB sessions/transactions, unit-of-work objects, mutable caches, task-affine resources.
- Rule of thumb: share context, not mutable resource handles.
Caution
Providing instances at scope entry is an advanced feature. Use it only when you need strict control over scope composition.
- Validity
- Provided keys must be real dependencies already known to Wireup's graph (injectables/factories); otherwise those values will not be used for dependency resolution.
- Wireup does not type-check provided values. If you provide a wrong object for a key, resulting runtime failures are your responsibility.
- Ownership
- Wireup also assumes ownership of the passed mapping for the scope lifetime and may mutate it by storing resolved
scoped instances. Do not reuse the same mapping across scopes. Do not modify the mapping after passing it to
enter_scope(). - Ownership of provided instances is not transferred to Wireup. You are responsible for cleanup of those objects if needed. Wireup will not attempt to close or clean them up when the scope exits.
- Provided instances must outlive the scope they are provided to. If you provide instances from a parent scope, ensure the child scope does not outlive that parent scope. This is paramount for resources that require cleanup (i.e., anything that is created via a yielding factory in Wireup.)
- Wireup also assumes ownership of the passed mapping for the scope lifetime and may mutate it by storing resolved
scoped instances. Do not reuse the same mapping across scopes. Do not modify the mapping after passing it to
Next Steps¶
- Factories - Create complex dependencies with setup and teardown logic.
- Interfaces - Register multiple implementations of the same type.
- Testing - Override dependencies for testing.