Services
How τjs’s service registry works for organising data access and business logic.
Overview
Section titled “Overview”τjs provides a service registry pattern for applications that want separation between route handlers and business logic. This is optional - routes can handle data directly or delegate to services.
Key components:
defineService- Create service definitionsdefineServiceRegistry- Register services for runtime dispatchServiceContext- Context passed to service methodsServiceDescriptor- Declarative service call specificationctx.call- Imperative service composition
Basic Service Definition
Section titled “Basic Service Definition”Services are collections of related methods:
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 from the callerctx- ServiceContext with logging, tracing, auth, and composition capabilities
Service methods must return:
- Plain objects (JSON-serialisable)
- Never primitives, never class instances
Service Registry
Section titled “Service Registry”Register all services in one place:
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 - τjs calls the service for you:
{ path: '/users/:id', attr: { render: 'ssr', data: async (params) => ({ serviceName: 'UserService', serviceMethod: 'getUser', args: { id: params.id } }) }}What happens:
- Route handler returns the descriptor
- τjs looks up
UserServicein the registry - τjs calls
UserService.getUser({ id: params.id }, serviceContext) - Result flows to renderer
2. ctx.call (Imperative)
Section titled “2. ctx.call (Imperative)”Call services directly in your route handler:
{ 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 }; } }}ServiceContext
Section titled “ServiceContext”Every service method receives a ServiceContext:
type ServiceContext = { signal?: AbortSignal; // Request cancellation deadlineMs?: number; // Timeout deadline (not enforced by framework) traceId?: string; // Request correlation ID logger?: Logs; // Request-scoped logger user?: { // Authenticated user (if auth middleware enabled) id: string; roles: string[]; } | null; call?: ( // Service-to-service composition service: string, method: string, args?: JsonObject ) => Promise<JsonObject>;};Using Context
Section titled “Using Context”export const UserService = defineService({ updateUser: async (params: { id: string; name: string }, ctx) => { // Logging with trace context ctx.logger?.info({ userId: params.id }, "Updating user");
// Authorisation check if (!ctx.user) { throw new Error("Authentication required"); }
if (ctx.user.id !== params.id && !ctx.user.roles.includes("admin")) { throw new Error("Unauthorised"); }
// Check for cancellation 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”The framework provides a withDeadline helper to combine a parent signal with a timeout:
import { withDeadline } from "@taujs/server/config";
export const UserService = defineService({ slowOperation: async (params: { id: string }, ctx) => { // Create a signal that aborts after 5 seconds OR when parent aborts 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, we use
Error('Aborted').- If the deadline fires, we use
Error('DeadlineExceeded').Not all APIs preserve
AbortSignal.reason. Some (including somefetchimplementations) always throw a genericAbortError/DOMExceptionwith a message like"This operation was aborted", ignoring the custom reason. That’s expected and doesn’t change the timeout semantics - it just affects what error object you actually see.
Service Composition
Section titled “Service Composition”Services can call other services using ctx.call:
export const OrderService = defineService({ getOrderDetails: async (params: { orderId: string }, ctx) => { const order = await ctx.call("OrderService", "getOrder", { id: params.orderId, }); const user = await ctx.call("UserService", "getUser", { id: order.userId }); const products = await ctx.call("ProductService", "getProducts", { ids: order.productIds, });
return { order, 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”Enable autocomplete and type checking for ctx.call by creating a type augmentation file:
import type { RegistryCaller } from "@taujs/server/config";import type { serviceRegistry } from "./services";
declare module "@taujs/server/utils/DataServices" { interface ServiceContext { call: RegistryCaller<typeof serviceRegistry>; }}With augmentation:
- ✅ Autocomplete for service names
- ✅ Autocomplete for method names
- ✅ Type-checked parameters
- ✅ Inferred return types
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";
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 (using
.parse) or any synchronous function(u: unknown) => T. Bothparamsandresultparsers are optional - use them where validation is needed.
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 error responses:
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 pure async functions - test them without HTTP or routes:
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" ); });
it("throws when user not found", async () => { await expect( UserService.getUser({ id: "nonexistent" }, ctx) ).rejects.toThrow("User nonexistent not found"); });});Best Practices
Section titled “Best Practices”1. Return Serialisable Objects Only
Section titled “1. Return Serialisable Objects Only”// Non-primitive, serialisablegetUser: async (params, ctx) => { return { user: { id: "123", name: "Alice" }, created: new Date().toISOString(), };};