Architecture SaaS Databases PostgreSQL

Multi-Database Architecture for Enterprise Customers: How We Built BYODB at Positeasy

A hybrid multi-tenant model. Shared SaaS path stays untouched. Enterprise merchants get their own PostgreSQL. One API call to provision. JWT-driven per-request routing.

May 2026 11 min read
TL;DR

We just shipped BYODB (Bring Your Own Database) — a hybrid multi-tenant architecture that gives enterprise customers their own dedicated PostgreSQL database for operational data, while keeping platform-level data (auth, billing, subscriptions) on a shared control plane. Zero behavior change for existing SaaS merchants. One API call to provision a new enterprise tenant. Per-request routing driven by JWT.

The Problem

Positeasy started life as a typical SaaS POS platform — single monolithic PostgreSQL database, ~98 Sequelize models, row-level tenancy via a merchantId column on every operational table. It worked. For thousands of small merchants on shared infra, it still works great.

Then enterprise prospects started showing up with the same three asks:

Row-level tenancy can't satisfy any of these. We needed real, physical database separation — but only for the customers who actually need it.

The Goal

A hybrid architecture where:

The control plane stays unified. The data plane splits.

The Architecture

Two Classes of Model

Every Sequelize model in the codebase is now classified as exactly one of:

Shared Merchant
Control Plane (Platform DB)
merchant user role subscription integrations
Data Plane (same Platform DB)
orders products payments inventory
Enterprise Merchant
Control Plane (Platform DB)
merchant user role subscription tenantDatabaseConfigs
Data Plane (Dedicated Tenant DB)
orders products payments inventory

The classification lives in a single file: src/database/tenant/modelClassification.js. Two arrays — SHARED_MODEL_KEYS and TENANT_MODEL_KEYS — plus a TENANT_MODEL_FILES map that lets the tenant model factory load each model on a per-tenant Sequelize instance.

A boot-time check (assertClassificationCoverage) blocks startup if any model registered in src/models/index.js is missing from the lists. Forgetting to classify a new model is one of the easiest ways to silently leak cross-tenant data — so the app refuses to start until it's fixed.

Per-Request Routing

Every authenticated request flows through two middlewares in order:

verifyMerchantTokenresolveTenantDbhandlerflow

resolveTenantDb reads the JWT-derived merchantId, looks up the merchant's deploymentType from a small in-memory cache (5-min TTL), and attaches the right model bag to ctx:

Controllers then access models through a tiny helper:

const { tenantModels, sharedModels } = require("../database/tenant/getModels");

async function placeOrder(ctx) {
  const { booking: Booking, payment: Payment } = tenantModels(ctx);
  const { merchant: Merchant } = sharedModels(ctx);
  // ...
}node.js

SHARED merchants pass through the exact same code path with zero behavioral change. ENTERPRISE merchants' writes route to their dedicated database.

Connection Caching

Per-request new Sequelize(...) would be a disaster — connection pools are expensive and slow to build. The connection manager keeps a Map<merchantId, { sequelize, models, lastUsedAt }>, lazily builds the pool on first hit, and sweeps idle entries (>30 min unused) every 5 minutes. Graceful shutdown closes them all.

Credentials at Rest

Tenant DB credentials are stored encrypted in a tenantDatabaseConfigs table on the platform DB using AES-256-GCM (reusing the existing EIP_ENCRYPTION_KEY). The control plane is the only thing that ever holds decrypted credentials, in-memory, during a connection build.

One-Call Provisioning

We did not want a runbook every time enterprise sales closed a deal. Provisioning is one POST:

POST /api/v2/POS/admin/tenant/provisionhttp

The endpoint does the whole flow:

Provisioning Flow
1
Validate merchant exists
Lookup in platform DB by merchantId
2
Test the supplied Postgres connection
Fail fast if the host, port, or credentials are wrong
3
Encrypt and persist the credentials
AES-256-GCM into tenantDatabaseConfigs
4
Register all tenant models on a fresh Sequelize instance
Same model factory used at request time
5
Run sequelize.sync({ force: false })
Materialize every tenant table on the new DB
6
Flip merchant deploymentType to ENTERPRISE
Single column update on the platform DB
7
Clear the deployment-type cache
Next request picks up the new routing immediately

If any step fails, the whole thing rolls back cleanly and the merchant stays on the shared DB.

Migrations

Schema changes split by destination:

node scripts/migrate-tenant.js --merchantId=<uuid>
node scripts/migrate-tenant.js --all-enterprisebash

The runner uses umzug v3 under the hood with a small adapter that bridges sequelize-cli's (queryInterface, Sequelize) signature into umzug's context-based one. It tracks SequelizeMeta per tenant DB, so each merchant's migration history is independent.

Background Jobs

Kafka consumers, BullMQ workers and cron jobs don't have a Koa ctx. They resolve the tenant DB directly from merchantId:

const { tenantDb } = await resolveTenantDbForJob(merchantId);node.js

Same connection cache, same model bag, no request scope required.

What We Deliberately Did Not Build

A few things we considered and explicitly skipped:

Cross-database foreign keys

A full audit found zero cross-DB FKs in the existing schema. We use plain UUID columns for cross-DB references (e.g. booking.merchantId) and enforce integrity at the application layer — which we were already doing. No FK gymnastics.

Separate infra for shared operational data

The shared "tenant" view is logical only — same physical Postgres as the platform DB. We can split it later if the enterprise tier grows enough to justify the operational cost. Until then, one database for everyone who hasn't paid for two.

Locked routing

A USE_TENANT_DB_ROUTING=false env flag forces every merchant onto the shared DB regardless of deploymentType. That kill switch lets us deploy the routing code without committing data movement, soak it, then flip it on.

Architecture Diagrams

Three views of the system that mattered most while building it: how a single request gets routed, how the connection cache stays warm, and what happens end-to-end when an enterprise tenant gets provisioned.

1. Per-Request Routing

Two middlewares decide which database a controller sees. The decision is driven entirely by deploymentType on the merchant record — the controllers themselves never branch.

Request lifecycle — JWT to model bag
CLIENT Authenticated request + JWT verifyMerchantToken extracts merchantId from JWT resolveTenantDb lookup deploymentType (5-min cache) attach ctx.sharedDb & ctx.tenantDb SHARED ENTERPRISE PLATFORM DB SINGLETON ctx.tenantDb = shared pool (operational + control plane on the same Postgres) PER-TENANT POOL connection cache lookup build on miss (decrypt creds, new Sequelize, register models) CONTROLLER tenantModels(ctx) · sharedModels(ctx)

2. Connection Cache

One Map keyed by merchantId. Lazy build on first hit, idle eviction by a sweeper, full close on shutdown. Only enterprise merchants ever land in this cache — shared merchants always go through the platform singleton.

Connection manager — lazy build, idle sweep, graceful close
Map<merchantId, PoolEntry> acme-corp-uuid sequelize · models · lastUsedAt: 2s ago HOT megastore-uuid sequelize · models · lastUsedAt: 18min ago WARM old-tenant-uuid sequelize · models · lastUsedAt: 42min ago EVICT REQUEST cache miss → lazy build → insert entry SWEEPER every 5 min evict if idle > 30 min SIGTERM / SIGINT close every pool · drain in-flight queries · exit

3. Provisioning Sequence

One POST. Seven steps. Atomic from the caller's point of view — if any step fails the merchant stays on the shared DB and no partial state is left behind.

Enterprise tenant provisioning — admin to ready merchant
ADMIN CALLER CONTROL PLANE TENANT POSTGRES POST /admin/tenant/provision 1 Validate merchant exists (platform DB) 2 Test connection authenticate() ✓ 3 Encrypt creds → tenantDatabaseConfigs 4 Build fresh Sequelize · register tenant models 5 sync({ force: false }) CREATE TABLE … (all tenant tables) 6 UPDATE merchant SET deploymentType='ENTERPRISE' 7 Clear deploymentType cache (next request flips) 200 OK · merchant provisioned Any step fails → rollback. Merchant stays on shared DB, no half-state persisted.

What We Learned

Classification is the bug surface, not connection management

Once the connection manager works, it just works. The thing that bites you is a developer adding a new model and forgetting to classify it. Then enterprise customers' writes silently land on the platform DB, and you don't notice until someone reads back stale data months later. The boot-time check + a written onboarding guide for src/models/ is more valuable than the entire connection cache.

A kill switch is non-negotiable

Deploying a routing layer without a way to force everything to the old path is how you turn a Tuesday into a Saturday. The env flag costs nothing and buys infinite peace of mind.

Reclassification is expensive — make the default TENANT

We already had to move a handful of models the other direction (summaries and offline-bill staging from TENANT → SHARED). When in doubt, mark new models TENANT.

DirectionCostWhat it takes
TENANT → SHAREDCheapSQL drop on N tenant DBs
SHARED → TENANTExpensiveCopy production data out of the platform DB into each enterprise tenant DB, freeze writes, refactor controllers, and pray

Pick the cheap mistake.

One-call provisioning pays for itself the first time

Building the admin endpoint took a day. Doing it by hand would have taken longer than that on the first enterprise customer alone — and would have been wrong at least once.

If you're running multi-tenant SaaS

…and starting to hear "we need our own database" from your enterprise prospects, the answer doesn't have to be "rewrite everything" or "stay on shared and lose the deal."

A hybrid model is achievable, the routing layer is genuinely small, and the boring parts (classification, kill switch, one-call provisioning, an onboarding guide for the next developer) matter more than the clever parts.

Built on Koa + Sequelize + PostgreSQL.