QUESTPIE
CodeServices

Lifecycles

What "singleton" and "request" actually do, when each instance is built and thrown away, and the two ways an async create can fail on you.

View markdown

lifecycle is the option that decides when your create runs. namespace only decides where the instance lands.

ValueInstancesSees the callerdispose runs
"singleton" (default)One per processNoOn app.destroy()
"request"One per handler contextYesNever

singleton

Omit lifecycle and you get this. QUESTPIE builds the instance once while the app starts, then hands the same object to every caller forever.

Infrastructure comes up first, in a fixed dependency order that ends with the queue. Your services are built after all of it, so ctx.db, ctx.email and ctx.queue are ready by the time your create runs.

create runs before the first request arrives. The ctx it captured has no session on it. Do not close over one.

// Wrong. `ctx.session` is empty at startup and never changes.
create: (ctx) => ({
	currentUserId: () => ctx.session?.user?.id,
});

// Right. The caller has a live session, so let it pass one in.
create: (ctx) => ({
	async postsFor(userId: string) {
		return ctx.collections.posts.find({ where: { authorId: userId } });
	},
});

The same applies to any state you keep on the instance. A singleton is shared by every request in the process, so a value you cache on it leaks to the next caller.

request

A "request" service is rebuilt every time QUESTPIE assembles a handler context. Its create receives that context. So ctx.session is the caller's session, and ctx.db is the connection the current operation runs on, open transaction included.

src/questpie/server/services/audit.ts
import { service } from "questpie/services";

export default service({
	lifecycle: "request",
	create: (ctx) => {
		const actor = ctx.session?.user?.id ?? "anonymous";
		return {
			async record(action: string) {
				await ctx.collections.auditLog.create({ action, actor });
			},
		};
	},
});

The core module ships two of these. ctx.channels and ctx.crdt are both "request" services that bind themselves to the calling context.

One request is not one instance

QUESTPIE builds a fresh handler context for route execution, for each access check and for each hook. A "request" service is rebuilt at every one of them. Use it to read the caller, not to cache work across a request.

Two restrictions

A "request" service may only use namespace: null or the default "services" bucket. A custom namespace throws when the app starts:

[QUESTPIE] Service "tracker" uses namespace "analytics" but only singleton
services may use custom namespaces. Use namespace: null or "services" for
request-scoped services.

Its create must also be synchronous. The path that builds it cannot await, so returning a promise throws.

dispose does not run

dispose fires for singletons when app.destroy() runs. Nothing calls it for a "request" instance. Release anything you open inside the method that opened it, or keep the resource on a singleton.

Async create

create may return a promise. QUESTPIE awaits it while initializing singletons, so an async create that connects or authenticates is fine on its own.

export default service({
	create: async () => {
		const client = createClient({ url: process.env.REDIS_URL });
		await client.connect();
		return client;
	},
	dispose: async (client) => {
		await client.quit();
	},
});

It stops being fine when another service reads it during its own create. If the async service has not initialized yet, QUESTPIE cannot await it there and throws:

[QUESTPIE] Service "redis" has async create() but was lazily triggered.
Reorder services so "redis" initializes first.

Your services initialize in alphabetical order by key, because that is the order codegen writes them in. So services/cache.ts may read an async services/broker.ts, and services/broker.ts may not read an async services/cache.ts.

Do not build your app on that ordering. Two fixes hold whatever the filenames are:

Keep create synchronous

Return the object immediately and do the async work inside a method. This is the version that never breaks.

// One connection, opened on first use instead of at startup.
const open = async () => {
	const client = createClient({ url: process.env.REDIS_URL });
	await client.connect();
	return client;
};

export default service({
	create: () => {
		let ready: ReturnType<typeof open> | undefined;
		return {
			async get(key: string) {
				const client = await (ready ??= open());
				return client.get(key);
			},
		};
	},
});

Reach for it later

Read the dependency inside a method rather than in the factory body. By the time a handler calls the method, every singleton is up.

create: (ctx) => ({
	async publish(payload: Payload) {
		await ctx.services.broker.send(payload);
	},
});

Circular dependencies

Two factories that read each other are caught at startup, with the cycle in the message:

[QUESTPIE] Circular service dependency detected: billing -> invoices -> billing

Break it the same way as the async case. Move one of the reads out of the factory body and into a method.

  • Services, the file convention, dispose and namespace.
  • Infrastructure, the built-in singletons you configure instead of writing.
  • Hooks, one of the places a fresh handler context gets built.

On this page