Services
How τjs’s service registry works for organising data access and business logic.
Overview
Section titled “Overview”τjs provides an optional service registry pattern for separating route handlers from business logic and data access. You can use τjs without it but, it becomes valuable when you need consistent cross-cutting behaviour (logging, auth, tracing, retries) across all data access, or when you expect service boundaries to evolve over time.
Key components:
defineService- Create typed service definitions from a collection of async methodsdefineServiceRegistry- Register services for runtime dispatch and type inferenceServiceContext- Base context passed to service methodsTypedServiceContext<R>-ServiceContextplus typedctx.callRegistryCaller<R>- Typed caller derived from a service registryServiceDescriptor- Declarative service call specificationctx.call- Imperative service composition from routes or other services
Basic Service Definition
Section titled “Basic Service Definition”A service is a plain object of async methods. Each method receives params and a ctx:
import { defineService } from "@taujs/server/config";
export const UserService = defineService({ getUser: async (params: { id: string }, ctx) => { ctx.logger?.info({ userId: params.id }, "Fetching user");
const user = await db.users.findById(params.id);
if (!user) { throw new Error(`User ${params.id} not found`); }
return { user }; },
listUsers: async (params: { limit?: number }, ctx) => { const users = await db.users.findMany({ limit: params.limit ?? 10, });
return { users }; },});Service methods receive:
params- Parameters passed by the callerctx- Context with logging, tracing, auth, cancellation, and optional composition
Service methods must return:
- Plain JSON-serialisable objects
- Never primitives
- Never class instances
τjs serialises results for SSR hydration; primitives and class instances don’t round-trip cleanly across the server/client boundary.
Service Registry
Section titled “Service Registry”Register all services in one place. The registry shape drives all type inference downstream - no separate type declarations needed:
import { defineServiceRegistry } from "@taujs/server/config";import { UserService } from "./user.service";import { ProductService } from "./product.service";import { OrderService } from "./order.service";
export const serviceRegistry = defineServiceRegistry({ UserService, ProductService, OrderService,});Pass the registry when creating your server:
import { createServer } from "@taujs/server";import { serviceRegistry } from "./services";import config from "./taujs.config";
await createServer({ fastify, config, serviceRegistry, clientRoot: "./client",});Using Services from Routes
Section titled “Using Services from Routes”Two ways to call services from route handlers:
1. ServiceDescriptor (Declarative)
Section titled “1. ServiceDescriptor (Declarative)”Return a descriptor object and τjs will call the service for you:
{ path: "/users/:id", attr: { render: "ssr", data: async (params) => ({ serviceName: "UserService", serviceMethod: "getUser", args: { id: params.id }, }), },}What happens:
- The route handler returns the descriptor
- τjs looks up
UserServicein the registry - τjs calls
UserService.getUser({ id: params.id }, serviceContext) - The result flows to the renderer
Use this when a route maps directly to a single service call with no coordination logic.
2. ctx.call (Imperative)
Section titled “2. ctx.call (Imperative)”Call services directly inside the route handler when you need to coordinate multiple calls or apply logic between them:
{ path: "/users/:id", attr: { render: "ssr", data: async (params, ctx) => { const user = await ctx.call("UserService", "getUser", { id: params.id }); const posts = await ctx.call("PostService", "getUserPosts", { userId: params.id, });
return { user, posts }; }, },}Prefer ServiceDescriptor for simple mappings. Prefer ctx.call when the route needs to coordinate, transform, or conditionally fetch data.
ServiceContext
Section titled “ServiceContext”Every service method receives a base ServiceContext:
import type { ServiceContext } from "@taujs/server/config";
type ServiceContext = { signal?: AbortSignal; deadlineMs?: number; traceId?: string; logger?: Logs; user?: { id: string; roles: string[]; } | null;};ServiceContext is intentionally the base context only.
If you want typed service-to-service composition, use TypedServiceContext<R>:
import type { TypedServiceContext } from "@taujs/server/config";import type { serviceRegistry } from "./services";
type AppServiceContext = TypedServiceContext<typeof serviceRegistry>;Augmenting ServiceContext
Section titled “Augmenting ServiceContext”Augment ServiceContext with your own app-specific fields:
declare module "@taujs/server/config" { interface ServiceContext { tenantId?: string; requestStartMs?: number; }}Use this for shared context fields your application adds at runtime.
Do not augment
ServiceContext.calldirectly.If you want a typed
ctx.call, useTypedServiceContext<R>. AugmentingcallwithRegistryCaller<typeof serviceRegistry>creates circular type relationships in real applications.
Using Context
Section titled “Using Context”export const UserService = defineService({ updateUser: async (params: { id: string; name: string }, ctx) => { ctx.logger?.info({ userId: params.id }, "Updating user");
if (!ctx.user) { throw new Error("Authentication required"); }
if (ctx.user.id !== params.id && !ctx.user.roles.includes("admin")) { throw new Error("Unauthorised"); }
if (ctx.signal?.aborted) { throw new Error("Request cancelled"); }
const user = await db.users.update({ where: { id: params.id }, data: { name: params.name }, });
return { user }; },});Working with Deadlines
Section titled “Working with Deadlines”Use withDeadline to combine a parent signal with a timeout:
import { withDeadline } from "@taujs/server/config";
export const UserService = defineService({ slowOperation: async (params: { id: string }, ctx) => { const timeoutSignal = withDeadline(ctx.signal, 5000);
const response = await fetch(`https://api.example.com/slow/${params.id}`, { signal: timeoutSignal, });
return await response.json(); },});Abort reasons
withDeadlinesets a structured reason on the abort signal:
- If the parent aborts without a reason, τjs uses
Error("Aborted")- If the deadline fires, τjs uses
Error("DeadlineExceeded")Some APIs do not preserve
AbortSignal.reasonand will still throw a genericAbortErrororDOMException. That does not change the timeout semantics.
Service Composition
Section titled “Service Composition”Services can call other services using ctx.call.
For service-to-service composition, prefer typing each service against the services it depends on rather than the full app registry. That avoids circular type inference while still giving full autocomplete and type checking.
import { defineService } from "@taujs/server/config";import type { TypedServiceContext } from "@taujs/server/config";import { UserService } from "./user.service";import { ProductService } from "./product.service";
type OrderServiceDeps = { UserService: typeof UserService; ProductService: typeof ProductService;};
export const OrderService = defineService({ getOrderDetails: async ( params: { orderId: string }, ctx: TypedServiceContext<OrderServiceDeps>, ) => { const user = await ctx.call("UserService", "getUser", { id: "user_123" }); const products = await ctx.call("ProductService", "getProducts", { ids: ["p1", "p2"], });
return { user, products }; },
getOrder: async (params: { id: string }, ctx) => { const order = await db.orders.findById(params.id);
if (!order) { throw new Error(`Order ${params.id} not found`); }
return { order }; },});Type-Safe Composition
Section titled “Type-Safe Composition”RegistryCaller<R> is fully typed from a registry:
- Service names are checked
- Method names are checked per service
- Args are checked per method
- Return types are inferred
App-Wide Typed Context
Section titled “App-Wide Typed Context”Once your registry exists, you can bind a context type to the full registry:
import { defineServiceRegistry } from "@taujs/server/config";import { UserService } from "./user.service";import { ProductService } from "./product.service";import { OrderService } from "./order.service";
export const serviceRegistry = defineServiceRegistry({ UserService, ProductService, OrderService,});import type { TypedServiceContext } from "@taujs/server/config";import type { serviceRegistry } from "./index";
export type AppServiceContext = TypedServiceContext<typeof serviceRegistry>;Now ctx.call is fully typed anywhere you use AppServiceContext:
import type { AppServiceContext } from "./types";
declare const ctx: AppServiceContext;
const user = await ctx.call("UserService", "getUser", { id: "123" });// service name ^// method name ^// args type ^// result type is inferred from UserService.getUserImportant
Section titled “Important”Use:
declare module "@taujs/server/config" { interface ServiceContext { tenantId?: string; }}Do not use:
declare module "@taujs/server/config" { interface ServiceContext { call: RegistryCaller<typeof serviceRegistry>; }}The second pattern creates circular type relationships and is not recommended.
Validation with Parsers
Section titled “Validation with Parsers”Services support optional runtime validation using parser functions.
Using Zod
Section titled “Using Zod”import { z } from "zod";import { defineService } from "@taujs/server/config";
const UserCreateSchema = z.object({ email: z.string().email(), name: z.string().min(1), age: z.number().int().positive().optional(),});
const UserCreateResultSchema = z.object({ user: z.object({ id: z.string(), email: z.string(), name: z.string(), }),});
export const UserService = defineService({ createUser: { params: (input) => UserCreateSchema.parse(input), result: (output) => UserCreateResultSchema.parse(output), handler: async (params, ctx) => { const user = await db.users.create({ email: params.email, name: params.name, age: params.age, });
return { user }; }, },});Parsers can be Zod schemas with
.parse(...)or any synchronous function of shape(u: unknown) => T.Both
paramsandresultare optional.
Error Handling
Section titled “Error Handling”Service Method Errors
Section titled “Service Method Errors”Errors thrown in service methods are caught by τjs:
export const UserService = defineService({ getUser: async (params: { id: string }, ctx) => { const user = await db.users.findById(params.id);
if (!user) { throw new Error(`User ${params.id} not found`); }
return { user }; },});Structured Errors
Section titled “Structured Errors”Use AppError for structured framework-aware errors:
import { AppError } from "@taujs/server/config";
export const UserService = defineService({ getUser: async (params: { id: string }, ctx) => { const user = await db.users.findById(params.id);
if (!user) { throw AppError.notFound(`User ${params.id} not found`); }
return { user }; },});Testing Services
Section titled “Testing Services”Services are plain async functions, so you can test them without HTTP or route setup.
Testing a simple service
Section titled “Testing a simple service”import { describe, it, expect, vi } from "vitest";import { UserService } from "./user.service";import type { ServiceContext } from "@taujs/server/config";
describe("UserService", () => { const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn(function () { return this; }), };
const ctx: ServiceContext = { traceId: "test-trace-id", logger: mockLogger, };
it("returns user when found", async () => { const result = await UserService.getUser({ id: "123" }, ctx);
expect(result.user).toBeDefined(); expect(result.user.id).toBe("123"); expect(mockLogger.info).toHaveBeenCalledWith( { userId: "123" }, "Fetching user", ); });});Testing a composed service
Section titled “Testing a composed service”import { describe, it, expect, vi } from "vitest";import { OrderService } from "./order.service";import { UserService } from "./user.service";import { ProductService } from "./product.service";import type { TypedServiceContext } from "@taujs/server/config";
type OrderServiceDeps = { UserService: typeof UserService; ProductService: typeof ProductService;};
describe("OrderService", () => { it("calls dependent services", async () => { const ctx: TypedServiceContext<OrderServiceDeps> = { call: vi.fn(async (service, method, args) => { if (service === "UserService" && method === "getUser") { return { user: { id: "user_123", name: "Alice" } }; }
if (service === "ProductService" && method === "getProducts") { return { products: [{ id: "p1" }, { id: "p2" }] }; }
throw new Error(`Unexpected call: ${service}.${method}`); }), };
const result = await OrderService.getOrderDetails( { orderId: "order_123" }, ctx, );
expect(result.user.user.id).toBe("user_123"); expect(result.products.products).toHaveLength(2); });});Best Practices
Section titled “Best Practices”1. Return serialisable objects only
Section titled “1. Return serialisable objects only”getUser: async (params, ctx) => { return { user: { id: "123", name: "Alice" }, created: new Date().toISOString(), };};τjs serialises service results across the SSR boundary. Primitives and class instances don’t round-trip cleanly - always wrap in an object.
2. Keep route handlers thin
Section titled “2. Keep route handlers thin”Routes handle request/response concerns. Services handle business logic and data access. The boundary should be clear.
3. Type service dependencies locally
Section titled “3. Type service dependencies locally”Inside a service, prefer:
type OrderServiceDeps = { UserService: typeof UserService; ProductService: typeof ProductService;};over typing against the full registry while the registry is still being declared.
Inside a service, type ctx against only the services it directly depends on (OrderServiceDeps) rather than the full app registry. Use AppServiceContext only in app-level code written after the registry is fully defined.
4. Use TypedServiceContext after registry creation
Section titled “4. Use TypedServiceContext after registry creation”Don’t augment ServiceContext.call. It creates circular type relationships. Use TypedServiceContext<R> for typed composition.
For app-wide helpers, tests, or post-registry code, bind TypedServiceContext to the full registry.