Skip to content

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 databaseHelper functions (findOne, findMany, create, update, remove)
  • ✅ Use storageHelper for file operations
  • ✅ Call other base.ts files
  • ✅ Read userId from tryCtx()?.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 to databaseHelper/callFunction
  • ❌ No try/catch
  • ❌ No logging
  • ❌ No ServiceResponse — return raw types (T | null, boolean, etc.)
  • ❌ No external API calls
  • ❌ No client? or userId? positional parameters (use options?: { 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 wrapService and export them from the domain's READ_QUERIES object
  • ✅ Call another domain's queries.server.ts when 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:

  1. Export the wrapped operation from the domain's queries.server.ts and add it to READ_QUERIES.
  2. Add the operation name to the same domain in lib/reads/manifest.ts.
  3. Add direct behaviour coverage for the query callback. lib/reads/boundary.test.ts verifies 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.ts for reads
  • ✅ Call other services' server.ts for commands/orchestration
  • ✅ Make external API calls (AI Hub, geocoding, email)
  • ✅ Use wrapService for pure pass-through wrappers
  • ✅ Use longhand try/catch for orchestration functions
  • ❌ Import other services' base.ts directly

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>>;

wrapService logs its args on every call/success/failure. The sanitizer (cleanLogData in @delta/common) now elides binary blobs — a Buffer, any TypedArray/DataView, or a raw ArrayBuffer is replaced with a [Binary 3.2MB] placeholder instead of being serialized byte-by-byte. This was a production incident: a wrapService-wrapped upload (uploadFormAttachment) took a multi-MB image Buffer as an arg, and the log sanitizer walked every byte twice and then JSON.stringify'd millions of integer keys — ~38s of synchronous CPU per call, which tripped FUNCTION_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:

  • const bindings 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's const exports are still undefined/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:

  • processDocumentParsing
  • processOcrExtraction
  • processEntityMatching
  • processTypeDiscovery
  • processBudgetItemMatching
  • processChartOfAccountsMatching
  • processBudgetDailyAllocation
  • processJustificationAnalysis
  • processTransactionTotalCheck
  • processTaxNumberValidation
  • processCompanyValidation
  • processInvoiceValidation
  • processTransactionDuplicateCheck
  • processPaymentCreation
  • finishTransactionProcessing
  • resolveLinkedIssue
  • continueTransactionProcessing
  • getTransactionEntity
  • getTransactionRelatedData

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) — no client.ts or 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:

  1. Direct browser→storage uploads — streaming a File object through a server action would re-stream it over HTTP and lose upload progress
  2. 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, remove from databaseHelper
  • ✅ Use the condition helpers above for query filtering
  • ✅ Use uploadFile, deleteFile, getStorageUrl from storageHelper
  • ✅ Use authHelper.getCurrentUser() etc.
  • ✅ Use eqFilter (and future helpers) from realtimeHelper for realtime subscriptions
  • ❌ Never call .from(), .rpc(), .auth.*, .storage.* directly in services
  • ❌ Never import SupabaseClient in services — use DatabaseClient (opaque branded type)
  • ❌ Never write raw PostgREST operator strings ('ov', 'filter', col.op.val OR 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)
  );