Skip to content

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


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

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.

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) — no client.ts 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 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, 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_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)
  );