packages/server/STYLE.md
Backend-specific conventions for packages/server/*. The root CLAUDE.md covers cross-cutting rules (no any, named params, file order, comments-why-not-what, util exports, etc.) — read it first. This doc only adds what is specific to server code.
A backend service is exported as a function that takes log: FastifyBaseLogger and returns an object literal of methods. No classes, no constructor injection, no this inside methods.
// packages/server/api/src/app/flows/flow/flow.service.ts
export const flowService = (log: FastifyBaseLogger) => ({
async create({ projectId, request, externalId, ownerId, templateId }: CreateParams): Promise<PopulatedFlow> {
const folderId = await getFolderIdFromRequest({ projectId, folderId: request.folderId, folderName: request.folderName, log })
// ...
const savedFlowVersion = await flowVersionService(log).createEmptyVersion(savedFlow.id, { /* ... */ })
// ...
},
async list({ projectIds, platformId, cursorRequest, /* ... */ }: ListParams): Promise<SeekPage<PopulatedFlow>> {
// ...
},
})
Callers instantiate per call site, threading the request logger through:
// inside a controller
const flow = await flowService(request.log).create({ /* ... */ })
Why: each call gets a fresh logger with per-request context, cross-service calls just pass log along (flowVersionService(log).…), and there is no DI framework or lifecycle to manage.
Stateless variant — when the service needs neither logging nor per-request state, export a plain object directly. This is the exception, not the rule.
// packages/server/api/src/app/tables/field/field.service.ts
export const fieldService = {
async create({ request, projectId }: CreateParams): Promise<Field> { /* ... */ },
async createFromState({ projectId, field, tableId }: CreateFromStateParams): Promise<Field> { /* ... */ },
}
Naming:
xxxService — orchestrates business logic, talks to reposxxxHelper — smaller collaborator used by services (e.g. s3Helper, appearanceHelper)xxxUtils — pure utility functions grouped under one object (per root CLAUDE.md util rule)xxxRepo — thin repoFactory(Entity) exportRead the file top-down like a table of contents: imports → exported const (the public surface) → helper functions → types. Helpers are implementation detail and must live below the const they support.
Think of the const as a namespace — it groups the public API. A reader sees what the module does before they see how.
// packages/server/api/src/app/flows/flow/flow.service.ts (shape)
// 1. imports
import { ActivepiecesError, apId, /* ... */ } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
// ...
// 2. repo export
export const flowRepo = repoFactory(FlowEntity)
// 3. the namespace — public contract, scannable first
export const flowService = (log: FastifyBaseLogger) => ({
async create(/* ... */) { /* calls lockFlowVersionIfNotLocked, applyStatusChange */ },
async list(/* ... */) { /* ... */ },
async publish(/* ... */) { /* ... */ },
})
// 4. helpers — implementation detail, below the namespace
const lockFlowVersionIfNotLocked = async ({ flowVersion, userId, /* ... */ }: LockFlowVersionIfNotLockedParams): Promise<FlowVersion> => { /* ... */ }
async function applyStatusChange(params: { /* ... */ }, log: FastifyBaseLogger): Promise<void> { /* ... */ }
// 5. types at the bottom
type CreateParams = { projectId: ProjectId; request: CreateFlowRequest; /* ... */ }
type ListParams = /* ... */
Rules of thumb:
getFolderIdFromRequest in flow.service.ts).log.export const <fileName> — don't export raw functions one-by-oneWhen a module exposes more than one related function or constant, group them under a single export const named after the file. A reader opens the file and sees the whole public API as one object, and callers read as <fileName>.<fn>(…) at the call site — self-documenting even after auto-import.
// ✅ Good — packages/server/utils/src/file-system-utils.ts
export const fileSystemUtils = {
fileExists: async (path: string): Promise<boolean> => { /* ... */ },
threadSafeMkdir: async (path: string): Promise<void> => { /* ... */ },
// ...
}
// caller
await fileSystemUtils.fileExists(path)
// ❌ Bad — raw function exports scattered across the file
export async function fileExists(path: string): Promise<boolean> { /* ... */ }
export async function threadSafeMkdir(path: string): Promise<void> { /* ... */ }
// caller
await fileExists(path) // no file/namespace context at the call site
Rules of thumb:
iptables-lockdown.ts → iptablesLockdown, sandbox-capacity.ts → sandboxCapacity.startEgressProxy, a Fastify plugin like flowController), export it directly. The "group" is a group of one.export class BlockedHostError, export type EgressProxy) — they don't belong inside the namespace const.xxxService, xxxHelper, xxxUtils, xxxRepo — they're all the same pattern under different names (see section 1).ActivepiecesError at boundaries, use tryCatch for recoverable failures@activepieces/shared exports tryCatch / tryCatchSync (see try-catch.ts) that turn throws into a discriminated { data, error } result. Two distinct patterns, each with its place.
ActivepiecesErrorFor "this should not have happened" conditions — missing entities, validation failures, authorization failures — throw ActivepiecesError with an ErrorCode. Let it bubble up to the Fastify error handler.
if (isNil(flow)) {
throw new ActivepiecesError({
code: ErrorCode.ENTITY_NOT_FOUND,
params: { entityType: 'Flow', entityId: id, message: 'Flow not found' },
})
}
Convention: methods named getOne return Thing | null; methods named getOneOrThrow throw. Never mix the two behaviors in one method.
{ data, error } from tryCatchWhen you need to react to a failure rather than propagate it (fallback path, retry, logging-and-continue, attempt-then-check), wrap the call in tryCatch and branch on error. Do not write raw try { ... } catch { ... } for this — it fragments control flow and loses the typed result.
// packages/server/worker/src/lib/execute/jobs/execute-flow.ts
const { data: provisioned, error: provisionError } = await tryCatch(
() => provisionFlowPieces({ flowVersion, platformId: data.platformId, flowId: data.flowId, projectId: data.projectId, log: ctx.log, apiClient: ctx.apiClient }),
)
if (provisionError) {
await reportFlowStatus(ctx, data, FlowRunStatus.INTERNAL_ERROR)
throw provisionError
}
// `provisioned` is narrowed to the success type from here
// packages/server/utils/src/file-system-utils.ts
fileExists: async (path: string): Promise<boolean> => {
const { error } = await tryCatch(() => access(path))
return error === null
},
For sync code, use tryCatchSync the same way.
Rules of thumb:
data with its narrowed non-null type (the discriminated union gives you this for free).{ data: provisioned, error: provisionError }, { data: published, error: publishError }.tryCatch with a throw of the same error — pick one: either recover or propagate.try/catch is reserved for integration glue where the catch block must invoke a side-effect handler (e.g. exceptionHandler.handle(error, log)) and continue down a different strategy. Example: the S3-upload fallback in packages/server/api/src/app/file/file.service.ts.