Part 0 — Orientation
C The Architecture Map
This chapter draws the whole system on one page: the apps people touch, the server that holds the rules, the one database that holds the truth, the background workers, and the outside systems your ERP must live with. Then it traces a single real operation, a customer service rep committing 24 units to an order, through every layer, every lock, and every network hop, so you can see where each of the next ten chapters plugs in.
In this chapter
- What you need to know first
- C.1 Why you need a map before you need code
- C.2 The 10,000-foot picture
- C.3 The layers, and the rule for what goes in each
- C.4 One operation, traced end to end
- C.5 The data flow map: who owns which fact
- C.6 Read paths and write paths are different animals
- C.7 Environments and deployment topology
- C.8 Synchronous or asynchronous?
- C.9 Failure domains: what breaks, and what the user sees
- C.10 Security and tenancy boundaries
- C.11 How the rest of this book maps onto the diagram
- C.12 Cost and scale: what this handles, and what breaks first
What you need to know first
This chapter uses a small number of words over and over. Here is what each one means in plain language. Nothing else in the chapter assumes you have seen them before.
- ERP — enterprise resource planning. One system that holds the operational facts of a company: what you own, what you owe, what you promised, and what you shipped.
- SKU — stock keeping unit. One exact sellable thing. Not "the Preston Shirt" but "Preston Shirt, black, size medium." A style is the garment design; a colorway is that design in one specific color; a size run is the set of sizes you make it in. Multiply them together and you get SKUs.
- Wholesale — you sell cases of goods to a shop or a department store, which then sells them on. DTC (direct-to-consumer) — you sell single items to a shopper from your own website. Most apparel brands do both, and the two channels behave nothing alike.
- Postgres — the database program this book uses. A database stores rows of data on disk and answers questions about them in a language called SQL.
- Transaction — a group of database changes that all happen or none happen. You open it with
BEGINand close it withCOMMIT. If anything fails in between, youROLLBACKand the database is untouched. - Next.js — the framework you write the web app in. Vercel — the company that runs that app for you. Supabase — the company that runs your Postgres database for you.
- Worker — a program with no user attached, doing jobs in the background on its own schedule.
- Cache — a stored copy of an answer, kept because recalculating it is slow. A cache can always be thrown away and rebuilt.
- Source of truth — for any single fact, the one system you would believe if two systems disagreed.
- Chargeback — money a retailer deducts from your invoice because you broke one of their rules: late shipment, wrong label, missing paperwork. In wholesale apparel these are routine and they are how integration bugs turn into lost cash.
C.1 Why you need a map before you need code
An ERP is a small city of programs that agree on a story. A rep types a number into a browser. A picker scans a carton on an iPad in a warehouse with bad Wi-Fi. Shopify fires a webhook at 3 a.m. because someone in Ohio bought a shirt. A nightly job pushes accounting entries into QuickBooks. All four of those things are touching the same underlying fact — how many black medium Preston Shirts exist and who has a claim on them, and they are doing it from different machines, at different times, with different reliability.
An architecture is the set of decisions about where each of those activities is allowed to happen, and what each one is allowed to touch. It is mostly a set of restrictions. That sounds joyless, but restrictions are what make a system explainable. When you know that every inventory change goes through one door, a bug report that says "the number is wrong" becomes a search of one code path instead of a search of the whole repository.
Two words before we start, because I promised to define everything on first use. A module is a folder of code with a name and a job. A service is a program that runs on its own and can be started and stopped independently — your Next.js app is a service, your background worker is a different service, Postgres is a third. Two modules can live in the same service; two services can share zero code. Keeping those two ideas apart is most of what people mean by "thinking architecturally."
C.2 The 10,000-foot picture
Here is the entire system. Every box is either a program you write, a program you rent, or a place data sits. Every arrow is a network call or a database connection. Nothing else exists.
ACTORS THE SYSTEM
------------ ------------------------------
[ Browser ] +------------------------------+
admin app --- HTTPS ---> | NEXT.JS APP (Vercel) |
(CS, ops, | |
merchants) | L1 UI components |
| L2 server actions / routes |
[ iPad ] --- HTTPS ---> | L3 application services |
offline app (batched, | L4 domain core (pure) |
warehouse retryable) | L5 repositories |
+ showroom +--------------+---------------+
|
[ Shopify ] -- webhook -> | pooled SQL
[ Carriers ] - webhook -> | (port 6543)
v
+------------------------------+
| POSTGRES (Supabase) |
| inventory_ledger (truth) |
| documents: orders, POs |
| ats_cache (derived) |
| outbox (queue) |
| RLS policies (tenant fence) |
+------+----------------+------+
^ |
writes | | claim rows
| v
+------+----------------+------+
| WORKERS (long-running Node) |
| outbox relay | sync merge |
| report build | EDI | retry |
+--+------+--------+-------+---+
| | | |
v v v v
Shopify QBO 3PL/WMS Carriers
|
v
EDI partners
(850 / 856 / 810)
Now the guided tour. Read this slowly; every later section assumes you can point at these boxes.
The two clients: browser and iPad
The browser (admin app). This is the main ERP interface: order entry, purchase orders (POs), style setup, allocation screens, reports. It runs in Chrome on a desk. It is online-only by design — if the network is out, the rep is out. That is an acceptable trade for an office app, and it removes an enormous amount of complexity.
The arrow to the Next.js app is plain HTTPS. Two kinds of traffic ride it: page loads (the server renders HTML and sends it down) and mutations (a mutation is any request that changes stored data), which travel as POSTs. A POST is the kind of HTTP request browsers use to send data to a server.
The iPad (offline app). This is the warehouse and showroom client: receiving cartons, cycle counts, picking, writing orders at a trade show where the venue Wi-Fi is a rumor. A cycle count is a physical recount of a few SKUs on the shelf, done regularly, to catch drift between what the system says and what is really there.
The iPad app is offline-first, meaning it keeps a local copy of the data it needs in on-device storage and lets the user work with the network unplugged. Its arrow to the server is different in kind from the browser's: it sends batches of queued changes, it expects to be told "I already have that one," and it retries forever. That difference is the entire subject of chapter 6.
Inbound webhooks are hostile input
Inbound webhooks. A webhook is a request an outside system makes to you when something happens on their side. Shopify calls you when a DTC order is placed. A carrier calls you when a package is delivered. These arrive at the same Next.js app as user traffic, on dedicated routes, and they are hostile input: unauthenticated until you verify the signature, delivered out of order, and delivered more than once.
Note the direction of the arrow — it points into your system, which means outside parties can generate load on your database whenever they like.
The Next.js app and its connection to Postgres
The Next.js app. One deployable service, five internal layers (L1–L5, dissected in the next section). On Vercel it runs as a set of Functions — code that Vercel starts on demand and runs in a region you choose, rather than one machine you keep switched on.
Under Vercel's Fluid compute model, which has been the default for new projects since 23 April 2025, several concurrent requests can share one warm instance instead of each getting a private micro-VM (a tiny isolated virtual machine). Vercel's docs state that with Fluid, unhandled errors "won't crash other concurrent requests running on the same instance."
Two things follow from that, and you need both of them in your head from here on: you cannot keep state in a module-level variable and expect it to be private to one user, and one instance can hold several database connections at once.
The arrow from Next.js to Postgres. Labeled "pooled SQL, port 6543." Serverless-style compute opens and closes database connections constantly, and Postgres runs a separate operating-system process for every open connection, so a few hundred function instances will exhaust a small database.
The fix is a connection pooler — a middleman that keeps a small number of real Postgres connections and hands them out to many short-lived clients. Supabase's shared pooler is called Supavisor.
Port 6543 is transaction mode, which Supabase describes as "ideal for serverless or edge functions, which require many transient connections." Port 5432 is either a direct connection or session-mode pooling, meant for long-lived processes. Your Next.js app uses 6543. Your workers and your migration tool use 5432. (Ports and modes verified against Supabase's docs as of July 2026.)
What lives inside the Postgres box
The Postgres box. This is the center of gravity of the entire product. Five things live in it and they are not the same kind of thing:
inventory_ledger— an append-only list of every stock movement ever. Append-only means rows are added and never edited or removed. This is the truth for inventory. Chapter 1 is about why.- documents — orders, purchase orders, shipments, invoices. These rows do change, and each one moves through a fixed set of states (draft, open, shipped, closed) with rules about which state can follow which.
ats_cache— "available-to-sell" per SKU, derived from the ledger and stored so screens are fast. If you deleted it, you could rebuild it. That is the test for whether something is a cache. This book shortens "available-to-sell" to ATS from here on.outbox— a table of messages that need to go out to the world, written in the same transaction as the business change that caused them. Section C.4 and chapter 4 live here.- RLS policies — Row Level Security, a Postgres feature that attaches a rule to a table so that every query is silently filtered. This is the last line of defense for keeping one customer's data away from another; chapter 5 owns it.
The workers
The workers. A separate, long-running Node process (or a few), deployed somewhere that allows processes to live for hours — a container host, a small VM, or a durable workflow runtime. Workers never serve a user directly. Nobody is waiting on them. They have four jobs on this diagram:
- relay outbox rows to external systems,
- merge inbound iPad sync batches that are too heavy to do in-request,
- build reports and pre-computed summaries,
- and retry anything that failed.
The arrow up from workers to Postgres is a normal write connection; the arrow down from Postgres to workers is the worker claiming outbox rows, which it does with a SELECT … FOR UPDATE SKIP LOCKED so two workers never grab the same row.
The outside systems
Shopify. Your DTC storefront. Data flows both ways: you push inventory levels and product data to it, it pushes orders and customers to you. It is the source of truth for retail merchandising and consumer orders; you are the source of truth for stock. Getting that split wrong is one of the most common integration failures in apparel, and section C.5 exists to stop it.
QuickBooks (QBO). The accounting system. You push summarized accounting entries, invoices, and bills into it. You almost never read from it. It is the books of record, the thing an auditor and the company's accountant look at, and your job is to feed it accurately. Replacing it is not on the table.
3PL / WMS. A 3PL is a third-party logistics provider: someone else's warehouse holding your goods. A WMS is the warehouse management system running inside it. You send them orders to ship and receipts to expect; they send you back confirmations of what physically happened. They are the source of truth for physical reality; you are the source of truth for intent.
Carriers. UPS, FedEx, DHL. You call them to buy a label and get a tracking number; they call you (or you poll them) with delivery events.
EDI partners. EDI — Electronic Data Interchange — is how big retailers still transact: fixed-format documents with numbers instead of names. In the American X12 standard, an 850 is a purchase order the retailer sends you, an 856 is the advance ship notice (ASN) you send them listing exactly what is in each carton, and an 810 is your invoice.
They arrive through a VAN (value-added network, a paid message-relay service) or an SFTP drop (a file server you both log into), on a schedule, and a formatting mistake earns you a chargeback. Chapter 4 covers the formats in detail.
The one sentence to remember
If you remember one sentence from this diagram, make it this one. Every write goes through one app, into one database, inside one transaction, and every consequence of that write leaves the database through the outbox and a worker — never through a network call made while a transaction is open.
Almost every rule in the rest of the chapter is a corollary of that sentence, and almost every production disaster in this kind of system is a violation of it.
C.3 The layers, and the rule for what goes in each
Inside the Next.js app there are five layers. Each layer is a folder with rules about what it may import; all five ship together as one service. Here they are with the dependency arrows drawn correctly.
+---------------------------------------------------------+
| L1 UI components |
| React. Forms, tables, buttons, optimistic state. |
+---------------------------------------------------------+
| calls (and never the reverse)
v
+---------------------------------------------------------+
| L2 server actions / route handlers |
| Session, parsing, HTTP shapes, cache revalidation. |
+---------------------------------------------------------+
| calls
v
+---------------------------------------------------------+
| L3 application services ("use cases") |
| Transactions, orchestration, calls out via PORTS. |
+---------------------------------------------------------+
| |
| calls directly | calls via PORTS
v v
+---------------------------+ +-------------------------+
| L4 domain core (pure) | | L5 adapters |
| Rules, invariants, | | repositories (SQL), |
| calculations, types. | | shopify client, |
| Zero I/O. | | carrier client |
+---------------------------+ +------------+------------+
|
v
+-------------------------+
| L6 Postgres, the world |
+-------------------------+
DEPENDENCY RULE: L4 imports nothing from L1, L2, L3, L5, L6.
What each layer is
Reading the diagram:
- L1 is React components — what the user sees.
- L2 is the thin skin where HTTP meets your code: a server action (a function marked
"use server"that the client can call as if it were local, but which actually runs on the server via a POST) or a route handler (a plain HTTP endpoint file). - L3 is where a use case lives: "commit stock to an order," "receive a carton," "issue a credit memo."
- L4 is pure business logic — functions that take data in and return data out, with no database, no clock, no randomness, no network. That is what "zero I/O" means on the diagram: I/O is input and output, any reading or writing outside the function's own memory.
- L5 is adapters: the code that actually speaks SQL, or speaks Shopify's GraphQL API, or speaks UPS's REST API. (GraphQL and REST are just two styles of web API; the adapter's job is to hide which one you are dealing with.)
- L6 is the outside world itself.
The two arrows out of L3 are the important part. L3 calls L4 directly — it imports the pure functions by name, because pure functions are safe to depend on.
L3 calls L5 through ports. A port is an interface: a TypeScript type describing what you need ("something that can save a ledger entry") without saying who provides it. The real implementation is handed to your code at runtime. This is the core idea of Alistair Cockburn's Hexagonal Architecture, whose stated intent is to "allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."
The dependency rule is: inner layers know nothing about outer ones. L4 has no idea that Postgres exists, that HTTP exists, or that React exists. L3 knows there is a database shaped like an interface but not which one. Only L5 knows the truth. Here is what that looks like in practice.
The domain core in code
// packages/domain/src/inventory/commit.ts
// LAYER 4 — the domain core. Pure. Imports nothing outside this package.
export type AtsSnapshot = {
skuId: string;
onHand: number; // physically in the building
committed: number; // already promised to other orders
inbound: number; // on a PO, not yet received
};
export type CommitDecision =
| { ok: true; entry: LedgerEntry }
| { ok: false; code: "QUANTITY_NOT_POSITIVE" | "INSUFFICIENT_ATS";
shortBy?: number };
export type LedgerEntry = {
skuId: string;
kind: "commit";
quantity: number;
reason: string;
};
export function availableToSell(ats: AtsSnapshot): number {
return ats.onHand - ats.committed;
}
export function planCommit(
ats: AtsSnapshot,
request: { skuId: string; quantity: number },
): CommitDecision {
if (!Number.isInteger(request.quantity) || request.quantity <= 0) {
return { ok: false, code: "QUANTITY_NOT_POSITIVE" };
}
const available = availableToSell(ats);
if (request.quantity > available) {
return {
ok: false,
code: "INSUFFICIENT_ATS",
shortBy: request.quantity - available,
};
}
return {
ok: true,
entry: {
skuId: request.skuId,
kind: "commit",
quantity: request.quantity,
reason: "order_line_commit",
},
};
}
Walking through it. AtsSnapshot is a plain data shape describing what we know about one SKU right now — four numbers, no methods, no database row ids beyond the SKU. CommitDecision is a discriminated union: a type that is either a success carrying a ledger entry or a failure carrying a machine-readable code, and TypeScript forces you to check which one you got. Returning a value instead of throwing an exception is deliberate. It forces every caller to handle the failure, and it makes the failure testable.
availableToSell encodes an actual business definition: available equals on hand minus committed. Inbound stock is deliberately excluded, because promising goods that are still on a boat is how you get chargebacks. planCommit validates the quantity, computes availability, and either refuses with a reason and a shortfall or returns the exact ledger entry that should be written. It does not write it.
Notice what is absent: no await, no db, no fetch, no Date.now(). You can test this function a thousand ways in a millisecond each, with no database running.
The application service that coordinates it
Now the layer above, the one that opens the transaction and calls everything in order.
// packages/app/src/inventory/commitToOrder.ts
// LAYER 3 — application service. Knows the domain and the PORTS.
// Knows nothing about HTTP, React, or which SQL driver we use.
import { planCommit } from "@erp/domain/inventory/commit";
import type { UnitOfWork } from "../ports";
export type CommitToOrderInput = {
tenantId: string;
orderId: string;
skuId: string;
quantity: number;
actorId: string;
idempotencyKey: string;
};
export async function commitToOrder(
uow: UnitOfWork,
input: CommitToOrderInput,
) {
return uow.transaction(input.tenantId, async (tx) => {
const seen = await tx.idempotency.claim(input.idempotencyKey);
if (seen) return seen.result; // already done once
const order = await tx.orders.findForUpdate(input.orderId);
if (!order) return { ok: false as const, code: "ORDER_NOT_FOUND" };
if (order.status !== "open") {
return { ok: false as const, code: "ORDER_NOT_OPEN" };
}
const ats = await tx.inventory.readAtsForUpdate(input.skuId);
const decision = planCommit(ats, {
skuId: input.skuId,
quantity: input.quantity,
});
if (!decision.ok) return decision;
await tx.inventory.appendLedger(decision.entry, {
orderId: order.id,
actorId: input.actorId,
});
await tx.inventory.bumpAtsCache(input.skuId, {
committed: input.quantity,
});
await tx.orders.upsertLine(order.id, input.skuId, input.quantity);
await tx.outbox.enqueue({
topic: "inventory.committed",
payload: {
skuId: input.skuId,
orderId: order.id,
quantity: input.quantity,
},
});
const result = { ok: true as const, orderId: order.id };
await tx.idempotency.record(input.idempotencyKey, result);
return result;
});
}
Element by element. UnitOfWork is a port — an interface with one method, transaction, that hands you a tx object holding every repository scoped to that one database transaction. Because it is an interface, the test suite can pass an in-memory fake and the production code passes the Postgres implementation; commitToOrder cannot tell the difference.
input carries a tenantId and an actorId that the service receives rather than looks up, because looking up "who is logged in" is an L2 concern. The idempotency.claim call is the guard against double submission: if this exact key was processed before, we return the stored result instead of committing stock twice.
findForUpdate takes a row lock on the order, so two reps editing the same order take turns instead of colliding. A row lock is Postgres holding one row for you until your transaction ends; anyone else who asks for the same row waits. readAtsForUpdate does the same for the SKU's cache row.
planCommit is the only place a decision is made, and it is the pure function from the previous listing. The four writes that follow — ledger, cache, order line, outbox — all happen inside the same transaction, which is the whole point: they commit together or not at all. Finally the idempotency record is written inside the transaction too, so a crash before commit leaves no trace of a half-done operation.
Notice there is no fetch("https://shopify…") anywhere in that function. A Shopify call can take a few hundred milliseconds, or thirty seconds, or hang. If it happens inside BEGIN … COMMIT, you hold row locks on a busy SKU for the duration and you block every other rep selling that style. Worse: if the HTTP call succeeds and the transaction then rolls back, you have told Shopify about a commitment that never happened, and there is no way to take it back. Write an outbox row instead. Always.
C.3.1 A rule violated, and the pain it causes
Here is the same logic written by someone who has not yet been burned. It works. It ships. Nine months later it takes down order entry in the middle of a workday.
// packages/domain/src/inventory/commit.ts
// LAYER 4 — and every rule is broken.
import { db } from "@erp/db/client"; // reaches DOWN to layer 6
import { shopify } from "@erp/integrations"; // reaches OUT to the world
import { toast } from "sonner"; // reaches UP to the UI
import { currentUser } from "@erp/auth"; // reaches UP to the request
export async function planCommit(skuId: string, quantity: number) {
const user = await currentUser();
const ats = await db.query(
"select on_hand, committed from ats_cache where sku_id = $1",
[skuId],
);
if (quantity > ats.on_hand - ats.committed) {
toast.error("Not enough stock"); // domain renders UI
return null; // failure reason: lost
}
await db.query(
"update ats_cache set committed = committed + $1 where sku_id = $2",
[quantity, skuId],
);
await shopify.setInventory(skuId, ats.on_hand - quantity); // network I/O
console.log(`${user.email} committed ${quantity}`);
return { skuId, quantity };
}
Line by line, here is the bill:
- The
dbimport means the domain now depends on the database, so you cannot unit-test the rule without a live Postgres; your fastest test suite becomes your slowest. - The
shopifyimport means the domain depends on a third party's uptime, so a Shopify outage now fails order entry — a rep cannot type a wholesale order because a DTC storefront is down. - The
toastimport means the domain imports a browser library; the day you try to reuse this function in the background worker, the worker crashes at import time becausewindowis undefined. - The
currentUser()call means the function only works inside an HTTP request, so the EDI importer that needs to commit stock from an 850 file cannot call it at all, and someone will copy-paste a second version of the rule, which will then drift from the first. - Returning
nullon failure throws away why it failed, so the UI can only say "something went wrong."
And the real disaster: there is no transaction. The read of ats_cache and the update to it are two separate statements with a gap between them. Two reps commit the same 24 units at the same time, both read 30 available, both succeed, and you have oversold. Then the Shopify call happens after the update but with no rollback path, so if it fails you have local state and remote state permanently disagreeing, with no record that they disagree.
Every one of those failures is invisible in code review and obvious in production. That is exactly why the discipline exists: the rule is enforceable by a linter (a tool that reads your code and rejects patterns you banned — here, packages/domain may not import anything), and a linter never forgets.
C.3.2 Where the layers live on disk
erp/
├── apps/
│ ├── admin/ # Next.js: L1 UI + L2 actions/routes
│ │ └── app/
│ │ ├── (dashboard)/orders/[id]/page.tsx
│ │ ├── (dashboard)/orders/[id]/actions.ts
│ │ └── api/webhooks/shopify/route.ts
│ ├── sync/ # Next.js routes serving the iPad
│ └── worker/ # long-running Node: outbox relay, jobs, EDI
├── packages/
│ ├── contracts/ # zod schemas + shared types. deps: none
│ ├── domain/ # L4 pure rules. deps: contracts
│ ├── app/ # L3 services + port types. deps: domain
│ ├── db/ # L5/L6 repositories, SQL, migrations
│ ├── integrations/ # L5 adapters: shopify, qbo, 3pl, edi, carriers
│ └── ui/ # shared React components. deps: none
├── supabase/
│ └── migrations/ # ordered .sql files, applied everywhere
├── turbo.json # task graph: build, test, lint, typecheck
└── package.json # workspace root, private: true
Reading the tree. This is a monorepo: one Git repository holding several deployable apps and the libraries they share, managed here by a tool called Turborepo. Turborepo's own recommendation is to split "your packages into apps/ for applications and services and packages/ for everything else"; each package needs its own package.json "to make the package discoverable to your package manager and turbo", and internal packages get a namespace prefix (@erp/…) so they never collide with something published on npm.
apps/admin is the browser-facing Next.js app, and inside it the pattern repeats per route: a page.tsx (L1) sits next to an actions.ts (L2). apps/sync is a separate deployable so the iPad's traffic cannot starve the admin app of function capacity. apps/worker is a plain long-running Node process rather than a Next.js app, because Vercel Functions have a time limit and an EDI batch or a full ATS rebuild is not a request.
As of July 2026, Vercel's documented defaults for Fluid compute are a 300-second (5-minute) default duration on every plan, a 300-second ceiling on Hobby, an 800-second ceiling on Pro and Enterprise, and an extended ceiling of 1,800 seconds (30 minutes) in beta on Pro and Enterprise. Those numbers move; the shape of the argument does not.
In packages/, the dependency arrows are visible as literal dependency lists. contracts depends on nothing, and holds the schemas, written with a validation library called zod, that describe what valid data looks like. domain depends only on contracts. app depends on domain. db and integrations implement the ports that app declares, so they depend on app — the arrow points inward, which is the whole trick of hexagonal architecture: the adapter knows the core, the core does not know the adapter.
supabase/migrations holds ordered SQL files that are the only sanctioned way the schema changes. A migration is one numbered SQL file that moves the database from one shape to the next. turbo.json defines the task graph so turbo test builds domain before testing app, and caches anything that has not changed.
C.4 One operation, traced end to end
This is the spine of the book. A CS rep named Dana has order SO-10482 for a boutique open on screen. She types 24 into the quantity box for Preston Shirt / Black / M (SKU PRE-BLK-M) and clicks Commit. Here is every step, in order.
Phase 1 — the browser (L1)
- Dana clicks Commit. A React client component's
onSubmitfires. - The component calls
startTransitionand sets optimistic state: the row grays out, the button shows a spinner, the ATS number ticks down by 24 locally only. Optimistic means "assume success and correct later" — it makes the app feel instant and it is always a guess. - The client generates an idempotency key — a random unique string (a UUID) stored with the pending submission. Idempotent means doing the same thing twice has the same effect as doing it once. If Dana double-clicks, or the network retries, the same key rides along, and the server will process it once.
- React serializes the arguments and POSTs them to the current route with the server action's ID in a header. You never write this endpoint yourself. It is the framework's own transport, and you never call it by hand.
Phase 2 — the edge and the server action (L2)
- Vercel receives the POST and routes it to a Function instance in your chosen region — ideally the region your database is in, because every millisecond of app-to-database latency is paid several times per request. (Vercel's default region for new projects is
iad1, Washington, D.C.) Under Fluid compute the instance may already be warm and already serving other requests concurrently. - Proxy code runs first — the file Next.js used to call middleware, which sees every request before the route does. It reads the session cookie, attaches a request ID for tracing, and rejects obviously unauthenticated traffic. It does not do business authorization, because it sits too far from the data to know whether Dana may touch this specific order.
- The server action body begins. It parses the incoming arguments with a zod schema from
packages/contracts. Anything that fails the schema is rejected here with a 400-shaped error. Never trust the client's shapes; the POST body is attacker-controlled even when it came from your own form. - The action resolves the session into a member record: user id, tenant id, role. A tenant is one customer company using your system. The tenant is taken from the session, never from the request body. If the payload contains a
tenantId, it is ignored or treated as an attack. - Coarse authorization: does this member's role include the
order.commitpermission? If not, return a typedFORBIDDENimmediately, before touching the database.
Phase 3 — the transaction (L3 → L5 → L6)
- The action calls
commitToOrder(uow, {...}), passing the Postgres-backed unit of work. This is the only line of the action that knows about business logic, and it is one line. - The unit of work checks out a connection from Supavisor in transaction mode on port 6543. Supabase documents that "transaction mode does not support prepared statements," so the query layer is configured with them turned off. (A prepared statement is a query the driver sends once and then reuses by name; it is a speed trick that does not survive a pooled connection changing hands.) This is a one-line config choice that causes baffling errors if you forget it.
BEGIN.select set_config('app.tenant_id', $1, true)— the third argumenttruemeans "transaction-local," so the setting evaporates at COMMIT and cannot leak to the next borrower of that pooled connection. Every RLS policy reads this value. Chapter 5 dissects this line at length; getting the third argument wrong is a cross-tenant data leak.- Idempotency claim: insert the key into an idempotency table with a unique constraint. If it already exists, load the stored result, roll back, and return it. Dana's double-click ends here.
SELECT … FROM orders WHERE id = $1 FOR UPDATE. The row lock means a second rep editing SO-10482 waits at this line rather than racing us.- Fine-grained authorization, now that we have the row: is the order in this tenant (RLS already guarantees it, but assert anyway), is its status
open, is Dana permitted on this specific account? Authorization that depends on data must happen after the data is loaded, inside the transaction. SELECT on_hand, committed FROM ats_cache WHERE sku_id = $1 FOR UPDATE. This is the point where commits for one SKU are forced into single file. Every commit ofPRE-BLK-Mqueues here, which is exactly what we want for correctness and exactly what will bite us at scale (see C.12).- Call
planCommit(ats, { skuId, quantity: 24 }). Pure, in-memory, far under a millisecond. It returns either a decision to write or a refusal withshortBy. - If refused:
ROLLBACKand return the typed error. Nothing was written, so there is nothing to undo. - If allowed:
INSERT INTO inventory_ledger— one immutable row recording +24 committed against SO-10482 by Dana at this timestamp. This row is the truth. It is never updated and never deleted. UPDATE ats_cache SET committed = committed + 24. This is the derived number that screens read. It is a performance artifact, rebuildable from step 20 at any time.INSERT … ON CONFLICT UPDATEonorder_linesto record the committed quantity on the order document. That SQL means "insert this row, or update it if it is already there."- Insert an audit/event row: who, what, when, from which IP, with which request ID. Auditability is a feature customers pay for, and it costs one insert.
INSERT INTO outboxa row with topicinventory.committedand a JSON payload. This is the transactional outbox pattern: because the message is written in the same transaction as the business change, messages are, in the pattern author's words, "guaranteed to be sent if and only if the database transaction commits." No two-phase commit, no lost messages.- Write the idempotency result row so a replay returns the same answer.
COMMIT. Everything from step 20 to 25 becomes real at this instant, or none of it does. The connection returns to the pool; the transaction-localapp.tenant_iddisappears.
Phase 4 — the response
- The service returns
{ ok: true }. The server action callsrevalidatePath(or a tag-based revalidation) so any cached rendering of this order is thrown away and rebuilt. - Next.js serializes the result and the updated server-rendered segments into the response.
- The browser receives it. React swaps the optimistic state for the real state. If the server said
INSUFFICIENT_ATSwithshortBy: 6, the optimistic tick-down is reverted and the UI says "only 18 available — short by 6," because we returned a code and a number instead ofnull. - Optionally,
after()fromnext/serverschedules cheap post-response work — an analytics event, a log flush. Next.js documents that it "will be executed even if the response didn't complete successfully," and that it "will run for the platform's default or configured max duration of your route." It shares that one invocation's time budget, so give it milliseconds of work, never minutes.
Phase 5 — the asynchronous tail
- The outbox relay worker, polling every second, runs
SELECT … FROM outbox WHERE delivered_at IS NULL ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100.SKIP LOCKEDlets several workers run without ever handing the same row to two of them. - The worker maps the domain event
inventory.committedonto Shopify's vocabulary: an inventory level adjustment for the location that backs the DTC channel. Mapping lives inpackages/integrations, never in the domain. - It calls Shopify with a timeout, a retry budget, and its own idempotency handling. Because outbox delivery is at-least-once — the relay can crash after publishing but before marking the row sent — the receiving side must tolerate duplicates.
- On success, mark the outbox row delivered with a timestamp and the remote response id. On failure, increment attempts and set
next_attempt_atusing exponential backoff with jitter: wait 1 second, then 2, then 4, then 8, plus a small random offset so a thousand failed messages do not all retry on the same tick. After a fixed number of attempts, move the row to a dead-letter table — a parking lot for messages that will never succeed on their own, and alert a human. - Minutes later, Shopify may fire a webhook back (for example, an order that consumed that inventory). It lands on your route handler, signature-verified, deduplicated by event id, and written as a new ledger entry — a fresh trip through steps 5–26 with a different actor.
- A nightly reconciliation job compares your ledger-derived on-hand per channel against Shopify's reported levels and against the 3PL's stock file, and writes a variance report. Integrations drift; the only question is whether you find out before the customer does.
- The next time an iPad in the warehouse syncs, its pull includes the new ledger row and its local ATS updates. Dana's click has now reached a device with no direct connection to her at all.
The same request on a clock
Here is the same story on a clock, so you can see which half the user experiences. These millisecond figures illustrate the shape of the request. Your own hardware will produce different ones.
t=0ms Dana clicks Commit
t=2 optimistic UI: row greys, ATS ticks down locally
t=5 POST (server action) leaves the browser
t=25 warm function instance picks it up
t=27 session -> member -> tenant_id
t=28 permission check: order.commit
t=30 check out pooled connection (6543)
t=31 BEGIN; set_config('app.tenant_id', ..., true)
t=33 idempotency claim
t=35 SELECT order FOR UPDATE
t=38 SELECT ats_cache FOR UPDATE
t=39 planCommit() <-- pure, no I/O, ~0.01ms
t=40 INSERT ledger; UPDATE ats_cache; UPSERT line; audit
t=43 INSERT outbox
t=46 COMMIT
t=48 revalidate + serialize response
t=70 browser paints the committed row << USER IS DONE
-----------------------------------------------------------------
t=200 outbox relay claims the row (SKIP LOCKED)
t=620 Shopify inventory adjustment accepted
t=625 outbox row marked delivered
t=~60s next iPad pull carries the new ledger entry
t=~2min Shopify webhook / reconciliation confirms
Reading the timeline: everything above the dashed line is inside Dana's request, and it totals about 70 milliseconds, of which roughly 15 are actual database work — BEGIN at t=31 through COMMIT at t=46. The business decision itself, planCommit, is a rounding error.
Everything below the line is work the system owes the world but which Dana must never wait for. The Shopify round trip in this trace is about 420 milliseconds, six times the whole user-visible request, and a real one can be far slower. If it were synchronous, order entry would feel broken, and a Shopify outage would stop wholesale sales. The dashed line is the architecture.
When you are lost later, come back here. "Where does chapter 3's advisory locking go?" Step 17. "Where does chapter 5's tenant fence go?" Steps 8, 13, and 16. "Where does chapter 4's retry budget go?" Step 34. The map is the index.
C.5 The data flow map: who owns which fact
A source of truth for a piece of data is the system you would believe if two systems disagreed. Every fact that moves between the systems on this diagram needs exactly one. The rule for deciding is short and it has three parts, applied in order:
- Where is the fact created? Physical facts are created where the physical event happens — the warehouse knows what is on the shelf, the carrier knows where the box is. Intent is created where the human decides — you know what you meant to ship.
- Who is legally or operationally accountable? If an auditor or a tax authority reads it, the accounting system wins. If a retailer charges you for it being wrong, the document you transmitted wins.
- One writer per fact. If both systems can write it, you have a race rather than a source of truth. Pick one writer and make the other read-only, even if that costs you a feature.
The table below applies those three questions to every fact this system touches. "Flow direction" reads as "who sends it to whom."
| Data | Source of truth | Who else holds a copy | Flow direction | Why |
|---|---|---|---|---|
| Inventory on hand & committed | Your ledger | Shopify levels, 3PL stock file | you → them | Only you see all channels at once; Shopify sees its slice. |
| Physical count in the building | 3PL / WMS | your ledger (as adjustment entries) | them → you | They can touch the boxes. You cannot. |
| Styles, SKUs, size runs, colorways | Your ERP | Shopify products, EDI catalogs, 3PL item master | you → them | The catalog exists before any channel does. |
| Retail merchandising (images, copy, SEO, collections) | Shopify | read-only mirror in your ERP | them → you | Merchandisers work in Shopify's tools; do not fight them. |
| Wholesale accounts, terms, credit limits | Your ERP | QuickBooks customer list | you → them | You approve credit; QBO records the money. |
| DTC consumers | Shopify | shadow records on your orders | them → you | They own the account, the login, and the marketing consent. |
| Wholesale orders | Your ERP | EDI partner's system (their PO) | both, per document | Their 850 is truth for what they asked; your order is truth for what you promised. |
| DTC orders | Shopify (creation) You (fulfillment state) | 3PL pick list | them → you → them | They took the money; you moved the goods. |
| Wholesale prices & discounts | Your ERP | EDI price catalogs | you → them | Contract pricing is per-account; only you know the matrix. |
| Retail prices | Shopify | your ERP (for margin reporting) | them → you | Promotions and markdowns are run in the storefront. |
| Shipments & tracking numbers | Carrier | 3PL, your ERP, Shopify, the customer | carrier → 3PL → you → all | Only the carrier knows where the box actually is. |
| Invoices & AR aging (which customer invoices are overdue, and by how long) | Your ERP issues, QuickBooks is books of record | the retailer's accounts-payable system | you → QBO | You decide what to bill; the accountant's ledger is what gets audited. |
| General ledger, tax filings, financial actuals | QuickBooks | your ERP's management reports | you → them (journals) | Never make your ERP the tax authority's counterparty. |
| Landed & standard cost (what a unit cost you by the time it reached your warehouse: goods, freight, duty) | Your ERP | QBO inventory value (summary only) | you → them | Cost is built from POs, freight, and duty — all yours. |
| Users, roles, permissions | Your auth (Supabase Auth) | nobody | — | Identity crossing a boundary is a security incident waiting to happen. |
One of the most expensive bugs in apparel integrations is letting Shopify write inventory back to you while you are also writing it to Shopify. You get an echo loop: your push changes their level, their webhook tells you the level changed, you record it, you push again. Levels oscillate, support gets calls, and nobody can find the loop because each system is behaving correctly. Fix it in the structure of the code: mark channel-originated events and refuse to re-emit them. Adding a delay only hides the loop.
C.6 Read paths and write paths are different animals
WRITE PATH READ PATH
---------- ---------
server action / webhook server component / report API
| |
v +--> Next.js cache (tagged)
application service (L3) +--> ats_cache table
| +--> materialized view
v +--> read replica
domain core (L4) DECIDES +--> primary (only when a
| fresh read is required)
v
repository (L5) -> PRIMARY ONLY
|
v
BEGIN ledger + cache + outbox COMMIT
Writes: one path, one primary, one decision-maker, always a txn.
Reads: many paths, stale-tolerant, never the basis of a decision.
Walking the two columns. On the left, a write enters through exactly one door (a server action or a webhook handler), descends through the application service, asks the domain core for a decision, and is executed by a repository against the primary database — the single writable Postgres instance. It is always wrapped in a transaction. There is one of each thing: one path, one primary, one place the rule lives.
On the right, a read may come from anywhere and may be stale. Next.js's cache can serve a rendered fragment that was computed a minute ago. ats_cache is a derived table that is by definition a summary. A materialized view — a query whose answer Postgres stores as a real table and recomputes on command — might back a sell-through report (how much of what you bought has actually sold) and be refreshed hourly.
A read replica — a copy of the database that streams changes from the primary and accepts no writes — can be seconds behind, because Supabase replicates asynchronously so that writes on the primary are never blocked. All of that is fine, because reads are how humans look at the business, and humans tolerate a number that is thirty seconds old.
Why a stale read must never drive a write
What is not fine is using a stale read to make a write decision. If you read available stock from a replica and then commit against it, you have reintroduced the race the transaction was supposed to prevent: the replica said 30, the primary already says 6, and you oversell. That is why step 17 in the lifecycle reads ats_cache on the primary, inside the transaction, FOR UPDATE, even though the same number is sitting in three faster places.
The rule is simple: a number you are about to act on must be read from the primary inside the transaction that acts on it. A number you are merely showing may come from anywhere.
This asymmetry is also why the two paths get different budgets. A write must be correct and can take 100ms. A read must be fast and can be wrong by a minute. Chapter 8 turns that sentence into indexes, materialized views, and cache tags.
C.7 Environments and deployment topology
LAPTOP PREVIEW STAGING PRODUCTION
------ ------- ------- ----------
next dev Vercel preview Vercel deploy Vercel deploy
supabase start (per pull request) (branch: (branch: main)
(Postgres in | staging) |
Docker) | | |
| v v v
local Postgres Supabase branch staging project prod project
seeded from (own instance + (scrubbed prod (customer data,
seed.sql own credentials, snapshot) PITR backups,
no prod data) read replica)
| | | |
+--- git push -----+--- merge to ------+--- merge to ---+
staging main
MIGRATIONS: the same ordered .sql files run in all four,
in the same order, by the same command. No dashboard edits.
Walking left to right. On the laptop, next dev runs the app and supabase start runs a real Postgres inside Docker (a tool that runs packaged programs in isolated containers on your machine). Use a real Postgres locally rather than a lighter substitute like SQLite, because a database that behaves differently in development is a database that lies to you. Data comes from a checked-in seed.sql with two fake tenants, so every developer sees the same fixtures and cross-tenant bugs are visible on day one.
Preview is created automatically per pull request — a pull request being the proposal to merge one branch of code into another, reviewed before it lands. Vercel builds the branch and gives it a URL; Supabase Branching gives that branch its own isolated environment — the docs say "each branch is a separate environment with its own Supabase instance and API credentials", and its deployment workflow applies pending migrations to that branch on every commit you push.
And "new branches do not start with any data from your main project," so a preview cannot leak or corrupt customer data. This is where reviewers click the actual feature instead of reading a diff.
Staging is a long-lived environment tracking a staging branch, restored from a scrubbed production snapshot — real volumes and real shapes of data, with names, emails, and prices randomized. Staging exists to catch two classes of bug that preview cannot: migrations that are fast on 200 rows and lock a table for minutes on twenty million, and integrations that only misbehave against a partner's sandbox with realistic catalog sizes.
Production is the real thing: customer data, point-in-time recovery enabled (the ability to rewind the database to any chosen second, instead of only to last night's backup), at least one read replica for reports, and alerting. The workers run as their own always-on deployment alongside it, connecting on port 5432 rather than the transaction pooler, because they are long-lived processes that benefit from session-level features.
How a change reaches production
The path a change takes: commit on a feature branch, open a pull request, get a preview URL and an isolated database, merge to staging and let it bake with realistic data, merge to main and let the pipeline deploy.
Migrations run as a step in that pipeline, before the new application code is serving traffic, and they are written to be backward compatible — the old code must survive the new schema for the minutes when both are live. That means: add columns nullable, backfill in a separate job, flip the constraint in a later deploy, and never rename a column in one step.
This is the twelve-factor discipline of "strictly separate build and run stages" and "keep development, staging, and production as similar as possible," applied to the one resource you cannot roll back by redeploying.
Redeploying yesterday's code on Vercel takes seconds — you promote a previous deployment and it is live. Un-running a migration that dropped a column takes a restore from backup and an incident review. Treat schema changes as one-way doors and code changes as revolving doors, and structure every risky change so the schema step and the behavior step ship separately.
C.8 Synchronous or asynchronous?
Synchronous work happens while the user waits. Asynchronous work happens later, in a worker, after the response has already gone out. Deciding which is which is one of the judgment calls that shapes this architecture most, and it has a rule.
Do it synchronously only if all three hold: (1) the user's next decision depends on the result, (2) it is fast and bounded — single-digit milliseconds to low hundreds, with a hard timeout, and (3) it touches only resources you control, meaning your own Postgres. If any one fails, write an outbox row and let a worker do it.
The rule applied to the commit lifecycle
Here is how each piece of Dana's commit scores against those three tests:
- Validating the quantity: synchronous — Dana needs to know now, it costs nothing, it is local.
- Reading and locking ATS: synchronous — the decision literally depends on it.
- Writing the ledger, the cache, the order line, the audit row, the outbox row: synchronous, because they must be atomic with the decision.
- Calling Shopify: asynchronous — it fails criterion 3 outright, and criterion 2 usually too.
- Emailing an order confirmation: asynchronous — Dana does not need the mail server's answer to keep working.
- Recalculating a customer's open-to-buy across 900 styles: asynchronous — it fails criterion 2. (Open-to-buy is how much a buyer still has left in their seasonal budget after the orders they have already placed.)
- Generating a 40-page PDF pick ticket: asynchronous, and the UI should show "preparing…" with a link that appears when it is ready.
Where after() fits, and where it does not
There is a middle tier worth knowing. Next.js's after() runs a callback after the response is finished, within the same invocation. The docs list it as usable in Server Components, Server Functions, Route Handlers, and Proxy, and state that it "will run for the platform's default or configured max duration of your route." On Vercel it is backed by Fluid compute's background processing via waitUntil.
Use it for logging, analytics pings, and cache warms — work measured in milliseconds that you simply do not want to make the user wait for. Do not use it as a job queue: it has no retries, no visibility, and no durability. If the instance dies, the work is gone. Anything that must eventually happen goes in the outbox.
Three ways to run the queue
For the worker side, you have three reasonable implementations of the queue itself, in increasing order of ceremony:
- Your own
outboxtable polled withFOR UPDATE SKIP LOCKED— simplest, and enough for a very long time. - Supabase Queues, which is "built on top of the
pgmqdatabase extension," states that messages "are guaranteed to be delivered to your consumers," delivers each message "exactly once to a consumer within a customizable visibility window," and lets you archive messages "for analytical or auditing purposes." - A durable workflow runtime for long multi-step processes that need to pause and resume for minutes to months, which is beyond a single function's duration limits.
Start with the table. The outbox pattern's two publishing strategies — polling publisher and transaction log tailing (watching Postgres's own write-ahead log for new rows) — both work here; polling is a hundred lines and log tailing is a system you operate.
C.9 Failure domains: what breaks, and what the user sees
A failure domain is the blast radius of one component dying. Deciding those radii on purpose is how you keep one dead component from taking the whole product down with it. Here is the honest accounting.
| What fails | What still works | What breaks | What the user must see |
|---|---|---|---|
| Postgres primary down | Nothing meaningful. Static pages render; the iPad keeps working offline. | All reads and writes. This is your single point of failure and you should be honest about it. | A full-page banner: "The system is temporarily unavailable. Your unsaved work is preserved locally." Never a raw stack trace, never a silent spinner. |
| Pooler saturated / connection storm | Cached reads, replica-backed reports. | New writes queue then time out. The symptom looks like slowness rather than an outage, which is why it is missed. | Slow requests, then a clear "busy, retry" error with a retry button. Log the pool wait time as a first-class metric. |
| Read replica down or lagging | All writes; all correctness-critical reads (they use the primary). | Reports and dashboards go stale or fail over to the primary and get slow. | A quiet "data as of HH:MM" stamp on every report. Never present stale data as live. |
| Shopify down | Everything internal: order entry, receiving, picking, POs, reports. | Outbox rows for Shopify accumulate; inbound DTC orders stop arriving. | An integration health badge on the dashboard, plus a count of pending messages. Order entry must not be blocked. |
| QuickBooks down / token expired | Everything. Invoicing continues locally. | Accounting sync backs up. The usual real cause is an expired OAuth token — the credential that lets your app act on the company's QuickBooks account, which has to be refreshed. | An admin alert with a "reconnect QuickBooks" button. Finance should hear it from your banner while there is still time to fix it, well before month-end close. |
| 3PL / EDI feed down | Everything internal. | Shipment confirmations stop; ASNs queue. Retailer chargeback clocks keep ticking. | An operations alert with a countdown to the deadline you agreed with the retailer, because this failure costs money on a schedule. |
| Sync service down | The iPad app entirely — it is offline-first, so users keep scanning and counting. | Changes stay on the device. Two devices' work does not converge. | A persistent "offline — 47 changes pending" chip. The chip is the feature; a silent failure to sync is how you lose a cycle count. |
| Worker crashes mid-job | Everything user-facing. | Nothing, if you did it right. The claimed rows' locks release on disconnect and another worker picks them up. | Nothing. But the external side may see a duplicate, which is why every consumer must be idempotent. |
| Vercel region degraded | Availability-zone redundancy inside a region is on by default, so losing one zone is invisible. Losing the whole region is only survivable if you are on an Enterprise plan with Function Failover enabled and failover regions configured; otherwise the app is down until the region returns. | With failover configured: latency rises, because the app is now further from the database. Without it: everything. | Slower pages if you failed over. Your latency alert should fire so you know why. If you have no failover, say so in your status page and know it in advance. |
| A bad deploy | Everything, once you roll back the code. | Whatever the bug did. If it also ran a destructive migration, see C.7. | Roll back first, diagnose second. |
Two patterns run through that table. First, the database is the only true single point of failure, and every other dependency has been arranged so its absence degrades a feature rather than the product. The outbox exists precisely to make that true. Second, every degraded state has a visible indicator. The worst outage is the one where everything looks fine and inventory quietly stops syncing for six hours. Build the health badge before you build the third integration.
For every operation you build, write the three sentences the user sees when it fails: what happened, whether their work was lost, and what to do next. If you cannot write the third sentence, the operation is not finished.
C.10 Security and tenancy boundaries
Multi-tenant means many customer companies share one deployment and one database, separated by a tenant_id column rather than by separate servers. It is cheaper and vastly easier to operate; it is also one WHERE clause away from showing one brand's costs to a competitor. Chapter 5 works through it in full. At the architecture level, four rules:
1. The tenant is established exactly once, at the edge, from the session
Step 8 of the lifecycle. It comes from a signed cookie resolved to a member record. It never comes from a URL, a header the client controls, a form field, or a request body.
Any code path that accepts a tenant id from the outside is a vulnerability, and the way to prevent it structurally is to make the tenant id impossible to pass in: the unit of work takes it as a required argument that only the session resolver can produce.
2. Authorization happens twice, in two different places, for two different reasons
Coarse checks — "does this role have order.commit at all?" — happen in L2 before you spend a database connection. Fine checks — "is this specific order in this tenant, in a state that allows commits, on an account this rep owns?" — happen in L3 inside the transaction, after the rows are loaded, because they depend on data.
Doing only the first leaves a hole. Doing only the second is slow, and the timing of your responses can tell an attacker which record ids exist.
3. RLS is the floor you fall back on, and never the plan
Every tenant-scoped table has Row Level Security enabled with a policy comparing tenant_id to the transaction-local app.tenant_id. Supabase's docs describe policies as "adding a WHERE clause to every query," which means RLS catches the query you forgot to filter. It backstops your own mistakes. Correct queries are still your job.
Two practical notes carried forward to chapter 5. First, index the columns your policies filter on; Supabase's published sample benchmark shows adding one user_id index taking a policy-filtered query from 171ms to under 0.1ms.
Second, wrap auth function calls in a select so Postgres works the value out once for the whole query instead of once per row — Supabase's own sample results for that single change run from roughly 95% faster in the simplest case to more than 99% faster when the policy calls a function that joins another table.
4. Some things never cross the boundary
Supabase's high-privilege server key — the legacy service_role JWT, or its replacement, the newer secret key that starts sb_secret_ — bypasses RLS entirely. (Supabase's docs say the legacy anon and service_role keys "will be deprecated by the end of 2026," so new projects should use the publishable and secret keys.) That key belongs only in the worker and in server-only code, never in a client bundle, never in a NEXT_PUBLIC_ variable, never in the iPad app.
Third-party API secrets live in the worker and in server environment variables. They never reach the browser. Cross-tenant identifiers never appear in a payload sent to a client. And the iPad's local database holds only the tenant and the location that device is assigned to — a stolen device must not be a data breach of the whole account.
Because Supavisor hands the same physical Postgres connection to different requests in sequence, a session-level set_config('app.tenant_id', x, false) can survive into the next borrower's queries — one tenant reading another tenant's rows, intermittently, in a way no test catches. The third argument must be true (transaction-local). Write a test that runs fifty concurrent alternating-tenant transactions through the pooler and asserts zero foreign rows. Run it in your continuous integration pipeline, the automated checks that run on every commit, forever.
C.11 How the rest of this book maps onto the diagram
| Chapter | Which box or arrow it deepens | Lifecycle steps |
|---|---|---|
| 1. Ledger-First Data Modeling | The inventory_ledger and ats_cache rows inside the Postgres box. Why truth is an append-only log and every visible number is derived from it. | 20, 21 |
| 2. Postgres, Deeply | The Postgres box itself and the SQL arrow into it: schema design, index strategy, query plans, constraints as the last honest enforcer of an invariant. | 11, 15, 17 |
| 3. Concurrency and Correctness | The FOR UPDATE locks and the transaction boundary — the most dangerous four inches of the diagram. Isolation levels, deadlocks, advisory locks, idempotency. | 12–19, 26 |
| 4. Integration Engineering | Everything to the right of the workers box: Shopify, QuickBooks, 3PL, carriers, EDI — plus the outbox relay arrow, retries, backoff, and reconciliation. | 24, 31–36 |
| 5. Multi-Tenancy and Security | The tenant fence drawn through every box: where tenant_id is established, how RLS policies enforce it, and what the high-privilege server key may and may not do. | 6, 8, 9, 13, 16 |
| 6. Offline-First Sync | The iPad arrow and the sync-merge worker: local storage, mutation queues, conflict resolution, and why "last write wins" loses a cycle count. | 37 |
| 7. Import and Migration Tooling | The arrow that is not on the diagram — day-one data load from spreadsheets and the legacy system. Treated as a product because every implementation lives or dies on it. | before step 1, forever |
| 8. Reporting and Computed-State Performance | The read path in C.6: ats_cache, materialized views, the read replica, cache tags, and how to keep a derived number honest. | 21, 27, and every read |
| 9. Testing and Operational Discipline | The environments diagram in C.7 and the failure table in C.9: the test pyramid over these layers, migration safety, observability, on-call. | all of them, in CI |
| 10. Stack Specifics: Next.js, Supabase, Turborepo | The infrastructure boxes as boxes: Fluid compute behavior, pooling modes and ports, Supabase branching, the Turborepo task graph and package boundaries. | 5, 11, 30 |
Read the table as a routing function. When something is wrong in production, identify which box or arrow it is in, and the chapter follows. A number that disagrees with reality is chapter 1 or 8 depending on whether the ledger or the cache is wrong. An oversell under load is chapter 3. A partner complaining about a missing ASN is chapter 4. A customer seeing another customer's style is chapter 5, and it is an incident.
C.12 Cost and scale: what this handles, and what breaks first
Everything above is a mid-market architecture. It is deliberately boring: one Postgres, one app, one worker fleet, one queue in a table. Now, how far does boring get you?
I cannot give you benchmark numbers, and you should distrust anyone who hands you some without describing their queries. What follows are planning estimates for a system of this shape. Treat them as a starting hypothesis and replace them with your own load test.
On a single Postgres primary in the range of 4–8 vCPU and 16–32 GB of RAM, with sane indexes, plan for something on the order of:
- dozens to a few hundred tenants on one deployment;
- tens of thousands to a couple of hundred thousand active SKUs per tenant;
- a few thousand order lines per day per active tenant;
- and tens of millions of ledger rows accumulating per year.
The SKU figure is less exotic than it sounds, and this part is just arithmetic: an apparel brand with 2,000 styles, each offered in 8 colors and 6 sizes, has 2,000 × 8 × 6 = 96,000 SKUs. That is an ordinary brand, and it is already in the range above.
The number worth instrumenting from day one has nothing to do with machine size. Measure how long each commit holds its row locks, and how many commits are waiting behind the busiest SKU. That is the measurement that tells you when this shape has stopped fitting.
Now the honest part. Here is what breaks first, in the order it will break.
1. The hot ATS row
Step 17 takes a row lock per SKU. That is correct and it is also a queue. On an ordinary day it is invisible. During market week — the stretch when wholesale buyers visit showrooms and place a whole season's orders at once — or during a product drop, every commit against one hot SKU serializes behind one row, and you watch tail latency climb while the CPU sits nearly idle.
("Tail latency," or the ninety-ninth percentile, means: sort every request by how long it took, and look at the slowest one in a hundred. It is what your angriest user experiences.) A bigger machine does not help, because the constraint is the shape of the contention.
The fixes, in order of ceremony:
- shorten the transaction so the lock is held for microseconds;
- split the cache row into per-warehouse or sharded counters — several rows that each hold part of the total and get summed on read, so writers spread across them;
- or serialize commits per SKU through a queue and make the UI acknowledge them asynchronously.
Chapter 3 has the details.
2. Connections and the pooler
Fluid compute's shared instances mean one instance can hold several concurrent database connections, and traffic spikes multiply instances. Postgres runs a process per connection, so the pooler is the only thing standing between a Black Friday spike and a database that stops accepting connections. The symptom arrives as timeouts rather than a clean error message, which makes it hard to read.
Escalation path:
- cut per-request connection time (do not open a transaction before you need one);
- move from Supabase's shared pooler to its dedicated pooler, which runs on your project's own compute for lower latency and is transaction-mode only;
- then push reporting traffic to a read replica so the primary's connections serve writes only.
Remember throughout that transaction mode forbids prepared statements — your client config must reflect it.
3. The ledger table's own weight
An append-only table is wonderful until it holds a few hundred million rows. Then reporting queries that used to sample it start scanning it, indexes grow past RAM, autovacuum falls behind on the tables you update constantly, and a migration that adds a column locks the thing everyone depends on. (Autovacuum is the Postgres background job that reclaims space from deleted and updated rows; when it falls behind, tables bloat and queries slow down.)
The fixes are known and unglamorous:
- partition the ledger by month, and possibly by tenant — partitioning splits one logical table into many physical ones so old months are cold and can be detached;
- move analytical queries off the primary entirely;
- snapshot balances periodically so a rebuild replays one month rather than five years;
- and archive delivered outbox rows aggressively, because a queue table that never gets pruned becomes the slowest table in the database.
Two more bottlenecks, and why not to pre-solve them
Two more will follow, and you should see them coming: the outbox relay becoming a single-threaded bottleneck (solved by sharding by topic or by partition key, since SKIP LOCKED already lets you run many workers), and single-region latency once you have tenants in Europe or Asia (solved by regional deployments, which is a real architecture project measured in months rather than a setting you toggle).
A closing warning about all of it: do not build for scale you do not have. Every escalation above is a week of work when you need it and a year of accidental complexity when you do not. Sharded counters, regional deployments, and log-tailing publishers are all correct answers to problems you may never have, and each one makes every subsequent change harder.
The architecture on this page is chosen precisely because each of those three failures has a well-understood fix that does not require rewriting the domain core. That is the actual payoff of the dependency rule from C.3: your business logic is the one thing you never have to touch when the infrastructure changes underneath it. Build the boring version, measure it, and let production tell you which of the three breaks first.
Field notes & further reading
- Alistair Cockburn: Hexagonal Architecture (Ports and Adapters) — the original, from 2005. Its stated intent is to "allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases." The primary/driving vs secondary/driven distinction is the whole idea.
- Chris Richardson: Transactional Outbox — why you cannot atomically write a database and publish a message, how the outbox table solves it ("messages are guaranteed to be sent if and only if the database transaction commits"), polling publisher vs transaction log tailing, and why consumers must be idempotent under at-least-once delivery.
- Vercel: Fluid compute — the execution model your Next.js app actually runs on: shared instances with in-function concurrency, enabled by default for new projects since 23 April 2025, error isolation, bytecode caching, and the per-plan duration limits that decide what can and cannot be a request.
- Vercel: Configuring regions for Vercel Functions — where your functions run, why you put them next to your database, and exactly what automatic failover does and does not cover on each plan. Read this before you write your status page.
- Supabase: Connecting to your database — direct connection vs Supavisor session mode vs transaction mode, ports 5432 and 6543, the dedicated pooler, and the prepared-statement limitation that will otherwise cost you an afternoon.
- Supabase: RLS performance and best practices — the measured cost of unindexed policy columns and of calling auth functions per row, with before/after timings. This is the source for the numbers in C.10.
- Next.js:
after()— scheduling work after the response finishes, where it can be called, that it runs even when the response errored, and that it shares the route's max duration. Read it and then read C.8 again to see why it cannot replace the outbox. - The Twelve-Factor App — config in the environment, backing services as attached resources, strict separation of build/release/run, dev/prod parity, logs as event streams. Written by Adam Wiggins, first published in 2011 and revised since, and still the checklist your deploy pipeline is graded against.
1. Redraw the map from memory, then argue with it. Close this chapter. On paper, draw every box and every arrow from C.2 — the two clients, the app, the database, the workers, and the five external systems, and label each arrow with its protocol and its direction. Then do the part that actually teaches: for each of the five external systems, write one sentence saying what happens to the ERP when it is down for four hours, and one sentence saying what the user sees. Compare against C.9. Anywhere your answer was "the app breaks," you have found a place where a synchronous call needs to become an outbox row.
2. Trace a second operation yourself. A picker at the 3PL confirms a shipment: 18 units of PRE-BLK-M physically left the building against order SO-10482. Write the numbered lifecycle for it the way C.4 does, from the triggering event to the last downstream consequence. You must answer, explicitly: does this arrive as a webhook, an EDI 856, or a sync batch from the iPad, and does your answer change any step? Which rows are locked, and in what order (does it match the commit path's lock order, or have you just invented a deadlock — two transactions each holding the row the other one wants, so neither can finish)? What does the ledger entry say, and does the committed number go down as the on_hand number goes down? Which outbox topics are emitted, and which of Shopify, QuickBooks, the carrier, and the retailer's EDI endpoint consume each one? What happens if the same 856 is delivered twice?
When you finish, you should have two artifacts: a hand-drawn architecture diagram you can reproduce on a whiteboard in a meeting, and a numbered shipment-confirmation trace that names at least one lock, one ledger entry, two outbox topics, and one duplicate-delivery defense. If your trace has an external API call inside the transaction, or a decision made from a cached read, go back to C.3 and C.6 — those are exactly the two mistakes this chapter exists to prevent.