Lauren logoLauren
← Home
Export this page

Lifecycle Hooks

Lauren runs your providers' lifecycle hooks deterministically — @post_construct in topological order at startup, @pre_destruct in reverse topological order at shutdown, the latter with a bounded per-hook timeout.

The two hooks

python
from lauren import injectable, post_construct, pre_destruct

@injectable()
class Db:
    @post_construct
    async def connect(self) -> None:
        self.pool = await asyncpg.create_pool("...")

    @pre_destruct
    async def disconnect(self) -> None:
        await self.pool.close()

Hooks may be async def or plain def. A sync @post_construct runs inline on the event loop (keep it quick, or make it async); a sync @pre_destruct is offloaded to a worker thread so blocking teardown work cannot freeze the loop. A class may declare several hooks of each kind and every one runs: @post_construct hooks run base class first, then in definition order within a class; @pre_destruct hooks run subclass first, then in definition order (so flush declared before close still runs first). Hooks follow the method-decorator inheritance rule: an override without the decorator stops being a hook, and a decorated override runs once, in its own class's position.

When they run

Mermaid Diagram
Scroll to explore

Rendering diagram...

Initializing Mermaid renderer...
  • Startup — phase 6 of LaurenFactory.create(...) only computes the order. Singletons are constructed and their @post_construct hooks run in LaurenApp.startup() — triggered by the ASGI lifespan startup event (or by TestClient; call await app.startup() yourself without lifespan) — in topological order (a dependency's hook completes before its dependent's hook starts). There is no timeout on @post_construct. Then hosted services start, in the same dependency order, and the scheduler is armed; scheduled jobs start firing once the app is ready (StartupComplete).
  • Shutdown — app.shutdown(drain_timeout=...) runs in logged phases: the scheduler stops firing, in-flight requests drain, in-flight job runs are awaited (then cancelled), hosted services stop in reverse start order, on_shutdown callbacks run (LIFO), then @pre_destruct hooks run in reverse topological order (dependents teardown first). Hosted services therefore stop while every dependency is still alive.

Never start a background loop with asyncio.create_task(...) inside @post_construct: nothing supervises or stops it, it can outlive the resources it uses, and it dies with the test client's startup loop. Use @hosted_service for loops and @interval / @cron for recurring work.

The application lifecycle state

The app itself moves through a public state machine, app.lifecycle_state (an AppLifecycleState). Every transition emits LifecycleStateChanged on app.signals:

Mermaid Diagram
Scroll to explore

Rendering diagram...

Initializing Mermaid renderer...

@post_construct hooks run during STARTING; @pre_destruct hooks run while the app is DRAINING, and it is STOPPED once they are done. app.begin_draining() lets a pre-stop hook or a signal handler fail readiness before shutdown() starts, while requests are still served — see Health & Readiness Probes.

Topological ordering, with a picture

If your DI graph is Db ← Repo ← Service, the timeline looks like this:

python
Startup    (build):  Db → Repo → Service
@post_construct:     Db.connect → Repo.warm → Service.preload

Shutdown   (drain):  Service.flush → Repo.close → Db.disconnect

This is what makes lifecycle ordering correct: a Repo whose @post_construct hook expects Db.pool to exist will always find it, because Db.connect ran first. At teardown, Repo lets go of its handle to Db before Db.disconnect closes it.

Timeouts and best-effort teardown

@pre_destruct hooks run with a per-hook timeout (default 10 seconds). If one hangs, Lauren logs a DestructTimeoutError, abandons that hook, and moves on to the next. Teardown never aborts halfway through — every other hook still runs. The timeout applies to both async def and plain def hooks because sync teardown runs in a thread instead of inline on the loop.

python
await app.shutdown(drain_timeout=10.0)   # drain phase capped at 10s

Inside the shutdown:

PhaseBounded byWhat runs
Stop firing—The scheduler starts no new job runs; hosted services keep running.
Draindrain_timeoutIn-flight requests get a chance to finish.
Job runsjob_drain_timeout (default: the rest of drain_timeout)In-flight scheduled runs finish, or are cancelled.
Hosted serviceseach service's stop_timeoutStop requested in reverse start order, then cancelled.
Event handler drainwhat is left of drain_timeout (or EventsModule.for_root(drain_timeout=...))Running @on_event handler tasks finish; the rest are cancelled and reported as EventHandlerFailed.
on_shutdown callbacks— (no timeout; keep them short)User hooks registered via app.on_shutdown(fn) (LIFO order).
@pre_destruct hooksper-hook timeoutProvider teardown in reverse topological order.
Goodbye—Final log line confirming clean shutdown.

Failures inside any callback or hook are captured as DestructError and logged, but do not block the remaining cleanup.

Practical patterns

Open a connection pool at startup, close it at shutdown

python
@injectable()
class Db:
    pool: asyncpg.Pool | None = None

    @post_construct
    async def open(self) -> None:
        self.pool = await asyncpg.create_pool(DSN)

    @pre_destruct
    async def close(self) -> None:
        if self.pool:
            await self.pool.close()

Warm a cache after dependencies are ready

python
@injectable()
class TemplateRegistry:
    def __init__(self, fs: FileSystem) -> None:
        self.fs = fs
        self.tpl: dict[str, str] = {}

    @post_construct
    async def warm(self) -> None:
        for path in await self.fs.list("/templates"):
            self.tpl[path] = await self.fs.read(path)

Flush a metrics queue at shutdown

python
@app.on_shutdown
async def flush_metrics() -> None:
    await metrics_client.flush()

on_shutdown callbacks run before @pre_destruct hooks, so the metrics client is still alive when flush_metrics calls it.

Per-request resource cleanup with aclose

Request-scoped injectables can declare an aclose (or __aexit__-style) method. Lauren awaits it after every request automatically:

python
@injectable(scope=Scope.REQUEST)
class DbSession:
    def __init__(self, db: Db) -> None:
        self.session = db.pool.acquire()

    async def aclose(self) -> None:
        await self.session.release()

Errors and edge cases

ErrorWhen it fires
LifecycleConfigErrorA hook is declared on a non-injectable, or on a method with the wrong signature.
LifecycleViolationErrorA @post_construct is declared on a transient-scoped provider (it would fire on every resolve).
DestructErrorA @pre_destruct hook raised an exception; collected and logged.
DestructTimeoutErrorA @pre_destruct hook exceeded its per-hook timeout.
DrainTimeoutErrorThe drain phase exceeded drain_timeout.
LifecycleViolationError (startup)startup() called again while starting or after it succeeded, or once shutdown began (detail["lifecycle_state"]); retrying after a failed startup is allowed.

All of them are emitted through the structured logger, so production aggregators see them as JSON records you can alert on.

Best practices

  • Keep @post_construct cheap. Anything that can fail and might need retry belongs in a separate "warm" step you can re-run, not in startup.
  • Plain def hooks may block, but only briefly. A sync @post_construct runs inline on the event loop — the app stays STARTING (readiness fails) until it returns; a sync @pre_destruct runs in a worker thread, which protects the loop, but long work still delays shutdown completion. Prefer async def for naturally async I/O and keep sync hooks bounded.
  • Use on_shutdown for things that aren't owned by a provider — flushing a global metrics client, sending a "we're shutting down" event to a service registry, closing a side-channel connection.
  • Use @pre_destruct for provider-owned resources — connection pools, file handles, subprocesses. These mirror the @post_construct that opened them.
  • Don't depend on order between on_shutdown callbacks and @pre_destruct hooks, except that all callbacks run before any hooks. Within each phase, registration order (LIFO) and topology decide.

Continue to Request & Response →.