Skip to content

Services

How τjs’s service registry works for organising data access and business logic.

τ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 definitions
  • defineServiceRegistry - Register services for runtime dispatch
  • ServiceContext - Context passed to service methods
  • ServiceDescriptor - Declarative service call specification
  • ctx.call - Imperative service composition

Services are collections of related methods:

services/user.service.ts
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:

  1. params - Parameters passed from the caller
  2. ctx - ServiceContext with logging, tracing, auth, and composition capabilities

Service methods must return:

  • Plain objects (JSON-serialisable)
  • Never primitives, never class instances

Register all services in one place:

services/index.ts
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:

server/index.ts
import { createServer } from "@taujs/server";
import { serviceRegistry } from "./services";
import config from "./taujs.config";
await createServer({
fastify,
config,
serviceRegistry,
clientRoot: "./client",
});

Two ways to call services from route handlers:

Return a descriptor object - τjs calls the service for you:

taujs.config.ts
{
path: '/users/:id',
attr: {
render: 'ssr',
data: async (params) => ({
serviceName: 'UserService',
serviceMethod: 'getUser',
args: { id: params.id }
})
}
}

What happens:

  1. Route handler returns the descriptor
  2. τjs looks up UserService in the registry
  3. τjs calls UserService.getUser({ id: params.id }, serviceContext)
  4. Result flows to renderer

Call services directly in your route handler:

taujs.config.ts
{
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 };
}
}
}

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>;
};
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 };
},
});

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

withDeadline sets 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 some fetch implementations) always throw a generic AbortError/DOMException with 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.


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 };
},
});

Enable autocomplete and type checking for ctx.call by creating a type augmentation file:

src/taujs-types.d.ts
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

Services support optional runtime validation using parser functions:

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. Both params and result parsers are optional - use them where validation is needed.

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 };
},
});

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 };
},
});

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");
});
});
// Non-primitive, serialisable
getUser: async (params, ctx) => {
return {
user: { id: "123", name: "Alice" },
created: new Date().toISOString(),
};
};