QUESTPIE
Build Your BackendBusiness Logic

Services

Singleton and request-scoped services — define once, inject everywhere via AppContext.

Services are reusable units of logic that get injected into AppContext under the services key. Define a service in services/, and it becomes available in every hook, route, and job handler.

Use services for application-specific logic: payment clients, domain workflows, third-party SDK wrappers, calculators, or reusable operations that need access to db, collections, queue, email, or other context services. Built-in infrastructure (db, queue, email, storage, kv, search, realtime, logger) is already injected directly by QUESTPIE.

Defining a Service

services/blog.ts
import { service } from "questpie/services";
const WORDS_PER_MINUTE = 200;

function stripHtml(html: string): string {
	return html.replace(/<[^>]*>/g, " ");
}

export default service({
	lifecycle: "singleton",
	create: () => ({
		computeReadingTime(content: string): number {
			const text = stripHtml(content);
			const words = text
				.trim()
				.split(/\s+/)
				.filter((w) => w.length > 0).length;
			return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
		},

		generateSlug(title: string): string {
			return title
				.toLowerCase()
				.replace(/[^a-z0-9\s-]/g, "")
				.trim()
				.replace(/\s+/g, "-");
		},
	}),
});

The filename becomes the key inside services: services/blog.tsctx.services.blog.

Using Services

Services are available in every handler via services destructuring:

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

export default collection("posts")
	.fields(({ f }) => ({
		title: f.text().required().label("Title"),
		content: f.textarea().label("Content"),
		readingTime: f.number().label("Reading Time"),
		slug: f.text().label("Slug"),
	}))
	.hooks({
		beforeChange: async ({ data, services }) => {
			const { blog } = services;
			if (data.content) {
				data.readingTime = blog.computeReadingTime(data.content);
			}
			if (data.title) {
				data.slug = blog.generateSlug(data.title);
			}
		},
	});
routes/get-post-stats.ts
import { route } from "questpie/services";
import z from "zod";

export default route()
	.post()
	.schema(z.object({ postId: z.string() }))
	.handler(async ({ input, collections, services }) => {
		const post = await collections.posts.findOne({
			where: { id: input.postId },
		});
		return {
			readingTime: services.blog.computeReadingTime(post.content),
			excerpt: services.blog.extractExcerpt(post.content),
		};
	});

Lifecycle

LifecycleCreatedDestroyedUse for
"singleton" (default)Once at app startupApp shutdownExternal clients, SDKs, connection pools
"request"Per requestEnd of requestTenant-scoped DB, user-specific config

Singleton (default)

services/stripe.ts
import { service } from "questpie/services";
import Stripe from "stripe";

export default service({
	lifecycle: "singleton",
	create: () => new Stripe(process.env.STRIPE_SECRET_KEY!),
});

Request-scoped

services/tenant-db.ts
import { service } from "questpie/services";
export default service({
	lifecycle: "request",
	create: ({ db, session }) => {
		return createScopedDb(db, session?.user?.tenantId);
	},
	dispose: (scopedDb) => scopedDb.release(),
});

Dependencies

Service factories receive a ServiceCreateContext directly. Destructure the infrastructure and services you need; there is no separate deps list.

services/analytics.ts
import { service } from "questpie/services";
export default service({
	lifecycle: "singleton",
	create: ({ db, logger, services }) => {
		logger.info("Analytics service initialized");
		return new AnalyticsService(db, services.blog);
	},
});

Available dependencies include: db, logger, kv, email, queue, storage, search, realtime, session, and any other user-defined services.

Keep long-running processes out of services. A service may publish a job with queue.someJob.publish(...), but it should not call queue.listen() or start background loops. Job execution belongs in a worker process; see Jobs and Queue.

Namespaces

By default, user services live under services.*. You can place a singleton service under a custom namespace when that reads better for your app:

services/billing.ts
import { service } from "questpie/services";
export default service()
	.namespace("workflows")
	.create(({ queue }) => ({
		async confirmPayment(paymentId: string) {
			await queue.capturePayment.publish({ paymentId });
		},
	}));

Handlers access it as workflows.billing.confirmPayment(...). Request-scoped services can use the default services namespace or namespace(null) for top-level access, but custom namespaces are singleton-only.

Disposal

Optional dispose function for cleanup:

export default service({
	lifecycle: "singleton",
	create: () => {
		const pool = createConnectionPool();
		return pool;
	},
	dispose: async (pool) => {
		await pool.close();
	},
});
  • Singleton: dispose called at app shutdown
  • Request: dispose called at end of each request

API Reference

service({
  lifecycle?: "singleton" | "request",  // default: "singleton"
  namespace?: string | null,            // default: "services"
  create: (ctx) => TInstance,           // factory function
  dispose?: (instance) => void | Promise<void>,  // cleanup
})
  • Typed Routes — Use services in route handlers
  • Hooks — Use services in lifecycle hooks
  • Jobs — Publish background work from services
  • Codegen — How services appear in AppContext

On this page