Skip to content

Lifecycles

The three lifecycles Tripack supports - TRANSIENT, SINGLETON, SCOPED - decide how often a factory is called and how long an instance lives. Picking the right one is usually a question of who owns the instance and how expensive is it to build.

At a glance

Lifecycle Built when Cached on Use it for
TRANSIENT every resolve nowhere stateless helpers, value objects, throwaways
SINGLETON first resolve the resolver expensive shared infrastructure (pools, caches)
SCOPED first resolve in a scope the active Scope per-request state (sessions, transactions)

TRANSIENT - fresh every time

from tripack_contracts import Lifecycle
from tripack_container import ContainerBuilder


class Stopwatch:
    def __init__(self) -> None:
        self.events: list[str] = []


container = (
    ContainerBuilder()
    .bind(Stopwatch, Stopwatch, lifecycle=Lifecycle.TRANSIENT)
    .build()
)
a = container.resolve(Stopwatch)
b = container.resolve(Stopwatch)
assert a is not b

Each resolve runs the factory. No state is shared between two Stopwatch instances. This is the natural choice for value-like services that hold per-call state.

TRANSIENT is the default lifecycle when neither the lifecycle= keyword nor a provider helper (@singleton, @scoped) is used.

SINGLETON - one instance for the container's lifetime

class CachePool:
    def __init__(self) -> None:
        self._cache: dict[str, str] = {}

    def get(self, key: str) -> str | None:
        return self._cache.get(key)


container = (
    ContainerBuilder()
    .bind(CachePool, CachePool, lifecycle=Lifecycle.SINGLETON)
    .build()
)
first = container.resolve(CachePool)
second = container.resolve(CachePool)
assert first is second

A SINGLETON is built once and reused for every subsequent resolve. Its lifetime is the container's lifetime; opening or closing scopes does not affect it.

Use SINGLETON for expensive-to-build, safe-to-share resources: connection pools, in-memory caches, configuration objects, loggers. Anything that holds state which should be shared across the whole application.

SINGLETON instances exposing close (or aclose) are torn down on container.close() / aclose() (and automatically on with container: exit), in LIFO order so dependents close before what they depend on.

SCOPED - one instance per scope

class Session:
    def __init__(self) -> None:
        self.events: list[str] = []


container = (
    ContainerBuilder()
    .bind(Session, Session, lifecycle=Lifecycle.SCOPED)
    .build()
)
with container.scope():
    first = container.resolve(Session)
    second = container.resolve(Session)
    assert first is second        # shared within the scope
with container.scope():
    other = container.resolve(Session)
    assert first is not other     # distinct across scopes

A SCOPED binding caches its instance on the active Scope. Inside one with container.scope(): block, every resolve of the same token returns the same instance. Across scopes, the instances are distinct.

Use SCOPED for per-request state: HTTP request context, database transactions, per-job logging. The canonical pattern is a web framework that opens one scope per inbound request, resolves a tree of request-bound services, then closes the scope when the response is sent.

A SCOPED resolve without an open scope raises ScopeError - the container refuses to dangle a request-bound instance outside its lifetime.

SCOPED teardown

class Connection:
    def __init__(self) -> None:
        self.open = True

    def close(self) -> None:
        self.open = False


container = (
    ContainerBuilder()
    .bind(Connection, Connection, lifecycle=Lifecycle.SCOPED)
    .build()
)
with container.scope():
    conn = container.resolve(Connection)
    assert conn.open is True
assert conn.open is False     # auto-closed on scope exit

SCOPED closeables are torn down when the scope exits, in LIFO order. The container's SINGLETON teardowns are independent and run on container.close().

Async lifecycles

The same three lifecycles work with async factories and async scopes:

async def make_async_pool() -> CachePool:
    return CachePool()


container = (
    ContainerBuilder()
    .bind(CachePool, make_async_pool, lifecycle=Lifecycle.SINGLETON)
    .build()
)
async with container.ascope() as scope:
    pool = await container.aresolve(CachePool)

async with container.ascope(): mirrors with container.scope(): and awaits aclose on teardown targets.

Mixing lifecycles

The lifecycles compose freely. A typical web application has:

  • SINGLETON: connection pool, cache, config, logger.
  • SCOPED: request, session, transaction.
  • TRANSIENT: query builders, response formatters, anything stateless that needs no caching.
container = (
    ContainerBuilder()
    .bind(ConnectionPool, ConnectionPool, lifecycle=Lifecycle.SINGLETON)
    .bind(Session, Session, lifecycle=Lifecycle.SCOPED)
    .bind(QueryBuilder, QueryBuilder, lifecycle=Lifecycle.TRANSIENT)
    .build()
)

Per request: open a scope, resolve a Session, resolve a QueryBuilder, run business logic, exit the scope. The ConnectionPool is reused across every request; the Session is fresh per request; each QueryBuilder is fresh per call.

Where to go next