Skip to content

Lifecycle and hooks

A Kumiko process moves through four states: starting, ready, draining, and stopped. It validates environment values and the feature graph before opening database connections in boot-check mode or serving traffic in a real boot.

During shutdown, the process becomes unready, stops background consumers, and closes resources through registered shutdown hooks. The hooks run in reverse registration order and share a deadline. /health tells an orchestrator that the process is alive; /health/ready tells it whether traffic should be sent to that process.

starting -> ready -> draining -> stopped

Every API, worker, and all-in-one entry point uses the same four-state lifecycle. Features declare work through the registrar instead of inventing a separate process scheduler. Operators get predictable boot failures and a readiness signal that changes during drain.

This page covers process lifecycle and boot validation. The transaction phases of preSave, postSave, and delete hooks are covered in Events and projections.

The production entry point performs these groups in order:

  1. Validate the composed environment schema. A render-only mode can print the required values and exit.
  2. Resolve Key Manager ciphertext slots and load required environment values.
  3. Compose features, validate the feature graph, and build the registry.
  4. In boot-check mode, report success and exit without opening database or Redis connections.
  5. Check the subject-key provider, then connect to PostgreSQL and Redis.
  6. Verify that checked-in schema migrations are applied and expected tables exist.
  7. Build the dispatcher, auth routes, jobs, event consumers, file providers, and other runtime services.
  8. Apply idempotent boot seeds and pending seed migrations.
  9. Start event consumers and job runners, build the HTTP handler, then mark the lifecycle ready.
yes no Validate environment composed Zod schema Resolve key slots Key Manager ciphertexts Compose features validate graph, build registry Boot-check mode? exit before network connections Connect dependencies KMS, PostgreSQL, Redis Check schema drift migrations already applied Build runtime dispatcher, auth, jobs, consumers Apply boot seeds including seed migrations Start consumers event dispatcher and job lanes State -> ready Validation OK, exit

The diagram groups some implementation details more finely, but the ordering rule is the same: validation precedes traffic, and schema drift stops boot. Production does not apply schema migrations automatically. The deployment runs kumiko schema apply; runProdApp verifies the result.

Before a real boot opens PostgreSQL or Redis, validateBoot checks the complete feature graph. Examples include:

CheckFailure caught before traffic
Every r.requires("x") resolvesMissing hard dependency
Handler and screen references resolveTypo in a qualified name
Dependencies contain no cyclesA requires B, which requires A
Entity and screen names do not collideDuplicate registration
Sort and filter fields support the requested operationInvalid list configuration
Ownership rules name existing roles, claims, and columnsSilent access mismatch
Registrar extension use declares its provider dependencyExtension loaded without its owner
Personal-data annotations and KMS wiring agreePlaintext or unshreddable data path

Validation reports a collection of findings where possible. A failed check keeps the lifecycle in starting and the process exits non-zero.

Run the same graph check without database connections in CI:

Terminal window
bun run boot

Feature code uses registrar declarations:

  • r.hook(...) attaches validation and entity lifecycle behavior.
  • r.job(...) declares event-driven, cron, boot, or manual queue work.
  • r.projection(...) declares an in-transaction read model.
  • r.multiStreamProjection(...) declares an asynchronous event consumer.
  • r.requires(...) and extensions describe boot-time relationships.

A feature does not register a new process startup phase. Runtime entry points start and stop the declared jobs and event consumers.

A postSave or postDelete hook chooses a phase:

  • inTransaction runs before commit. Throwing rolls back the write.
  • afterCommit runs after commit. Throwing is logged and cannot undo the write.

preSave and preDelete run inside the write transaction. Use these hooks for entity behavior, not for process startup.

BullMQ stores job queues and cron schedulers in Redis. A job’s runIn setting chooses the API or worker lane. Event-triggered and manual jobs use the same queue infrastructure. Features declare triggers and concurrency policy; they do not elect a process leader themselves.

SIGTERM and SIGINT call the entry point’s stop path. The lifecycle changes to draining, so readiness returns 503. Registered shutdown hooks then run in last-in, first-out order. Job runners and event consumers stop through these hooks before the entry point closes PostgreSQL and Redis.

SIGTERM or SIGINT State -> draining /health/ready returns 503 Run shutdown hooks last registered runs first Stop background work jobs, event consumers, SSE broker Close connections HTTP server, PostgreSQL, Redis State -> stopped 40 second default drain deadline

Lifecycle drain waits up to 40 seconds by default. It logs hook failures and continues with the remaining hooks. After the deadline it marks the process stopped even if one hook did not finish. Kubernetes needs a termination grace period long enough for that drain.

/health is the liveness endpoint. It returns 200 while the process is alive, including during startup or drain. Kubernetes uses it to decide whether the container needs a restart.

/health/ready is the readiness endpoint when a lifecycle is wired. It returns 200 only in the ready state. During startup, drain, or a failed registered readiness check it returns 503, and the load balancer stops sending new traffic.

A rolling deployment can start a new pod, wait for readiness, then drain the old pod. A feature-graph or schema error prevents the new pod from becoming ready while the old replica continues serving when the deployment strategy and capacity allow it.

Boot validation cannot prove that an external provider is semantically correct or that a business probe succeeds. Deployment verification still needs health checks plus one real application read and write.

The lifecycle-hooks recipe registers validation, save, and delete hooks:

const editorWrite = { access: { roles: ["Admin", "Editor"] } } as const;
export const articlesFeature = defineFeature("blog", (r) => {
const article = r.entity({ name: "article", ...articleEntity });
const articleCreate = r.writeHandler(
defineEntityCreateHandler("article", articleEntity, editorWrite),
);
const articleUpdate = r.writeHandler(
defineEntityUpdateHandler("article", articleEntity, editorWrite),
);
r.writeHandler(defineEntityDeleteHandler("article", articleEntity, editorWrite));
// --- scheduleAfterRollback: give back a reservation made outside the transaction when it does
// not commit. The callback must be idempotent: a failed COMMIT also triggers it.
r.writeHandler({
name: "article:reserve-slot",
schema: z.object({ slot: z.string(), reject: z.boolean() }),
access: editorWrite.access,
handler: async (event, ctx) => {
const { slot, reject } = event.payload;
reservedSlots.add(slot);
ctx.lifecycle.afterRollback?.(async () => {
reservedSlots.delete(slot);
});
if (reject) return writeFailure(new UnprocessableError("slot_rejected"));
return { isSuccess: true as const, data: {} };
},
});

Full source: samples/recipes/lifecycle-hooks.