Skip to content

Twelve-Factor Examples, Factors IX through XII

These examples finish the shared order-service lifecycle. They focus on replacement, verification, operational evidence, and one-off work. Choose a language in any tab group. Every group on this page follows that selection.

Factor IX: Disposability

The web process starts quickly, advertises readiness only after its dependencies are usable, and removes readiness before shutdown drains active work. The worker stops accepting jobs before its grace period expires.

Node signal handlers enter one idempotent shutdown path. The web process stops dispatch, drains readiness, closes Fastify and attached clients, and uses the configured grace deadline.

const stop = onceAsync(async () => {
try {
clearInterval(interval);
await drain(readiness, async () => {
await app.close();
await queue.close();
await store.close();
await tracing?.shutdown();
}, config.SHUTDOWN_GRACE_MS);
} catch {
emit(eventRecord("web", config.RELEASE_ID, "error", "web.shutdown_deadline", {
errorCategory: "shutdown_deadline"
}));
process.exitCode = 1;
}
});
process.once("SIGTERM", () => void stop());
process.once("SIGINT", () => void stop());

Factor IX: Disposability TypeScript source

Evidence to collect: Send the termination signal during active HTTP and worker tasks. Verify readiness fails first, accepted work finishes or retries safely, and the process exits within the configured grace period.

Factor X: Dev/prod parity

Parity focuses on production-significant behavior. The local and CI paths use the same database and queue service types as production while keeping credentials, scale, and topology local.

Vitest starts a disposable PostgreSQL service through Testcontainers. The test uses the production pg client and migrations, so transaction and query behavior are not replaced by an in-memory fake.

beforeAll(async () => {
[postgres, redis] = await Promise.all([
new PostgreSqlContainer("postgres:18-alpine").withDatabase("orders").withUsername("orders").withPassword("local_only").start(),
new GenericContainer("redis:8-alpine").withExposedPorts(6379).start()
]);
databaseUrl = postgres.getConnectionUri();
redisUrl = `redis://${redis.getHost()}:${redis.getMappedPort(6379)}`;
}, 120_000);
afterAll(async () => {
await Promise.all([postgres?.stop(), redis?.stop()]);
});

Factor X: Dev/prod parity TypeScript source

Evidence to collect: List every production-significant difference, name the test layer that covers it, and fail review when a difference has no owner or check.

Factor XI: Logs

Application processes write structured events to standard output and standard error. They do not choose log files, retention periods, or remote storage destinations.

The event builder reads the active OpenTelemetry span and adds its trace identifier when present. The emitter serializes the structured record to standard output.

export function eventRecord(
processType: ProcessType,
releaseId: string,
level: Level,
event: string,
fields: Partial<Pick<EventFields, "orderId" | "jobId" | "attempt" | "errorCategory">> = {},
activeContext: Context = context.active()
): EventFields {
const traceId = trace.getSpan(activeContext)?.spanContext().traceId;
return {
timestamp: new Date().toISOString(),
level,
event,
service: "twelve-factor-orders",
processType,
releaseId,
...(traceId ? { traceId } : {}),
...fields
};
}
export function emit(record: EventFields): void {
process.stdout.write(`${JSON.stringify(record)}\n`);
}

Factor XI: Logs TypeScript source

Evidence to collect: Submit one failing request and reconstruct its path from the external event stream using release, request, and trace identifiers.

Factor XII: Admin processes

The migration command uses the same application packages, dependency graph, configuration loader, and artifact as web and worker processes. The platform runs it as a bounded job with an attributable result.

Commander parses a narrow migration command. The action reuses the validated config, PostgreSQL client, and migration function shipped with web and worker processes.

export async function runAdmin(argv: string[]): Promise<void> {
const config = parseConfig(process.env, "admin");
const pool = createPool(config.DATABASE_URL);
const command = buildAdminCommand(async (target) => {
emit(eventRecord("admin", config.RELEASE_ID, "info", "admin.migration_started"));
await migrate(pool, target);
emit(eventRecord("admin", config.RELEASE_ID, "info", "admin.migration_finished"));
});
try {
await command.parseAsync(argv, { from: "user" });
} finally {
await pool.end();
}
}

Factor XII: Admin processes TypeScript source

Evidence to collect: Tie the migration to an actor, command, release digest, start time, end time, and result without opening an unrestricted shell in a running instance.

Reference releases

The examples on this page are exact excerpts from independently runnable releases: