SaaS Architecture multi-tenant

Per-Tenant Approval Rules

Each ApprovalEngine is created with a tenantId that scopes its templates and instances. Your admin UI saves a chain per tenant, and you turn it into that tenant's template with defineTemplate(). Different tenants, different rules — one shared storage adapter.

Basic plan
Adminfallback
→
Approved
Professional
Manager
→
Director
→
Approved
Enterprise
Manager
→
VP
→
CFO
→
Legalcustom
→
Approved

One engine per tenant, one template per config

Keep a small map of engines keyed by tenant. Transform each tenant's saved config into levels and register it with defineTemplate(). Tenants that haven't configured a chain get a fallback single-approver template.

  • Templates and instances are scoped to the engine's tenantId
  • Fallback chain prevents orphan submissions
  • Tenant can update config at any time with updateTemplate() — in-flight instances keep their template snapshot
  • No restart needed when tenant config changes
tenant-template.ts
import { ApprovalEngine } from 'hierarchical-approval';
import type { ApprovalTemplateConfig } from 'hierarchical-approval';
import { PostgresAdapter } from 'hierarchical-approval/adapters/postgres';

// One engine per tenant: tenantId scopes templates and instances.
// Share the adapter (one pool) across all of them.
const adapter = new PostgresAdapter({ connectionString: process.env.DATABASE_URL });
const engines = new Map<string, ApprovalEngine>();

function engineFor(tenantId: string): ApprovalEngine {
  let engine = engines.get(tenantId);
  if (!engine) {
    engine = new ApprovalEngine({
      adapter,
      tenantId,
      // Resolves every { type: 'role' } approver. The engine calls this with
      // the role only, so close over the tenant here.
      orgProvider: { getUsersByRole: (role) => directory.usersWithRole(role, tenantId) },
    });
    engines.set(tenantId, engine);
  }
  return engine;
}

// The row your admin UI saves for each tenant
interface TenantApprovalConfig {
  levels: Array<{ approverRoles: string[]; requireAll: boolean }>;
}

const FALLBACK_CONFIG: TenantApprovalConfig = {
  levels: [{ approverRoles: ['admin'], requireAll: false }],
};

function toTemplate(config: TenantApprovalConfig): ApprovalTemplateConfig {
  return {
    name: 'tenant-document-approval',
    documentType: 'document',
    levels: config.levels.map((level, index) => ({
      level: index + 1,
      name: `Level ${index + 1}`,
      approvers: level.approverRoles.map((role) => ({ type: 'role', role })),
      mode: level.requireAll ? 'all' : 'any',
    })),
  };
}

// Run when the tenant saves their chain (and once for tenants that never did)
await engineFor(tenantId).defineTemplate(toTemplate(config ?? FALLBACK_CONFIG));

Multi-tenancy introduces unique approval edge cases.

These failures only appear at scale — after your first hundred tenants.

01

Tenant suspended mid-approval

A tenant's subscription lapses while they have three approval workflows in flight. Blocking all operations would freeze legitimate in-progress work and anger approvers who are trying to do the right thing.

suspension-policy.ts
// Block new submissions from suspended tenants while
// allowing existing in-flight approvals to complete
async function submitDocument(tenantId: string, documentId: string, actorId: string, data: Record<string, unknown>) {
  const tenant = await db.query(
    'SELECT status FROM tenants WHERE id = $1',
    [tenantId]
  );
  if (tenant.rows[0]?.status === 'suspended') {
    throw new Error(`Tenant ${tenantId} is suspended. Contact support to reactivate.`);
  }

  return engineFor(tenantId).submit({
    templateName: 'tenant-document-approval',
    documentType: 'document',
    documentId,
    submittedBy: actorId,
    data,
  });
}

// approve() and reject() go straight to the engine, so instances that were
// already in flight finish normally.

Check the tenant's status before submit(); leave approve/reject alone so existing workflows complete. In-flight approvals resolve cleanly.

02

Invalid tenant chain configuration

A tenant admin accidentally saves a chain with no levels or no approvers on a level. Saving it as the tenant's template would be rejected at defineTemplate(), after the admin has already left the settings page.

validate-config.ts
// Validate tenant chain config before saving it
function validateChainConfig(tenantId: string, config: TenantApprovalConfig): string | null {
  const result = engineFor(tenantId).validateTemplate(toTemplate(config));
  if (result.valid) return null;
  return result.errors.map((e) => `${e.field}: ${e.message}`).join('; ');
}

// In your admin API endpoint:
const error = validateChainConfig(tenantId, newConfig);
if (error) {
  return res.status(400).json({ error });
}
await db.query(/* upsert config */);
await engineFor(tenantId).updateTemplate(toTemplate(newConfig));

Validate at save time with engine.validateTemplate(), which returns every problem without persisting anything. Return the messages to the admin UI before the broken config is saved.

03

In-flight instances after config change

A tenant updates their chain to add a CFO level. There are 12 approval instances already in progress. Should the new level apply to them?

1 engine.submit() stores a snapshot of the template on the instance — updateTemplate() doesn't change in-flight instances
2 New submissions after the update use the new template version
3 Notify the tenant admin that existing workflows complete under the old rules — new ones use the new config

Each instance keeps the template it was submitted with, so prior approvals stay valid. Note that updateData() can still recompute the remaining levels of an instance from its own template.

Your next approval workflow
ships faster than the last one.

Drop it into any Node.js project. No database required for local development.

$ npm install hierarchical-approval

Peer dep for Postgres: npm install pg @types/pg