by Qinsys

Architecture

Every decision here, and what it cost

A multi-tenant B2B platform connecting manufacturers to their distributor networks. Manufacturers publish catalogues, prices and terms to their own resellers; resellers order; orders reach the manufacturer's ERP. Below is the shape of it, and the price paid for each choice.

Designed and documented, building now Alaa Tawalbeh, CTO October 2026

01Constraints

Two requirements decide almost everything

Isolation has to be provable. A manufacturer's prices and credit terms are commercially sensitive against other manufacturers, and inside one tenant they are sensitive between competing resellers. Claiming isolation and demonstrating it are different activities, and only one of them survives a security review.

Arabic with right-to-left layout is first-class, not a translation layer added at the end. It reaches the data model, the document renderer and the import pipeline, not just the stylesheet.

Everything below follows from those two, plus a third that is not technical: the system has to be operable by a very small team, so every running service has to earn its place.

02Shape

Three deployables, one repository

Platform architecture and its trust boundary Four tiers. Clients: a browser on a per-tenant hostname, where the tenant is derived from the hostname and never sent by the client, and partner systems calling in by webhook, file drop or push. Public surface: a Next.js web and BFF layer, the only public ingress, which holds the session server-side and signs a context inward, and beside it an inbound integration host with no session and no interface, which forwards to the API the same way. Both cross a single trust boundary; below it there is private networking only. Private: the API, NestJS on Fastify, REST with OpenAPI, identity in-process, no public domain, and a worker running the same codebase as a separate process for the outbox relay, connectors, imports, notifications and PDFs. Data, shared by API and worker: PostgreSQL 16 with row-level security and one schema per module, Redis with BullMQ, and S3-compatible object storage. 01 CLIENTS Browser {tenant}.vmp.app Tenant from hostname Client never sends it Partner systems Machine callers Webhooks · file drop Partner push 02 PUBLIC Web / BFF Next.js Only public ingress Server-side session Signs context inward Integration host Public host Inbound only No session, no UI Same signed context TRUST BOUNDARY Private networking only 03 PRIVATE API NestJS on Fastify REST + OpenAPI Identity in-process No public domain Worker Same codebase Separate process Outbox relay Connectors · imports Notifications · PDFs SHARED BY API + WORKER 04 DATA PostgreSQL 16 Row-level security, one schema per module Redis + BullMQ Job queues, scheduling, retries, locks Object storage S3-compatible: documents, exports, PDFs

The API and the worker are two processes over one modular monolith, not two services. Boundaries are enforced twice, because enforcing them once is a convention and conventions decay.

Enforced in code

// dependency-cruiser, committed, runs in CI
{ name: "no-cross-module-imports",
  severity: "error",
  from: { path: "^packages/core/src/modules/([^/]+)/" },
  to:   { path: "^packages/core/src/modules/(?!$1)([^/]+)/(?!index\\.ts)" } }

Enforced in the database

One schema per module, and no foreign key crosses a schema boundary. A cross-module reference is an id validated through the owning module's public command, inside the same transaction. The check is mechanical:

SELECT c.conname, n1.nspname AS from_schema, n2.nspname AS to_schema
  FROM pg_constraint c
  JOIN pg_class      t1 ON t1.oid = c.conrelid
  JOIN pg_namespace  n1 ON n1.oid = t1.relnamespace
  JOIN pg_class      t2 ON t2.oid = c.confrelid
  JOIN pg_namespace  n2 ON n2.oid = t2.relnamespace
 WHERE c.contype = 'f' AND n1.nspname <> n2.nspname;
-- must return zero rows

Orders freeze the commercial facts they reference as a snapshot, so a later price or catalogue change cannot rewrite history.

03Isolation

The database refuses first

Application code forgetting a tenant predicate must not be able to leak anything. Row-level security is forced, which matters: ENABLE alone exempts the table owner, and the owner is exactly who a migration runs as.

ALTER TABLE ordering.order ENABLE ROW LEVEL SECURITY;
ALTER TABLE ordering.order FORCE  ROW LEVEL SECURITY;   -- owner is not exempt

CREATE POLICY tenant_isolation ON ordering.order
  USING (tenant_id = current_setting('vmp.tenant_id', true)::uuid);

The second argument to current_setting is what makes this fail closed. Unset, it returns NULL, the comparison is NULL, and the query returns zero rows. A bug shows an empty screen, never someone else's price list.

Three roles, and none of them owns the runtime

vmp_migratorOwns every schema. Used only by migrate deploy. Never holds a request.
vmp_appThe runtime. Row-level security applies. Carries tenant and reseller context per transaction.
vmp_systemRouting only: host lookup, outbox claiming, webhook pre-routing. Allow-listed to six tables, under a policy keyed on current_user rather than on a setting any code path could write.

Context is set per transaction, with SET LOCAL so it dies at commit and cannot survive on a pooled connection into somebody else's request:

BEGIN;
SET LOCAL vmp.tenant_id   = '8f3c…';
SET LOCAL vmp.reseller_id = '2b71…';   -- reseller principals only
-- module commands run here
COMMIT;                                 -- settings die with the transaction

Tested, not asserted

Six gates fail the build: a tenant-owned model without a tenant id, against a committed exclusion list naming the few deliberately global tables; row-level security coverage; the system-role allow-list; cross-tenant reads; cross-reseller reads; and a forged or externally supplied context header.

The cost

Setting context per transaction means every protected request holds one pooled connection for the life of that transaction. The connection budget becomes a design constraint: API replicas times pool size, plus the worker pool and the outbox relay, under the managed ceiling with headroom for migrations. Imports and PDF rendering are chunked so background work cannot starve the API.

04Request security

No token ever reaches the browser

On a tenant host the browser holds an opaque, host-only cookie and nothing else. The BFF keeps the identity session token server-side. After login on a fixed authentication host, a single-use hashed token with a one-minute life hands the session to the tenant host. No parent-domain cookies, no wildcard redirects.

Every call inward carries a short-lived signed context, scoped by purpose. The purpose is in the signature, so a webhook context cannot be replayed as a portal request:

X-VMP-Context: v1.<base64url(payload)>.<base64url(hmac_sha256)>

payload {
  pur  "request" | "host_lookup" | "webhook" | "login_handoff"
  tid  tenant id            aud  api audience + environment
  iat  issued at            exp  iat + 60s
  jti  replay id
}

Only request carries a session token; the other three carry none at all. The API validates the session against the database, so revocation is immediate, then validates signature and audience, then re-resolves membership itself. It never trusts the caller's claim of who the user is.

Second factors are mandatory for manufacturer users. Whether the second factor was passed is recorded server-side on the session; a password-only session gets a 403 on manufacturer routes, and there is no "trust this device".

05Delivery

The outbox, and where it breaks

Every side effect is written as a row in the same transaction as the business change, so nothing is enqueued by code that might not have committed. Order matters for purchase orders, so each row carries a sequence per tenant, aggregate and destination.

OutboxEvent
  id  tenant_id  aggregate_type  aggregate_id  destination
  seq                 -- monotonic per (tenant, aggregate, destination)
  payload  payload_version
  status              -- PENDING | CLAIMED | SUCCEEDED | RETRY | DEAD
  attempts  next_attempt_at  last_error  correlation_id

The relay claims only the head of each stream, so two instances cannot reorder deliveries for one order:

WITH head AS (
  SELECT DISTINCT ON (tenant_id, aggregate_id, destination) id
    FROM integrations.outbox_event
   WHERE status IN ('PENDING', 'RETRY')
     AND next_attempt_at <= now()
   ORDER BY tenant_id, aggregate_id, destination, seq
)
UPDATE integrations.outbox_event e
   SET status = 'CLAIMED', attempts = attempts + 1, claimed_at = now()
  FROM head
 WHERE e.id = head.id
   AND e.status IN ('PENDING', 'RETRY')   -- re-checked under the row lock
RETURNING e.*;

That re-check in the WHERE clause is doing the concurrency work, and it is there for a specific reason: PostgreSQL rejects FOR UPDATE together with DISTINCT, so the head selection cannot take the lock itself. Two relays can therefore pick the same id. The second one blocks on the row lock, re-evaluates the predicate after the first commits, and updates nothing. The queue only ever executes what the relay successfully claimed.

The cost, chosen deliberately

Ordering buys head-of-line blocking. A delivery that exhausts its retries parks as DEAD and blocks later events for that aggregate and destination until a human replays or skips it, under audit. Other destinations for the same order keep flowing. Backlog depth and the age of the oldest blocked row are both alerted, so a wedged stream arrives as a page rather than as a customer asking where their order went.

So it breaks as a queue somebody has to look at, rather than as an ERP receiving a cancellation before the order it cancels.

06Correctness

Money, and the three-decimal problem

The Jordanian dinar has three decimal places and the Saudi riyal has two. Get that wrong once in the model and every total is quietly wrong forever, so it is fixed-scale decimal everywhere and rounded only at defined boundaries.

PostgreSQL   NUMERIC(19,6)          Prisma   Decimal
never        JavaScript number, and never a float across an integration

HALF_UP applied only at:  line net -> line tax -> order total
Currency.minor_unit       JOD = 3,  SAR = 2

5 x 4.1235 JOD  =  20.6175  ->  20.618   net
                    3.29888 ->   3.299   tax
                                23.917   gross

The reference fixture carries that line specifically because it forces rounding at both boundaries. If a refactor breaks the rule, the acceptance test fails on an exact total rather than drifting by a fraction nobody notices for a quarter.

Price resolution

What a reseller pays resolves through five levels, first match wins, with fall-through when a book lacks the item:

line override  ->  reseller-owned book  ->  group / contract book
               ->  market book  ->  tenant default book

The resolver returns the source level and version alongside the price, so "why did this reseller see that number" is a lookup rather than an investigation. Carts re-resolve at submission and reject with 422 PRICE_CHANGED rather than silently charging the new figure.

07Integration

Where this kind of platform usually dies

Everything external adapts to canonical contracts. Connectors declare the capabilities they support, and configuration decides which system is authoritative per capability rather than for the integration as a whole. A tenant whose ERP owns pricing but not credit is ordinary, not a special case.

Four routes, first that works wins: configuration alone through the connector registry; an adapter we host; an outbound-only on-premise relay that dials out so we never dial in; and an adapter the customer's own team builds against a published contract.

Because that fourth route exists, the contract is a product. It is versioned MAJOR.MINOR, both supported majors are served at once by the boundary, and a partner adapter passes certification against a sandbox tenant before production. Failures are classified before they are recorded, because the class decides whether a retry is even correct:

RETRYABLE                  backoff, no human
  CONNECTION_TIMEOUT  RATE_LIMITED  EXTERNAL_UNAVAILABLE

FATAL_FOR_THIS_PAYLOAD     do not retry this row
  VALIDATION_FAILED  MAPPING_MISSING  CONTRACT_VERSION_MISMATCH
  BUSINESS_REJECTED        the ERP said no, with its own code

FATAL_FOR_THIS_CONNECTION  stop the stream, alert
  AUTH_FAILED  CREDENTIAL_EXPIRED  OWNERSHIP_VIOLATION  SCHEMA_DRIFT

UNKNOWN                    retryable three times, then fatal

An adapter that cannot classify returns UNKNOWN. It never guesses RETRYABLE to make a failure disappear.

08Rejected

What we said no to

The rejected options say more than the chosen ones, so they are recorded rather than forgotten.

  1. Microservices→modular monolith, boundaries enforced in CI

    Boundaries are cheaper to enforce in-process than over a network, and nothing here needs independent scaling yet.

  2. A database per tenant→one database, row-level security forced

    Operable by a small team. The residency escape hatch is a dedicated cell, not thousands of databases.

  3. A managed identity provider→identity in-process

    One fewer service, identity data stays in our own database, no per-user pricing. We own the MFA bugs too.

  4. Tokens in the browser→a BFF holding the session

    No public API surface, immediate revocation, one place to reason about request security.

  5. Kafka→a PostgreSQL outbox with a Redis queue

    Transactional guarantees for free, and ordering that is actually testable at this scale.

  6. Elasticsearch→PostgreSQL search

    Not needed until relevance or scale proves otherwise.

  7. Python as a first-class service→Node owns behaviour

    One runtime to operate. Optimisation arrives when the workload does.

  8. Splitting the app across two providers→one host, private networking

    Fewer moving parts and one bill. The marketing site is the deliberate exception, since it shares nothing.

  9. GraphQL→REST with code-first OpenAPI

    Contract diffing in CI and a single validation path.

09Strain

Where this design gives first

Every decision above bought something and charged for it. These are the four places the bill comes due, written down now so nobody has to rediscover them at two in the morning.

  1. The connection ceiling

    Context per transaction means a connection per in-flight request, so the ceiling is arithmetic rather than a guess: API replicas times pool size, plus the worker and the relay, under the managed limit with headroom for migrations. SET LOCAL survives transaction-mode pooling, because the setting dies with the transaction that owns it, but anything session-scoped does not, and prepared-statement handling needs care wherever a pooler sits. That sizing is real work, not a footnote. Open: at what tenant and request volume does the pooler stop being optional?

  2. One database is one blast radius

    A shared database is operable by a small team, and it means a noisy tenant, a bad migration or a corruption event reaches everybody. The dedicated-cell escape hatch for data residency is written down and has never been rehearsed, which makes it a plan rather than a capability. Open: what is the first drill that would prove the cell path works?

  3. Head-of-line blocking assumes somebody answers

    A dead delivery blocking its stream is the correct failure, but it converts an automated system into one that needs an operator, and the alert threshold assumes a human is reachable. On a small team that human is one person. Open: which streams could safely reconcile instead of block, so the pager is reserved for the ones that cannot?

  4. Identity in-process means owning its bugs

    MFA policy, lockout counters and authentication audit are our code now. Those paths are unglamorous, rarely exercised and easy to get subtly wrong, so each one carries explicit negative tests. That is a mitigation, not a guarantee. Open: at what point does an external provider's per-user price become cheaper than our own incident?

None of the four is a reason to change course. They are the cost side of decisions made deliberately, and a design whose author cannot name them has not been thought about hard enough.