QUESTPIE
Code

Services

A service is a shared object you declare in one file. An SDK client, a cache, a piece of domain logic. QUESTPIE builds it, keeps it for as long as you asked, and hands it to every handler.

View markdown

Where should the Stripe client live? Not in a module-level new that half your files import. Put it in services/ and read it off the handler context.

Declare it

Put a file under services/. Import service from questpie/services. The factory takes no name argument, because the filename is the key.

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

const WORDS_PER_MINUTE = 200;

export default service({
	create: () => ({
		computeReadingTime(content: string): number {
			const words = content
				.replace(/<[^>]*>/g, " ")
				.trim()
				.split(/\s+/).length;
			return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
		},
	}),
});

Then register it. questpie add service blog writes the file and runs this.

questpie generate   # registers blog, types it onto ctx.services.blog

Use it

Hooks, routes, jobs, email templates and seeds all carry the same services.

src/questpie/server/collections/posts.ts
import { collection } from "#questpie/factories";

export const posts = collection("posts")
	.fields(({ f }) => ({
		title: f.text(255).required(),
		content: f.textarea(),
		readingTime: f.number(),
	}))
	.hooks({
		beforeChange: ({ data, services }) => {
			if (data.content) {
				data.readingTime = services.blog.computeReadingTime(data.content);
			}
		},
	});

services.blog is typed to whatever create returned. A misspelled name is a compile error.

Services live on `ctx`, not on `app`

app.collections and app.email exist. app.services does not. Your services reach you through a handler context, or through createContext() from #questpie in a script. app.createContext() is a different call and carries no services.

The filename is the key

services/blog.ts registers as blog. services/capacity-planner.ts registers as capacityPlanner. Codegen turns kebab-case into camelCase and uses the result as the key. Rename the file and you rename the service.

One file is one service. Codegen takes the default export, or the first named export when there is no default. It skips index.ts and names starting with _.

Dependencies come from ctx

There is no deps array. create(ctx) receives the app surface, so a dependency is just something you read off ctx. The container builds that dependency the moment you read it, part way through your own create.

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

export default service({
	create: (ctx) => ({
		async sendWeekly(authorId: string, to: string) {
			const { docs } = await ctx.collections.posts.find({
				where: { authorId },
				limit: 10,
			});
			await ctx.email.sendTemplate({
				template: "weeklyDigest",
				to,
				input: { count: docs.length },
			});
		},
	}),
});

ctx carries db, collections, globals, email, queue, storage, kv, logger, search, realtime, t and app. Your own services sit where their namespace puts them. The default bucket is ctx.services.

Two ways to write it

Both forms build the same state, and each chained method returns a new builder.

service({ lifecycle: "singleton", create: (ctx) => new Cache(ctx) });
service()
	.lifecycle("singleton")
	.create((ctx) => new Cache(ctx));

Lifecycle

lifecycle decides how often create runs. Leave it out and you get "singleton".

ValueWhen create runsUse it for
"singleton" (default)Once, at app startAPI clients, pools, caches, stateless logic
"request"Every time a handler context is builtAnything that reads the caller's session

A singleton never sees the caller

Its create runs at startup, before any request exists. ctx.session is empty there. Take the session as a method argument instead, from the caller that has one.

dispose

dispose(instance) runs on app.destroy(). Use it to close what you opened. The tanstack-start starter wires that call to SIGINT and SIGTERM. On the other starters, call destroyApp() from #questpie yourself.

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

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

namespace

namespace moves the instance somewhere else on ctx.

ValueLands atExample
omitted, or "services"ctx.services[key]ctx.services.billing
nullctx[key]ctx.billing
any other stringctx[namespace][key]ctx.integrations.billing

The chained .namespace() takes string | null, so it cannot say "default". Omit the option instead. An empty string throws.

`null` will not override a built-in

app, db, session, services, queue, email, storage, kv, executor, logger, observability, search, realtime, collections, globals and t land first. A namespace: null service with one of those keys is dropped without a warning. Pick another key.

The built-ins are services too

ctx.db, ctx.email, ctx.queue, ctx.storage, ctx.kv, ctx.logger, ctx.search and ctx.realtime are service() definitions in the core module. Each is declared namespace: null, which is why they sit beside yours on one context. You configure them rather than write them, so Infrastructure covers what each one does.

Two unrelated `email` symbols

questpie/services exports an email that is the email-template factory, covered under Emails. f.email() is a field type. They share a name and nothing else.

Where each topic lives

TopicPage
"request" in full, async create, circular dependenciesLifecycles
Handlers that consume servicesRoutes
Shipping services in a bundle someone else installsModules

Next

Jobs is the same file convention for work that runs off the request path, and its handler resolves services the same way.

On this page