Service Layer Patterns¶
This document describes the standard patterns for implementing services in the app. Our architecture follows a four-layer pattern with request-scoped context propagation via AsyncLocalStorage (ALS).
Overview¶
Services live under packages/app/src/services/[domain]/ and follow four layers:
| File | Purpose |
|---|---|
base.ts |
Pure business logic and database CRUD — no external calls, no error handling |
server.ts |
Server-side orchestration, external services (AI, geocoding, email), complex multi-service flows |
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.
Server Layer (server.ts)¶
Purpose: server-side orchestration, external service calls, complex multi-service flows.
Rules:
- ✅ Call own
base.ts - ✅ Call other services'
server.ts - ✅ 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>>;
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.
Most data fetching lives in actions.ts (server actions), not client.ts. Prefer server actions for any operation that reads or writes data:
- 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 server actions via
queryFn: () => getEntityAction(id)— noclient.tsneeded
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 data path for client components. Each action wraps its body in withRequestContext:
'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¶
actions.ts → server.ts → base.ts
client.ts → base.ts (browser uploads only)
sana.ts → server.ts → base.ts
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 server.ts.
❌ 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_SCHEMA.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)
);