Lifecycle Hooks
Lauren runs your providers' lifecycle hooks deterministically —
@post_constructin topological order at startup,@pre_destructin reverse topological order at shutdown, the latter with a bounded per-hook timeout.
The two hooks
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
Rendering diagram...
- Startup — phase 6 of
LaurenFactory.create(...)only computes the order. Singletons are constructed and their@post_constructhooks run inLaurenApp.startup()— triggered by the ASGI lifespanstartupevent (or byTestClient; callawait 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_shutdowncallbacks run (LIFO), then@pre_destructhooks 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:
Rendering diagram...
@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:
Startup (build): Db → Repo → Service
@post_construct: Db.connect → Repo.warm → Service.preload
Shutdown (drain): Service.flush → Repo.close → Db.disconnectThis 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.
await app.shutdown(drain_timeout=10.0) # drain phase capped at 10sInside the shutdown:
| Phase | Bounded by | What runs |
|---|---|---|
| Stop firing | — | The scheduler starts no new job runs; hosted services keep running. |
| Drain | drain_timeout | In-flight requests get a chance to finish. |
| Job runs | job_drain_timeout (default: the rest of drain_timeout) | In-flight scheduled runs finish, or are cancelled. |
| Hosted services | each service's stop_timeout | Stop requested in reverse start order, then cancelled. |
| Event handler drain | what 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 hooks | per-hook timeout | Provider 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
@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
@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
@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:
@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
| Error | When it fires |
|---|---|
LifecycleConfigError | A hook is declared on a non-injectable, or on a method with the wrong signature. |
LifecycleViolationError | A @post_construct is declared on a transient-scoped provider (it would fire on every resolve). |
DestructError | A @pre_destruct hook raised an exception; collected and logged. |
DestructTimeoutError | A @pre_destruct hook exceeded its per-hook timeout. |
DrainTimeoutError | The 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_constructcheap. Anything that can fail and might need retry belongs in a separate "warm" step you can re-run, not in startup. - Plain
defhooks may block, but only briefly. A sync@post_constructruns inline on the event loop — the app staysSTARTING(readiness fails) until it returns; a sync@pre_destructruns in a worker thread, which protects the loop, but long work still delays shutdown completion. Preferasync deffor naturally async I/O and keep sync hooks bounded. - Use
on_shutdownfor 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_destructfor provider-owned resources — connection pools, file handles, subprocesses. These mirror the@post_constructthat opened them. - Don't depend on order between
on_shutdowncallbacks and@pre_destructhooks, except that all callbacks run before any hooks. Within each phase, registration order (LIFO) and topology decide.
Continue to Request & Response →.