From typescript-service-cookbook
TypeScript service design patterns and implementation cookbook. Use this skill when creating or refactoring TypeScript services to reference proven patterns for well-factored, maintainable service architectures.
How this skill is triggered — by the user, by Claude, or both
Slash command
/typescript-service-cookbook:typescript-service-cookbookThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Applications should separate construction from lifecycle management (starting/stopping). The entry point constructs all dependencies and injects them into an Application class, then calls `start()` as a separate step. This separation allows integration tests to construct the application with test configurations and fully control its lifecycle. Components should implement asynchronous `start()` ...
Applications should separate construction from lifecycle management (starting/stopping). The entry point constructs all dependencies and injects them into an Application class, then calls start() as a separate step. This separation allows integration tests to construct the application with test configurations and fully control its lifecycle. Components should implement asynchronous start() methods for initialization and optional stop() methods for graceful shutdown.
// src/Database.ts
export default class Database {
constructor(private readonly config: DatabaseConfig) {}
async start(): Promise<void> {
// Connect to database, run migrations, etc.
}
async stop(): Promise<void> {
// Close database connections
}
}
// src/WebServer.ts
export default class WebServer {
constructor(private readonly config: ServerConfig) {}
async start(): Promise<void> {
// Start HTTP server
}
async stop(): Promise<void> {
// Stop accepting new connections, finish in-flight requests
}
}
// src/Application.ts
export default class Application {
constructor(
private readonly database: Database,
private readonly server: WebServer
) {}
async start(): Promise<void> {
await this.database.start();
await this.server.start();
}
async stop(): Promise<void> {
await this.server.stop();
await this.database.stop();
}
}
// index.ts (production)
const config = loadConfig();
const database = new Database(config.postgres);
const server = new WebServer(config.server);
const application = new Application(database, server);
process.on("SIGINT", () => application.stop());
process.on("SIGTERM", () => application.stop());
await application.start();
Important Guidelines:
index.ts or main.ts) is responsible for constructing all dependencies and wiring them together. This is a fundamental requirement. Instantiating dependencies anywhere other than the entry point undermines the architecture and prevents proper testing, configuration management, and component reusability.start()Configuration should be version-controlled with the code that depends on it (except secrets). While this contradicts 12-factor app wisdom, most applications have a small, known set of environments, so naming them explicitly is pragmatic. Environment variables are problematic: they only support strings and cannot represent structured data like arrays or objects. A hierarchical configuration approach uses a base default.json file that is progressively merged with environment-specific overrides.
// src/Configuration.ts
import { readFileSync as read, existsSync as exists } from 'node:fs';
import { mergeDeepRight as merge } from 'ramda';
export default class Configuration {
static load(filenames: string[]): Record<string, any> {
return Object.freeze(
filenames
.filter((filename) => exists(filename))
.map((filename) => JSON.parse(read(filename, 'utf8')))
.reduce((config, overrides) => merge(config, overrides), {})
);
}
}
// src/init/load-config.ts
import Configuration from "../Configuration.js";
import { hostname } from 'node:os';
export default function loadConfig() {
return Configuration.load([
"config/default.json",
`config/${process.env.APP_ENV || "local"}.json`,
`config/${hostname()}.json`,
"config/runtime.json",
process.env.APP_CONFIG,
].filter(Boolean));
}
Configuration files are loaded and merged in this order:
config/default.json - Base configuration with sensible defaultsconfig/${APP_ENV}.json - Environment-specific overrides (local, test, development, staging, production)config/${hostname}.json - Machine-specific overrides (optional)config/runtime.json - Runtime overrides (optional, typically gitignored)${APP_CONFIG} - Path from environment variable for secrets (optional)Important Guidelines:
NODE_ENV: Third-party libraries use it for their own purposes (e.g., Handlebars only caches templates when NODE_ENV=production), which causes surprising behaviour when set to "staging" or "test"APP_ENV instead: This is application-specific and won't conflict with librariesObject.freeze() to prevent accidental mutationWrap third-party libraries in custom factory modules that enforce organizational standards and best practices. This creates a "pit of success" where correct usage becomes the default. As Jeff Bezos said: "Good intentions don't work, good mechanisms do." Rather than documenting best practices and hoping teams follow them, factory modules make best practices automatic and impossible to bypass.
In microservice architectures, standardizing on a single library isn't sufficient. When issues arise—such as unhandled errors, sensitive data leaks, or missed configuration—fixes must be manually applied to potentially hundreds of services, creating unsustainable overhead. Factory modules solve this by centralizing best practices in one place that all services consume.
// packages/database/src/index.ts
import pg from 'pg';
export function createDatabase(config: DatabaseConfig) {
const pool = new pg.Pool(config);
// Best practice: Always listen for errors to prevent unhandled exceptions
pool.on('error', (err) => {
console.error('Unexpected database error', err);
});
return pool;
}
What this prevents: Unhandled 'error' events that crash the application when database connections fail unexpectedly.
// packages/redis/src/index.ts
import { createClient } from 'redis';
export async function createRedis(config: RedisConfig) {
const client = createClient(config);
// Best practice: Always listen for errors
client.on('error', (err) => {
console.error('Redis error', err);
});
// Best practice: Wait for connection to be truly ready before returning
await new Promise<void>((resolve) => {
client.connect();
client.once('ready', () => resolve());
});
return client;
}
What this prevents: Unhandled Redis errors that crash the application, forgetting to call connect(), and race conditions where the application starts before Redis is truly ready.
// packages/logger/src/index.ts
import pino from 'pino';
export function createLogger(options: LoggerOptions = {}) {
return pino({
level: options.level || 'info',
// Best practice: Always redact sensitive fields
redact: {
paths: ['password', 'authorization', 'cookie'],
censor: '[REDACTED]'
},
// Best practice: ISO timestamps
timestamp: pino.stdTimeFunctions.isoTime,
});
}
What this prevents: Sensitive data appearing in logs, inconsistent timestamp formats.
The Unit of Work pattern groups database operations into a single transaction, ensuring all operations succeed or fail together. Use this pattern only when the orchestration layer (controller, route handler, message handler, etc.) coordinates multiple database interactions, whether directly or through service methods. If your orchestration layer makes only a single database call, a transaction is unnecessary overhead—simply call the service method directly.
Using Node's AsyncLocalStorage allows services to join an active transaction without explicitly passing transaction objects through every function call. This provides transactional guarantees while keeping service code clean and focused.
Critical Principle: Start transactions at a level above individual database operations (typically in route handlers, message listeners, or use case orchestrators) so that multiple service methods can participate in the same transaction. Never start transactions within service methods—this prevents operations from being grouped atomically.
// src/Database.ts
import { AsyncLocalStorage } from 'async_hooks';
import type { PgTransaction } from 'drizzle-orm/pg-core';
export default class Database {
private transactionStorage = new AsyncLocalStorage<PgTransaction<any, any, any>>();
getDatabase() {
const activeTransaction = this.transactionStorage.getStore();
return activeTransaction || this.db;
}
async startTransaction<T>(callback: () => Promise<T>): Promise<T> {
return this.db.transaction(async (newTransaction: PgTransaction<any, any, any>) => {
return this.transactionStorage.run(newTransaction, callback);
});
}
async joinTransaction<T>(callback: (transaction: PgTransaction<any, any, any>) => Promise<T>): Promise<T> {
const existingTransaction = this.transactionStorage.getStore();
if (!existingTransaction) {
throw new Error('No active transaction');
}
return callback(existingTransaction);
}
}
// src/UnitOfWork.ts
export default class UnitOfWork {
constructor(private database: Database) {}
async span<T>(name: string, fn: () => Promise<T>): Promise<T> {
return logger.withContext({ unitOfWork: name }, async () => {
logger.debug(`Starting unit of work: ${name}`);
const result = await this.database.startTransaction(async () => fn());
logger.debug(`Completed unit of work: ${name}`);
return result;
});
}
}
// src/routes/organisation.ts
export default function createOrganisationRoutes(
unitOfWork: UnitOfWork,
organisationService: OrganisationService
) {
async function createOrganisation(c: Context) {
const { name } = await c.req.json();
const organisation = await unitOfWork.span("Create Organisation", async () => {
return await organisationService.createOrganisation({ name }, userId);
});
return c.json(organisation, 201);
}
}
// src/services/OrganisationService.ts
export default class OrganisationService {
constructor(private database: Database) {}
async createOrganisation({ name }: CreateOrganisationRequest, userId: number) {
// Multiple database operations in the same transaction
const organisation = await this._insertOrganisation(name);
await this._assignAdmin(userId, organisation.id);
return organisation;
}
private async _insertOrganisation(name: string) {
return this.database.joinTransaction(async (transaction) => {
const [organisation] = await transaction
.insert(organisationSchema)
.values({ name })
.returning();
return organisation;
});
}
private async _assignAdmin(userId: number, organisationId: number) {
return this.database.joinTransaction(async (transaction) => {
await transaction
.insert(organisationMemberSchema)
.values({ userId, organisationId, isAdmin: true });
});
}
}
Important Guidelines:
unitOfWork.span() only when the orchestration layer coordinates multiple database operations; skip it for single database callsunitOfWork.span() to start them, not direct database transaction callsunitOfWork.span() in route handlers, message listeners, or orchestrators—never in servicesjoinTransaction() to participate in the active transactionjoinTransaction() should throw if no transaction is activeUse AsyncLocalStorage to automatically propagate context (like requestId, userId, etc.) through all async operations without manually passing it as parameters. This creates ambient context that flows through the entire request lifecycle, enriching all log statements automatically.
// src/Logger.ts
import { AsyncLocalStorage } from "node:async_hooks";
const asyncLocalStorage = new AsyncLocalStorage<Record<string, any>>();
export const instance = {
debug: (message: string, context?: any): void => {
log(LogLevel.DEBUG, message, context);
},
info: (message: string, context?: any): void => {
log(LogLevel.INFO, message, context);
},
error: (message: string, context?: any): void => {
log(LogLevel.ERROR, message, context);
},
withContext: <T>(context: Record<string, any>, fn: () => T): T => {
return asyncLocalStorage.run(context, fn);
},
};
function log(level: LogLevel, message: string, context: any = {}): void {
const store = asyncLocalStorage.getStore() || {};
// Merge ambient context with explicitly provided context
emit(ApplicationEvents.LOG, { level, message, context: { ...store, ...context } });
}
// In middleware - set context for entire request
export function requestLogger() {
return async (c: Context, next: Next) => {
const requestId = randomUUID();
const requestContext = { requestId, method: c.req.method, path: c.req.path };
return logger.withContext(requestContext, async () => {
logger.info(`HTTP ${c.req.method} ${c.req.path} started`);
await next();
logger.info(`HTTP ${c.req.method} ${c.req.path} completed`);
});
};
}
// In services - logs automatically include requestId
export class UserService {
async createUser(data: CreateUserInput) {
logger.info('Creating user', { email: data.email });
// Log output includes: { requestId: '...', email: '...' }
}
}
Important Guidelines:
withContext() in middleware, not deep in business logicAutomatically log all HTTP requests and responses with metadata like duration, status code, and request ID. This middleware wraps each request in a logging context and captures start/completion events without manual instrumentation.
// src/middleware/RequestLogger.ts
import { Context, Next } from "hono";
import { randomUUID } from "node:crypto";
export function requestLogger() {
return async (c: Context, next: Next) => {
const requestId = randomUUID();
const startTime = Date.now();
const method = c.req.method;
const path = c.req.path;
const requestContext = { requestId, method, path };
return logger.withContext(requestContext, async () => {
logger.info(`HTTP ${method} ${path} started`, { method, path });
await next();
const duration = Date.now() - startTime;
const status = c.res.status;
logger.info(`HTTP ${method} ${path} ${status}`, { method, path, status, duration });
});
};
}
// src/routes/index.ts
export default function createRoutes(database: Database) {
const app = new Hono();
// Apply to all /api routes
app.use("/api/*", requestLogger());
app.route("/api/users", userRoutes);
app.route("/api/organisations", organisationRoutes);
return app;
}
Important Guidelines:
Define fine-grained domain-specific error classes that express business problems, independent of HTTP concerns. A centralized error handler then maps these domain errors to appropriate HTTP errors with status codes. This strict separation ensures domain logic remains decoupled from transport layer concerns.
// src/errors/domain/ValidationError.ts
export default class ValidationError extends Error {
constructor(public details: any) {
super("Validation failed");
this.name = "ValidationError";
}
}
// src/errors/domain/KeyCollisionError.ts
export default class KeyCollisionError extends Error {
constructor(public key: string) {
super(`Resource with key '${key}' already exists`);
this.name = "KeyCollisionError";
}
}
// src/errors/domain/MissingEntityError.ts
export default class MissingEntityError extends Error {
constructor(public entityType: string, public id: string) {
super(`${entityType} with id '${id}' not found`);
this.name = "MissingEntityError";
}
}
// src/errors/http/HttpError.ts
export default class HttpError extends Error {
constructor(
public statusCode: number,
message: string,
public details?: any
) {
super(message);
this.name = "HttpError";
}
}
// src/middleware/errorHandler.ts
export default function createErrorHandler() {
return (error: Error, c: Context) => {
// Map domain errors to HTTP errors
if (error instanceof ValidationError) {
return c.json({
error: "Validation failed",
details: error.details
}, 400);
}
if (error instanceof KeyCollisionError) {
return c.json({
error: error.message
}, 409);
}
if (error instanceof MissingEntityError) {
return c.json({
error: error.message
}, 404);
}
if (error instanceof HttpError) {
return c.json({
error: error.message,
details: error.details
}, error.statusCode);
}
// Unmapped errors become internal server errors
logger.error("Unhandled error", error);
return c.json({ error: "Internal server error" }, 500);
};
}
// src/routes/index.ts
export default function createRoutes(database: Database) {
const app = new Hono();
app.onError(createErrorHandler());
return app;
}
// Services throw fine-grained domain errors
export class UserService {
async createUser(data: unknown) {
const result = CreateUserSchema.safeParse(data);
if (!result.success) {
throw new ValidationError(result.error.issues);
}
if (await this.userExists(result.data.email)) {
throw new KeyCollisionError(result.data.email);
}
return this.database.users.create(result.data);
}
async getUser(id: string): Promise<User> {
const user = await this.database.users.findById(id);
if (!user) {
throw new MissingEntityError('User', id);
}
return user;
}
}
Important Guidelines:
Decouple your application's logging calls from the underlying logging framework by emitting events that a logging bridge listens to. This allows you to swap logging frameworks (pino, winston, logtape) without changing application code, and makes it easy to test logging behavior.
// src/Logger.ts (application-facing)
import { AsyncLocalStorage } from "node:async_hooks";
import { Events as ApplicationEvents } from "./Application.js";
const asyncLocalStorage = new AsyncLocalStorage<Record<string, any>>();
export const instance = {
debug: (message: string, context?: any): void => {
log(LogLevel.DEBUG, message, context);
},
info: (message: string, context?: any): void => {
log(LogLevel.INFO, message, context);
},
error: (message: string, context?: any): void => {
log(LogLevel.ERROR, message, context);
},
withContext: <T>(context: Record<string, any>, fn: () => T): T => {
return asyncLocalStorage.run(context, fn);
},
};
function log(level: LogLevel, message: string, context: any = {}): void {
const store = asyncLocalStorage.getStore() || {};
// Emit event with merged context
process.emit(ApplicationEvents.LOG, {
level,
message,
context: { ...store, ...context }
});
}
// src/init/init-logging.ts (logging framework bridge)
import { getLogger, configure } from "@logtape/logtape";
import { Events as ApplicationEvents } from "../Application.js";
export default async function initLogging(config: any) {
await configure({
sinks: { console: getConsoleSink() },
loggers: [{ category: ["app"], lowestLevel: config.level, sinks: ["console"] }],
});
const logger = getLogger(["app"]);
addLogListeners(logger);
}
function addLogListeners(logger: any) {
process.on(ApplicationEvents.LOG, (data: { level: LogLevel; message: string; context: any }) => {
const method = data.level.toLowerCase();
logger[method](data.message, data.context);
});
}
// Application startup
const config = loadConfig();
await initLogging(config.logging);
// Application code
logger.info('User created', { userId: 123 });
// Event emitted, bridge forwards to LogTape
// In tests
const logEvents: any[] = [];
process.on(ApplicationEvents.LOG, (data) => logEvents.push(data));
// No need to configure full logging framework
Important Guidelines:
Always call .unref() on setInterval and setTimeout to prevent them from keeping the event loop alive—unless the timer represents the primary purpose of the process.
// Background tasks - use unref()
const timer = setInterval(() => checkHealth(), 30000);
timer.unref();
// Primary application work - don't use unref()
setInterval(() => processJobs(), 5000);
npx claudepluginhub cressie176/cressie176-claude-marketplace --plugin typescript-service-cookbookImplements error handling patterns: custom error hierarchies, structured logging, retry strategies, circuit breakers, and graceful degradation. Includes Express global error handler and anti-patterns checklist.
Provides functional programming patterns for Node.js/Deno backends using fp-ts, ReaderTaskEither, and dependency injection. Useful for building type-safe, testable services with typed errors.
Guides NestJS dependency injection using class, value, factory providers, modules, decorators, scopes, and best practices for modular Node.js apps.