Skip to content

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.

Next Steps

  • Factories - Create complex dependencies with setup and teardown logic.
  • Interfaces - Register multiple implementations of the same type.
  • Testing - Override dependencies for testing.