Service Layer Patterns¶
This document describes the standard patterns for implementing services in the app. Our architecture separates query-only reads from command/orchestration code, with request-scoped context propagation via AsyncLocalStorage (ALS).
Overview¶
Services live under packages/app/src/services/[domain]/ and use these surfaces:
| File | Purpose |
|---|---|
base.ts |
Pure business logic and database CRUD — no external calls, no error handling |
queries.server.ts |
Query-only, RLS-preserving reads; independently lazy-loaded by the generic reads route |
server.ts |
Server-side commands, external services, and complex multi-service orchestration |
client.ts |
Browser-only operations (file uploads direct to storage, realtime subscriptions) |
sana.ts |
WhatsApp bot-specific integrations |
Key principle: every server entry point (API route, server action, RSC, sana webhook) calls withRequestContext or runWithContext once. After that, all downstream databaseHelper calls resolve the client automatically from ALS — no client? threading, no positional userId args.
Request Context¶
How it works¶
AsyncLocalStorage holds a RequestContext for the lifetime of each request:
// lib/requestContext.ts
export type RequestContext = {
requestId: string;
database: DatabaseClient;
logger: DeltaLogger;
userId?: string;
};
Every entry surface wraps its body once:
// Server action
export async function createEntityAction(data: CreateEntityData) {
return withRequestContext(async () => {
return entityServer.createEntity(data);
});
}
// API route
export async function POST(request: Request) {
return withRequestContext(async () => {
const body = await request.json();
const result = await entityServer.createEntity(body);
return Response.json(result);
});
}
// Sana webhook (service-role client — no user session)
export async function POST(request: Request) {
return runWithContext(
{
requestId: request.headers.get('x-request-id') ?? randomUUID(),
database: createServiceRoleClient() as unknown as DatabaseClient,
logger: getServerLogger('sana'),
},
() => handleWebhook(request)
);
}
Reading context in services¶
import { ctx, tryCtx } from '@/lib/context/requestContext';
// Throws if no context (use in server-only code)
const { database, userId } = ctx();
// Returns undefined if no context (safe in shared code)
const context = tryCtx();
const userId = context?.userId;
databaseHelper functions (findOne, findMany, create, update, remove) read ctx().database automatically — you never pass a client.
Base Layer (base.ts)¶
Purpose: pure database CRUD and business logic. No external calls, no error handling, no logging.
Rules:
- ✅ Use
databaseHelperfunctions (findOne,findMany,create,update,remove) - ✅ Use
storageHelperfor file operations - ✅ Call other
base.tsfiles - ✅ Read
userIdfromtryCtx()?.userId— do not accept it as a positional arg - ✅ Accept
options?: { database?: DatabaseClient }when the function needs to work with both the ALS context client and an explicit service-role client — pass it straight through todatabaseHelper/callFunction - ❌ No
try/catch - ❌ No logging
- ❌ No
ServiceResponse— return raw types (T | null,boolean, etc.) - ❌ No external API calls
- ❌ No
client?oruserId?positional parameters (useoptions?: { database?: DatabaseClient }instead)
// services/entity/base.ts
import { findOne, create } from '@/lib/databaseHelper';
import { tryCtx } from '@/lib/context/requestContext';
import { DatabaseTable } from '@/constants/database';
export async function getEntity(entityId: string): Promise<Entity | null> {
return findOne<Entity>(DatabaseTable.ENTITIES, { id: entityId });
}
export async function createEntity(
data: CreateEntityData
): Promise<Entity | null> {
const userId = tryCtx()?.userId;
return create<Entity>(DatabaseTable.ENTITIES, {
...data,
created_by_user_id: userId,
});
}
When a function needs to work with both the ALS context client (user RLS) and an explicit service-role client, accept an options object and pass it straight through:
// services/vault/base.ts
import { callFunction } from '@/lib/databaseHelper';
import type { DatabaseClient } from '@/types/database';
export async function getSecret(
secretName: string,
options?: { database?: DatabaseClient }
): Promise<string> {
const data = await callFunction<string>(
DatabaseFunction.GET_VAULT_SECRET_BY_NAME,
{ secret_name: secretName },
options ?? {} // resolveContext picks up options.database, falls back to ctx().database
);
if (!data) throw new Error(`Secret '${secretName}' not found in vault`);
return data;
}
The caller decides which client to use — base.ts never constructs one:
// From a server action — uses ctx().database (user client, RLS enforced)
await vaultBase.getSecret('my_secret');
// From sana.ts — explicitly passes service-role client
await vaultBase.getSecret('whatsapp_app_secret', {
database: serviceRoleClient,
});
Choosing between options.database and runWithContext¶
Use options?: { database?: DatabaseClient } (the options override) when a single isolated base.ts call needs a different client. It is explicit, local, and avoids ALS overhead:
// One call needs service-role — pass it directly
await authBase.createUser(userData, { database: serviceRoleClient });
Use runWithContext (in actions.ts) when multiple downstream calls all need the same service-role client — set the context once and every databaseHelper call in the chain inherits it automatically:
// Several calls in a flow all need service-role — set context once
export async function createRelationshipAndInviteAction(input) {
return runWithContext(
{
requestId: randomUUID(),
database: createServiceRoleClient() as unknown as DatabaseClient,
logger: getServerLogger(),
},
() => projectServer.createRelationshipAndInvite(input)
);
}
Rule of thumb: one call → options override; multiple calls → runWithContext.
Query Layer (queries.server.ts)¶
Purpose: the complete query-only surface for a domain. React Query reads call these operations through /api/reads/[operation], whose registry loads only the requested domain. Separating reads from server.ts prevents simple lookups from eagerly evaluating Excel, email, WhatsApp, QuickBooks, AI, PDF, and other command-only dependencies.
Rules:
- ✅ Wrap base reads with
wrapServiceand export them from the domain'sREAD_QUERIESobject - ✅ Call another domain's
queries.server.tswhen a read needs query-only composition - ✅ Preserve the request's RLS client; authenticated user identity comes from
ctx().userId, which is populated from the verified JWT claim (claims.sub) - ✅ Keep explicit service-role reads on a separately named surface; never silently elevate a generic read
- ❌ Import
server.ts,actions.ts, providers, processors, or heavy integration modules - ❌ Add compatibility re-exports to
server.ts; consumers must import the query surface directly
// services/entity/queries.server.ts
import { wrapService } from '@/lib/serviceWrapper';
import logger from '@/lib/logger/server';
import * as entityBase from './base';
export const getEntity = wrapService(logger, 'getEntity', entityBase.getEntity);
export const READ_QUERIES = {
getEntity,
} as const;
Registering a read¶
For a new operation in an existing domain:
- Export the wrapped operation from the domain's
queries.server.tsand add it toREAD_QUERIES. - Add the operation name to the same domain in
lib/reads/manifest.ts. - Add direct behaviour coverage for the query callback.
lib/reads/boundary.test.tsverifies that the manifest and registries stay exact and that the source graph excludes heavy integrations.
For a brand-new domain, also add its registry type to lib/reads/contract.ts and a literal dynamic import to lib/reads/serverRegistry.ts. Do not replace the literal loaders with a computed path: the explicit imports give the bundler stable, independently loadable domain chunks.
Measuring the read bundle¶
Next.js .nft.json traces describe the full deployment closure, including lazily referenced chunks. Summing every file in a route's trace therefore overstates what is evaluated on a cold invocation. Verify the initial route/source graph excludes heavy dependencies, inspect the generated entry and per-domain chunks, and keep the boundary test as the regression guard; do not use the aggregate trace size alone as a cold-start measurement.
Server Layer (server.ts)¶
Purpose: server-side orchestration, external service calls, complex multi-service flows.
Rules:
- ✅ Call own
base.ts - ✅ Call other services'
queries.server.tsfor reads - ✅ Call other services'
server.tsfor commands/orchestration - ✅ Make external API calls (AI Hub, geocoding, email)
- ✅ Use
wrapServicefor pure pass-through wrappers - ✅ Use longhand
try/catchfor orchestration functions - ❌ Import other services'
base.tsdirectly
Pure wrappers — use wrapService¶
For functions that just delegate to base with no additional logic:
import { wrapService } from '@/lib/serviceWrapper';
import logger from '@/lib/logger/server';
import * as entityBase from './base';
export const getEntity = wrapService(logger, 'getEntity', entityBase.getEntity);
export const createEntity = wrapService(
logger,
'createEntity',
entityBase.createEntity
);
wrapService signature:
function wrapService<TArgs extends unknown[], TReturn>(
logger: Logger,
label: string,
fn: (...args: TArgs) => Promise<TReturn>
): (...args: TArgs) => Promise<ServiceResponse<TReturn>>;
wrapServicelogs itsargson every call/success/failure. The sanitizer (cleanLogDatain@delta/common) now elides binary blobs — aBuffer, anyTypedArray/DataView, or a rawArrayBufferis replaced with a[Binary 3.2MB]placeholder instead of being serialized byte-by-byte. This was a production incident: awrapService-wrapped upload (uploadFormAttachment) took a multi-MB imageBufferas an arg, and the log sanitizer walked every byte twice and thenJSON.stringify'd millions of integer keys — ~38s of synchronous CPU per call, which trippedFUNCTION_INVOCATION_TIMEOUT. If you add a service that takes binary data, the elision now handles it automatically; do NOT reintroduce per-byte logging.
For null-guard (base returns T | null but you want ServiceResponse<T>):
export const getEntityOrFail = wrapService(
logger,
'getEntityOrFail',
async (id: string) => {
const entity = await entityBase.getEntity(id);
if (!entity) throw new Error(`Entity not found: ${id}`);
return entity;
}
);
⚠️ CRITICAL: Never use wrapService (const) for functions involved in circular module dependencies¶
If a service file is part of a circular import chain, export const fn = wrapService(...) will cause a runtime ReferenceError: Cannot access 'fn' before initialization.
This happens because:
constbindings are not hoisted — they enter the Temporal Dead Zone (TDZ) until the module finishes evaluating- Circular imports mean module A starts evaluating, hits
import B, B starts evaluating and imports A back — but A hasn't finished, so A'sconstexports are stillundefined/TDZ export async function fn()declarations are hoisted and survive circular deps safely
Known circular chain in this codebase:
transaction/server.ts
→ processing/server.ts (imports './processors')
→ processors/transactionProcessors.ts
→ transaction/server.ts ← circular
Any function in transaction/server.ts that is registered as a processor via registerProcessor(...) in transactionProcessors.ts must remain an async function declaration, not a const = wrapService(...). This includes:
processDocumentParsingprocessOcrExtractionprocessEntityMatchingprocessTypeDiscoveryprocessBudgetItemMatchingprocessChartOfAccountsMatchingprocessBudgetDailyAllocationprocessJustificationAnalysisprocessTransactionTotalCheckprocessTaxNumberValidationprocessCompanyValidationprocessInvoiceValidationprocessTransactionDuplicateCheckprocessPaymentCreationfinishTransactionProcessingresolveLinkedIssuecontinueTransactionProcessinggetTransactionEntitygetTransactionRelatedData
The symptom if you accidentally convert one: ReferenceError: Cannot access 'X' before initialization at module evaluation time, deep in the processor registration stack trace.
Orchestration — stay longhand¶
Functions with multi-step logic, conditionals, or cross-service calls must stay longhand:
import logger from '@/lib/logger/server';
import * as entityBase from './base';
import { sendNotification } from '@/services/notification/server';
export async function createEntityWithNotification(
data: CreateEntityData
): Promise<ServiceResponse<Entity>> {
try {
logger.info('Creating entity', { data });
const entity = await entityBase.createEntity(data);
if (!entity) throw new Error('Failed to create entity');
const notifyResult = await sendNotification({
type: 'entity_created',
entityId: entity.id,
});
if (notifyResult.error) {
logger.warn('Notification failed', {
entityId: entity.id,
error: notifyResult.error,
});
}
return { data: entity, error: null };
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.error('createEntityWithNotification failed', err);
return { data: null, error: err };
}
}
Client Layer (client.ts)¶
Purpose: browser-only operations that genuinely cannot run server-side.
Query data fetching lives in queries.server.ts behind the generic reads route; mutations and orchestration live in actions.ts/server.ts. Do not move either into client.ts:
- Server actions run inside the request context (ALS), so
userId, the database client, and the logger resolve automatically — no prop drilling, no manual client construction - Browser-side data calls bypass the request context, require a browser database singleton, and produce no server-side logs or correlation IDs
- React Query hooks call
callRead('getEntity', id)— noclient.tsor read Server Action needed
Do not add a client.ts export unless the operation genuinely must run in the browser. Most services have no client.ts at all.
The two cases that stay in client.ts:
- Direct browser→storage uploads — streaming a
Fileobject through a server action would re-stream it over HTTP and lose upload progress - Realtime subscriptions — must run in the browser
Everything else belongs in actions.ts.
// services/project/client.ts
'use client';
import clientLogger from '@/lib/logger/client';
import * as projectBase from './base';
// Stays browser-side: streams File directly to Supabase Storage
export async function uploadProjectLogo(
file: File,
projectId: string
): Promise<ServiceResponse<{ attachmentId: string; storagePath: string }>> {
try {
const result = await projectBase.uploadProjectLogo(file, projectId);
return { data: result, error: null };
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
clientLogger.error('Failed to upload project logo', err, { projectId });
return { data: null, error: err };
}
}
Sana Layer (sana.ts)¶
Purpose: WhatsApp bot-specific logic. The sana webhook entry uses runWithContext with a service-role client (no user session cookie available). The sana.ts layer wraps server.ts functions with sana-specific formatting.
Logger: use getServerLogger(LoggerApp.SANA) at module level — the same logger instance is used for both wrapService and longhand functions.
Sana pure wrappers — use wrapService¶
For functions that just delegate to base.ts with no additional logic, use wrapService with the sana logger:
// services/vault/sana.ts
import { LoggerApp } from '@delta/common';
import { getServerLogger } from '@/lib/logger/server';
import { wrapService } from '@/lib/serviceWrapper';
import * as vaultBase from './base';
const logger = getServerLogger(LoggerApp.SANA);
export const getSecret = wrapService(logger, 'getSecret', vaultBase.getSecret);
export const getSecrets = wrapService(
logger,
'getSecrets',
vaultBase.getSecrets
);
Sana orchestration — stay longhand¶
Functions with sana-specific formatting, multi-step logic, or post-processing stay longhand:
// services/transaction/sana.ts
import { LoggerApp } from '@delta/common';
import { getServerLogger } from '@/lib/logger/server';
import * as transactionServer from './server';
const logger = getServerLogger(LoggerApp.SANA);
export async function handleTransactionUpload(
fileUrl: string,
phoneNumber: string
): Promise<WhatsAppResponse> {
const result = await transactionServer.createTransactionFromUrl(
fileUrl,
phoneNumber
);
if (result.error) {
logger.error('Transaction upload failed', result.error, { phoneNumber });
return createErrorResponse('Failed to process transaction');
}
return formatWhatsAppResponse(result.data);
}
Server Actions (actions.ts)¶
Server actions are the primary command/orchestration path for client components. Each action wraps its body in withRequestContext; React Query reads use callRead instead:
'use server';
import { withRequestContext } from '@/lib/context/requestContext';
import * as entityServer from './server';
export async function getEntityAction(entityId: string) {
return withRequestContext(() => entityServer.getEntity(entityId));
}
export async function createEntityAction(data: CreateEntityData) {
return withRequestContext(async () => {
return entityServer.createEntity(data);
});
}
For actions that write to tables with no UPDATE RLS for authenticated users (e.g. processing_jobs), use runWithContext with a service-role client explicitly:
import { runWithContext } from '@/lib/context/requestContext';
import { createServiceRoleClient } from '@/lib/database/server/serviceClient';
import { getServerLogger } from '@/lib/logger/server';
import { randomUUID } from 'crypto';
export async function restartProcessingAction(jobId: string) {
return runWithContext(
{
requestId: randomUUID(),
database: createServiceRoleClient() as unknown as DatabaseClient,
logger: getServerLogger(),
},
() => processingServer.restartProcessingJob(jobId)
);
}
ServiceResponse Pattern¶
Only server.ts, client.ts, sana.ts, and actions.ts return ServiceResponse. Base layer always returns raw types.
interface ServiceResponse<T> {
data: T | null;
error: Error | null;
}
Service Communication¶
callRead → queries.server.ts → base.ts
actions.ts → server.ts → base.ts
client.ts → base.ts (browser uploads only)
sana.ts → server.ts → base.ts
queries.server.ts → queries.server.ts (other services, reads only)
server.ts → queries.server.ts / server.ts (other services)
base.ts → base.ts (other services — same pattern, no external calls)
❌ server.ts must not import another service's base.ts directly — use that service's query or server surface.
❌ queries.server.ts must not import any server.ts or command-only integration.
❌ base.ts must not call external APIs or other services' server.ts.
Logger¶
// Server-side (server.ts, actions.ts, API routes)
import logger from '@/lib/logger/server';
// or named logger:
import { getServerLogger } from '@/lib/logger/server';
const logger = getServerLogger('sana');
// Client-side (client.ts, hooks, components)
import clientLogger from '@/lib/logger/client';
There is no separate @/lib/logger/sana — use getServerLogger('sana').
DB Agnosticism Boundary¶
The boundary is: services talk to databaseHelper / storageHelper / authHelper. Swapping those modules + lib/database/ should let us replace Supabase without touching any service file.
Query conditions — use semantic helpers¶
Never write raw PostgREST filter strings or PostgREST-specific operators in base.ts files. Use the semantic helpers from databaseHelper:
import {
conditions, // { column, operator, value } shorthand
orFilter, // OR across multiple conditions
andGroup, // AND sub-group inside an orFilter
notNull, // column IS NOT NULL
arrayOverlaps, // column && {values} (overlap)
arrayContains, // column @> {values} (contains)
notArrayOverlaps, // NOT column && {values}
geoWithin, // ST_DWithin geo filter
} from '@/lib/databaseHelper';
Examples:
// ✅ Semantic — provider-agnostic at the call site
orFilter(
conditions('project_id', 'eq', projectId),
conditions('project_id', 'is', null)
)
// ❌ Raw PostgREST — never in base.ts
{ column: '', operator: 'or', value: 'project_id.eq.abc,project_id.is.null' }
The ConditionOperator type (eq, neq, lt, lte, gt, gte, like, ilike, in, is, not, cs, cd) maps to universal SQL concepts and is safe to use. Do not use 'ov' or 'filter' — these were PostgREST-specific and have been removed from the type.
Realtime filters¶
Use eqFilter from realtimeHelper — never write raw column=eq.value strings:
import { eqFilter } from '@/lib/realtimeHelper';
filter: eqFilter('recipient_user_id', userId);
What this means in practice¶
- ✅ Use
findOne,findMany,create,update,removefromdatabaseHelper - ✅ Use the condition helpers above for query filtering
- ✅ Use
uploadFile,deleteFile,getStorageUrlfromstorageHelper - ✅ Use
authHelper.getCurrentUser()etc. - ✅ Use
eqFilter(and future helpers) fromrealtimeHelperfor realtime subscriptions - ❌ Never call
.from(),.rpc(),.auth.*,.storage.*directly in services - ❌ Never import
SupabaseClientin services — useDatabaseClient(opaque branded type) - ❌ Never write raw PostgREST operator strings (
'ov','filter',col.op.valOR strings) in services
PostgREST join embed strings (e.g. currency:currencies!currency_code(*)) in select clauses are accepted as Supabase-specific. A non-Supabase swap would rewrite those select strings — this is documented in docs/architecture/database/VIEWS_AND_FUNCTIONS.md § "Database Provider Swap Guide".
Example: Full Service Implementation¶
base.ts¶
import { findOne, findMany, create, update } from '@/lib/databaseHelper';
import { tryCtx } from '@/lib/context/requestContext';
import { DatabaseTable } from '@/constants/database';
export async function getEntity(entityId: string): Promise<Entity | null> {
return findOne<Entity>(DatabaseTable.ENTITIES, { id: entityId });
}
export async function createEntity(
data: CreateEntityData
): Promise<Entity | null> {
return create<Entity>(DatabaseTable.ENTITIES, {
...data,
created_by_user_id: tryCtx()?.userId,
});
}
server.ts¶
import logger from '@/lib/logger/server';
import { wrapService } from '@/lib/serviceWrapper';
import * as entityBase from './base';
import { sendNotification } from '@/services/notification/server';
// Pure pass-through — use wrapService
export const getEntity = wrapService(logger, 'getEntity', entityBase.getEntity);
// Orchestration — stay longhand
export async function createEntityAndNotify(
data: CreateEntityData,
notifyUserId: string
): Promise<ServiceResponse<Entity>> {
try {
const entity = await entityBase.createEntity(data);
if (!entity) throw new Error('Failed to create entity');
await sendNotification({
userId: notifyUserId,
type: 'entity_created',
entityId: entity.id,
});
return { data: entity, error: null };
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.error('createEntityAndNotify failed', err);
return { data: null, error: err };
}
}
actions.ts¶
'use server';
import { withRequestContext } from '@/lib/context/requestContext';
import * as entityServer from './server';
export const getEntityAction = (entityId: string) =>
withRequestContext(() => entityServer.getEntity(entityId));
export const createEntityAndNotifyAction = (
data: CreateEntityData,
notifyUserId: string
) =>
withRequestContext(() =>
entityServer.createEntityAndNotify(data, notifyUserId)
);