Skip to content

Lifecycle

Hook into the moments an app comes alive and winds down — #[hooks] tags async methods for the init and shutdown phases the framework drains in order.

A binary has a beginning and an end. Between the container being built and the first request served, providers may need to warm a cache, open a long-lived connection, or check a migration ran. When the process receives a signal, those same providers want a chance to flush, drain, or report before the runtime tears down. The lifecycle hooks are where that work hangs.

A provider opts in by tagging methods on an impl block with #[hooks]. Each tagged method names exactly one phase. The framework collects every hook at link time and drains them phase by phase — init phases after wiring and before serving, shutdown phases after the transports stop.

crates/features/src/blog/service.rs
use nest_rs::core::{hooks, injectable};
#[injectable]
#[derive(Default)]
pub struct BlogService;
#[hooks]
impl BlogService {
#[on_application_bootstrap]
async fn warm(&self) -> anyhow::Result<()> {
tracing::info!(target: "features::blog", entries = 0, "cache warmed");
Ok(())
}
#[on_application_shutdown]
async fn flush(&self) {
tracing::info!(target: "features::blog", pending = 0, "buffers flushed");
}
}

The #[hooks] block is separate from the one #[injectable] decorates — a provider keeps its primary impl for methods, and adds a second, hook-only impl the macro scans. The phase attributes (#[on_application_bootstrap], …) need no import; #[hooks] consumes them.

The host must be a singleton provider — the phases resolve it with Container::get::<Host>(), outside any request, which answers for exactly one registration shape. Three hosts can never satisfy that, and all three are compile errors naming the fix rather than a notice at boot:

HostWhy it cannot
an edge host (#[controller], #[gateway], #[resolver], #[mcp])registers metadata; the instance is built at mount
#[injectable(scope = request)]the container holds a factory, not a value
#[injectable(scope = transient)]resolution would build a throwaway, and the hook’s effects would be dropped

Each decorator records which of these it built, as ProviderResidency::SINGLETON, and #[hooks] reads it. So the refusal is not a marker you can satisfy by implementing it: contradicting what your decorator already wrote is a coherence error. A provider you register by hand with ContainerBuilder::provide is the one case nothing has spoken for, and it says so itself:

apps/api/src/provider.rs
impl ProviderResidency for MyHandWrittenProvider {
const SINGLETON: bool = true;
}

A fourth shape is your composition rather than a contradiction, so it warns at boot instead. A host the container holds under some other key has nothing under Foo itself — bound as providers = [Foo as dyn Trait], imported through a for_root, or registered by hand under a name. Move the methods to a provider listed under its own type. Listing the same host twice is not the repair it looks like: each binding runs the constructor, so the hooks fire on one instance while everything injecting Arc<dyn Trait> holds another.

Two run on the way up, three on the way down. They drain in this fixed order:

PhaseAttributeWhen
Module init#[on_module_init]After the container is built and transports configured, before serving.
Application bootstrap#[on_application_bootstrap]After every module-init hook has run, still before serving.
Module destroy#[on_module_destroy]After the transports stop.
Before shutdown#[before_application_shutdown]After every module-destroy hook has run.
Application shutdown#[on_application_shutdown]Last — after every before-shutdown hook has run.

The container is flat, so the init pair isn’t about per-module resolution order the way a tree-shaped framework would split them — both run against the assembled container. The two names exist so you can express “everyone’s on_module_init finishes before anyone’s on_application_bootstrap starts”: late hooks see the side effects of early ones. The three shutdown phases give the same staged unwind in reverse-intent order.

A hook method is async fn and takes &self. Its return is either:

  • nothing — the hook is infallible (async fn flush(&self)), or
  • Result<(), E> where E: Into<anyhow::Error> — the hook can fail, and the framework folds the error into the phase outcome.
fallible and infallible hooks
#[on_module_init]
async fn check(&self) -> Result<(), MyError> { /* ... */ Ok(()) } // fallible
#[on_module_destroy]
async fn drain(&self) { /* ... */ } // infallible

A hook that needs another provider injects it — there is no attribute to declare “run after OtherService’s hook.” Cross-provider init dependencies are expressed through the DI graph, not the lifecycle registry.

Within a phase, hooks run sequentially in (provider, method) name order — stable across builds, never parallel. The two halves of the lifecycle treat failure differently, and deliberately:

  • Init is strict. on_module_init then on_application_bootstrap run one hook at a time; the first error aborts boot with a message naming the provider, method, and phase. Nothing is listening yet, so a failed warm-up never leaves a half-started server accepting traffic.
  • Shutdown is exhaustive. The three teardown phases run every hook even if one fails — a failure is logged at error on the nest_rs::lifecycle target and the next hook still runs, so one provider’s botched cleanup can’t skip another’s.
Terminal window
Error: lifecycle hook MigrationGuard::check (OnModuleInit) failed: pending migrations

App::run walks a fixed sequence. The hooks bracket the serving window:

build container
→ attach + configure transports
→ on_module_init ┐ strict — first error aborts boot
→ on_application_bootstrap ┘
→ serve (until SIGINT / SIGTERM, or a transport errors)
→ on_module_destroy ┐
→ before_application_shutdown │ exhaustive — every hook runs, errors logged
→ on_application_shutdown ┘

Shutdown is triggered by SIGINT / SIGTERM (a shared cancellation token cancels every transport), or by the first transport that errors — which cancels the rest, then runs the teardown phases.

  • Init — warm a cache, verify migrations applied, register with a service discovery backend, prime a connection pool, assert an external dependency is reachable (fail boot if not).
  • Shutdown — flush a buffer, drain in-flight work, deregister from discovery, emit a final metric or report.

The UsersService in the Publish workspace uses #[on_application_shutdown] to log how many users exist at teardown — a one-method #[hooks] block beside the service’s main impl.

  • Providers — the things that own hooks; built once, shared as Arc<T>.
  • Modules — the composition tree the hooks drain across.
  • Interceptors — per-request wrapping, the other side of “code that runs around your handler” (lifecycle is per-process, interceptors are per-request).
  • Testing / e2e — booting the real AppModule, where App::init() drives the init phases.