ConceptsClass Runtime

Application Lifecycle

OnInit and OnShutdown — duck-typed hooks that run once at boot and once at shutdown, distinct from the per-request lifecycle that runs on every request.

A service that owns a resource — a database pool, a queue subscription, a background timer — needs to open that resource once, before the app takes its first request, and close it once, when the app shuts down. A constructor is the wrong place for either: constructors run the moment something first resolves the class, which can happen earlier than boot and has no equivalent moment for teardown at all.

What you'll learn

  • Understand why app-scoped setup/teardown needs a moment distinct from a class constructor
  • Recognize OnInit/OnShutdown as duck-typed interfaces, not decorators
  • Distinguish the app lifecycle (runs once, at boot and shutdown) from the per-request lifecycle (runs on every request)
  • Choose when a service needs a lifecycle hook versus when plain construction is enough

The problem

A naive database service opens its connection in the constructor and hopes for the best:

// No defined moment for "before traffic" or "on shutdown."
class Database {
  private pool = /* opens a connection pool */ {} as unknown; // opens immediately — but immediately relative to what?

  async query(sql: string) {
    return (this.pool as { query(sql: string): unknown }).query(sql);
  }
  // nothing closes this pool, ever — not even on SIGTERM
}

The pool opens whenever the DI container first resolves Database — which could be before other setup finishes, or lazily on the first request if nothing eager forces resolution first. There's no matching moment to close it: a process exit that skips pool.end() leaves connections open until the OS reclaims them.

Why this matters

An app that opens a resource with no defined shutdown step works fine in development, where the process rarely lives long enough to expose the leak. In production, every redeploy or restart repeats the same missing teardown, and the failure mode is not a crash — it's a slow accumulation of open connections until the database refuses new ones. Because construction order and shutdown order are two separate problems, solving only one (eager service instantiation) without the other (a guaranteed close step) fixes half the bug.

The solution

NextRush defines two lifecycle interfaces — OnInit and OnShutdown — that a @Service, @Repository, or controller class opts into by implementing the matching method. There is no decorator: the class runtime detects a lifecycle hook by checking whether the already-resolved instance has a callable onInit/onShutdown property. registerControllers() (and registerModule(), which reuses it) collects every instance in the reachable service graph that implements a hook, then registers one internal Extension that runs onInit at app.ready() and onShutdown at app.close().

This app lifecycle is a different axis from the request lifecycle: onInit/onShutdown run once, for the whole application, while the request lifecycle's middleware onion runs fresh for every request the server receives.

Core idea

Think of the app lifecycle as the two moments outside the request/response cycle — the instant before the server accepts its first connection, and the instant after it stops accepting new ones. Everything in between is the request lifecycle's territory: routing, middleware, handlers. A service with an onInit/onShutdown method is opting into "run this once, at one of those two boundary moments," not "run this on some request."

Mental model

Loading diagram...

The self-loop on Ready is the point: the app lifecycle passes through that state once and stays there for the application's entire run, while the request lifecycle — context creation, the middleware onion, routing, the handler — repeats independently for every request that arrives during it. onInit runs during the single Initializing transition, before Ready is reached; onShutdown runs during the single ShuttingDown transition, after Ready ends. Neither hook has any per-request equivalent — that's what distinguishes this page from request lifecycle.

Quick example

A database service that opens its pool before the app serves anything, and closes it on shutdown:

database.service.ts
import { Service } from 'nextrush/class';
import type { OnInit, OnShutdown } from 'nextrush/class';

interface Pool {
  end(): Promise<void>;
}
declare function createPool(): Promise<Pool>;

@Service()
class Database implements OnInit, OnShutdown {
  private pool!: Pool;

  async onInit(): Promise<void> {
    this.pool = await createPool();
  }

  async onShutdown(): Promise<void> {
    await this.pool.end();
  }
}

Nothing calls onInit() or onShutdown() directly. Implementing the method with that exact name is the entire contract — no @OnInit() decorator exists.

How it works

Example — register two dependent services and observe hook order:

lifecycle-order.ts
import { Service } from 'nextrush/class';
import type { OnInit, OnShutdown } from 'nextrush/class';

@Service()
class Db implements OnInit, OnShutdown {
  async onInit() { console.log('Db: connecting'); }
  async onShutdown() { console.log('Db: closing'); }
}

@Service()
class Cache implements OnInit, OnShutdown {
  constructor(private db: Db) {}
  async onInit() { console.log('Cache: warming'); }
  async onShutdown() { console.log('Cache: flushing'); }
}

Observation — booting and closing an app wired with both services logs:

Db: connecting
Cache: warming
Cache: flushing
Db: closing

Db.onInit runs before Cache.onInit, because Cache depends on Db. Cache.onShutdown runs before Db.onShutdown — the exact reverse.

Explanation — three things happen, verified against packages/class/src/lifecycle/lifecycle.ts:

  • Collection. collectLifecycleInstances() walks the reachable service graph — every registered controller plus its transitive @Service/@Repository dependencies — reversing a breadth-first traversal so that a service's dependencies are considered before the service itself. For each, it resolves the instance from the DI container and keeps it only if isOnInit() or isOnShutdown() returns true. Controllers are added last, using only their already-resolved cached instance — the collector never force-resolves a controller purely to check for hooks.
  • isOnInit/isOnShutdown are plain property checks, not a prototype walk: typeof value.onInit === 'function'. Any object — a class instance, a plain object literal — with a callable property of that name qualifies.
  • Registration. If the collected list is non-empty, registerLifecycleExtension() adds one internal Extension via app.extend(). Its setup() (run at app.ready()) calls onInit() on every collected instance in collection order — dependencies first. Its destroy() (run at app.close()) calls onShutdown() on the same list reversed — dependents first. If nothing in the graph implements a hook, no Extension is added at all.

Registering after boot throws

registerLifecycleExtension() checks app.isReady || app.isRunning and throws before adding the Extension if either is true — matching Application's rule that extend() only works during the Configuring state. Call registerControllers() / registerModule() before ready()/listen(), never after.

Typical use cases

  • A connection pool or client that must open before the first request — a database, a cache, a message broker client.
  • A background interval or subscription that must stop cleanly — a polling loop, a queue consumer, a file watcher.
  • A resource with an explicit close step required by its own API — anything whose client library exposes a .close()/.end()/.disconnect() that leaks a handle if skipped.

None of these need a hook if the resource has no setup cost and nothing to release — most services fall into that category and implement neither interface.

Configuration

There is no configuration surface for lifecycle hooks themselves — a class opts in purely by implementing onInit/onShutdown with the exact method name, on a class already registered as a @Service, @Repository, or controller. The one setting that affects whether a controller's hooks are detected lives on registerControllers():

registration.ts
import { createApp } from 'nextrush';
import { registerControllers } from 'nextrush/class';

const app = createApp();
await registerControllers(app, { root: './src', validate: false });

validate: false skips boot-time eager resolution of controllers, which keeps controller construction lazy — but a controller only participates in lifecycle collection through its already-resolved cached instance. Disabling validation means a controller implementing a hook goes undetected until something else resolves it. Service- and repository-level hooks are unaffected, because the collector resolves those directly from the container regardless of controller validation.

Performance

  • Complexity — collection is O(n) in the size of the reachable service graph, run once at app.ready(), never on the request path. onInit/onShutdown themselves run once each, for the application's whole life.
  • Memory — the collected instance list is a flat array held by one closure inside the internal Extension; no additional per-service bookkeeping beyond what the DI container already holds for singletons.
  • Scaling — an application with no lifecycle hooks anywhere in its graph gets no Extension at all, so there's zero always-on overhead for the majority of services that never implement either interface.

Security

  • Unhandled onInit rejection. Threat: an async onInit that throws propagates out of app.ready() and fails the entire boot. Why: boot does not complete until every collected onInit has settled — one failing hook blocks the whole application from starting. Safe default: catch a recoverable failure (a database that might be briefly unreachable) inside onInit() itself, and only let genuinely fatal setup failures propagate. Avoid: letting a hook reject for a condition that should degrade gracefully instead of blocking startup entirely.
  • Silent skip on an unresolvable service. Threat: a service whose constructor dependencies can't be satisfied is skipped during hook collection with no error at that point. Why: collectLifecycleInstances() wraps each resolve in a try/catch and continues past a failure, so a broken dependency graph doesn't crash collection itself. Safe default: rely on registerControllers()'s separate eager-validation step to catch a genuinely broken dependency graph — that's the intended place for that failure to surface, not lifecycle collection. Avoid: assuming a missing onInit log line means the hook didn't run; check DI validation errors first.

A hook error during boot fails the whole boot

An async onInit that rejects propagates out of app.ready() and stops the application from starting at all — there is no partial-boot state.

Trade-offs

Why duck typing instead of a decorator — it keeps the contract to one thing: implement a method with the right name. No import to remember beyond the type (optional, for compile-time checking), no annotation to apply on top of @Service(), and it works on any resolved instance, not only ones the registrar constructed itself.

  • Benefits — a service opts in with zero extra syntax beyond the method itself; the collector can detect hooks on plain objects too, not only decorated classes; there's no risk of a decorator applied without the matching method, or a method present with a forgotten decorator.
  • Costs — a typo in the method name (onInt instead of onInit) fails silently: the class never participates, with no error to flag the mistake. TypeScript's structural typing catches this only if you declare implements OnInit.
  • Alternatives — a @OnInit() decorator would give a compile-time-checkable, explicit opt-in, at the cost of one more decorator to import and apply correctly, and would still need a duck-typed detection fallback for plain objects the container didn't construct.
  • Why NextRush chose duck typing — the same reasoning as the rest of the class runtime's detection strategy: minimize what a developer has to remember on top of the method they're already writing.

Decision guide

Implement OnInit/OnShutdown when:

  • ✓ The service opens a connection, subscription, or timer that should live exactly as long as the application does
  • ✓ Skipping teardown would leak a handle, socket, or resource the underlying client library requires you to close

Skip both interfaces when:

  • ✓ The service is stateless, or its state needs no explicit open/close step
  • ✗ Don't reach for onInit to do work that belongs in the request lifecycle instead — per-request setup is a middleware's job, not an app-lifecycle hook's

Common mistakes

  • Adding @OnInit() or @OnShutdown() as decorators. Why it happens: every other class-runtime feature (@Get, @UseGuard) is a decorator, so this looks like a gap. Correct approach: implement the plain method onInit/onShutdown — no decorator exists or is needed. If ignored: TypeScript reports an unknown decorator, or (if invented locally) the decorator has no effect the collector reads.
  • Registering controllers after listen()/ready(). Why it happens: registration and server start look independent, so ordering feels optional. Correct approach: call registerControllers()/registerModule() before ready(), start(), or listen(). If ignored: registerLifecycleExtension() throws, because the app's configuration is already frozen and the Extension can't be added.
  • Expecting a lazily-constructed controller's hooks to run under validate: false. Why it happens: nothing in a controller's own code hints that its hook detection depends on a registration-time flag. Correct approach: keep eager validation on for any controller that implements a lifecycle hook, or accept that hook detection is skipped for it. If ignored: the controller's onInit/onShutdown silently never runs, with no error.
  • Confusing this page's hooks with the per-request lifecycle. Why it happens: "lifecycle" sounds singular, but NextRush has two independent ones. Correct approach: reach for OnInit/OnShutdown for boot/shutdown, and middleware for anything that must run on every request — see request lifecycle. If ignored: per-request setup ends up in onInit, running once for the app's whole life instead of once per request.

Key takeaways

  • OnInit/OnShutdown are duck-typed interfaces — a service participates by implementing onInit/onShutdown, with no decorator.
  • The app lifecycle (this page) runs its two hooks once each, at boot and shutdown; the request lifecycle runs its own pipeline fresh for every request in between.
  • onInit runs in dependency order at app.ready(); onShutdown runs in the exact reverse order at app.close().
  • A controller's hooks are detected only through its already-resolved, eagerly-validated instance — validate: false also disables controller-level hook detection.
  • An async onInit that rejects fails the whole boot; catch a recoverable failure inside the hook rather than letting it propagate.
  • No service in the graph implementing a hook means no lifecycle Extension is added at all — zero always-on cost for applications that don't use this.

Continue learning

Was this helpful?

On this page