Skip to content

Services

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

τjs provides an optional service registry pattern for separating route handlers from business logic and data access.

Key components:

  • defineService - Create service definitions
  • defineServiceRegistry - Register services for runtime dispatch
  • ServiceContext - Base context passed to service methods
  • TypedServiceContext<R> - ServiceContext plus typed ctx.call
  • RegistryCaller<R> - Typed caller derived from a service registry
  • 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 by the caller
  2. ctx - Context with logging, tracing, auth, cancellation, and optional composition

Service methods must return:

  • Plain JSON-serialisable objects
  • 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 and τjs will call 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. The route handler returns the descriptor
  2. τjs looks up UserService in the registry
  3. τjs calls UserService.getUser({ id: params.id }, serviceContext)
  4. The result flows to the renderer

Call services directly inside the 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 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>;

Augment ServiceContext with your own app-specific fields:

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

If you want a typed ctx.call, use TypedServiceContext<R>. Augmenting call with RegistryCaller<typeof serviceRegistry> creates circular type relationships in real applications.

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

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

withDeadline sets 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.reason and will still throw a generic AbortError or DOMException. That does not change the timeout semantics.

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.

services/order.service.ts
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>,
) => {
if (!ctx.call) {
throw new Error("Service caller unavailable");
}
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 };
},
});

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

Once your registry exists, you can bind a context type to the full registry:

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,
});
services/types.ts
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.getUser

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.

Services support optional runtime validation using parser functions.

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 params and result are optional.

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

Services are plain async functions, so you can test them without HTTP or route setup.

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

Use routes to coordinate request/response concerns and services for business logic.

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.

4. Use TypedServiceContext after registry creation

Section titled “4. Use TypedServiceContext after registry creation”

For app-wide helpers, tests, or post-registry code, bind TypedServiceContext to the full registry.