Architecture · SaaS

Multi-tenant from day one: lessons from an eCommerce SaaS

Abstract cover: many small storefront shapes sharing one foundation slab, one highlighted

Zatiq Easy puts a Bangladeshi merchant's storefront online in about ten seconds: pick a name, get a shop with inventory, orders, and bKash/Nagad/card payments. Behind those ten seconds is the least glamorous architecture decision we ever made, and the one I would defend the hardest — the platform was multi-tenant from the first migration, back when we had zero merchants and multi-tenancy felt like planning a stadium for a garage band.

Thousands of merchants and thousands of daily transactions later, here is exactly how it's put together, and the three early migrations that would have been effectively impossible once the data got heavy.

Step 0 — pick your isolation model while it's cheap

There are three honest options, and the trade is always the same: isolation versus operability.

We chose shared schema, and spent our effort on the "make it impossible" part. Three layers deep, so a bug has to get through all of them:

Both the subdomain and the custom domain resolve to a tenant before any query, which runs scoped and behind row-level securitymitali.zatiqeasy.commitalicrafts.comtenant resolverscoped queries + RLSWHERE tenant_id = …
Every request resolves its tenant before any query runs. Row-level security exists for the day someone forgets.

Step 1 — resolve the tenant at the front door

A storefront is reachable two ways — the free subdomain and, on paid plans, the merchant's own domain. Both are just a host header, and resolution happens in middleware before any page or API logic runs:

// middleware.ts — no storefront request proceeds without a tenant
export async function middleware(req: NextRequest) {
  const host = req.headers.get("host")?.toLowerCase() ?? "";
  const tenant = await resolveTenant(host); // domain → tenant, cached ~1ms

  if (!tenant) {
    return NextResponse.rewrite(new URL("/shop-not-found", req.url));
  }

  const headers = new Headers(req.headers);
  headers.set("x-tenant-id", tenant.id);
  return NextResponse.next({ request: { headers } });
}

Custom domains live in a domains table mapping hostname to tenant, verified by a DNS TXT challenge before activation. The mapping is one of the very few global-unique things in the system — a hostname can belong to exactly one shop, ever.

Step 2 — make scoped the default, not the discipline

Code review is not an isolation strategy. Humans forget WHERE clauses; the framework must not. On the Laravel API every tenant-owned model boots a global scope, so the unscoped query is the one you have to work to write:

// app/Models/Concerns/BelongsToTenant.php
protected static function bootBelongsToTenant(): void
{
    static::addGlobalScope('tenant', function (Builder $query) {
        $query->where(
            $query->getModel()->getTable() . '.tenant_id',
            Tenant::currentId(),
        );
    });

    static::creating(function ($model) {
        $model->tenant_id ??= Tenant::currentId();
    });
}

Order::all() silently means this shop's orders. Creating a row silently stamps it. The rare legitimate cross-tenant query — platform analytics, the admin panel — has to say withoutGlobalScope('tenant') out loud, which turns "forgot to scope" from a silent data leak into a grep-able, reviewable phrase.

Step 3 — row-level security is the net under the net

Layers one and two are application code, and application code has bugs. The last layer is Postgres itself:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::bigint);

The API's database role has no BYPASSRLS, and every request's transaction begins by pinning its identity:

SET LOCAL app.tenant_id = '4821';

If every other layer fails — a raw query, an ORM escape hatch, a tired engineer at midnight — the database returns zero rows instead of another merchant's customers. In three years this policy fired in anger exactly twice, both times catching a raw-SQL report query in staging. Two incidents that became log lines instead of apology emails: that's the entire ROI calculation.

The three migrations that had to happen early

Each of these was a ten-minute migration on a small database. Each would have been a locked-table, scheduled-downtime project six months later — which in practice means it would simply never have happened.

1. Every unique constraint got a tenant prefix. Global uniqueness is almost always wrong in a multi-tenant system. Two shops must both be able to sell a product slugged blue-saree:

ALTER TABLE products
  DROP CONSTRAINT products_slug_key,
  ADD CONSTRAINT products_tenant_slug_key UNIQUE (tenant_id, slug);

Retrofitting this later isn't just a slow migration — by then real merchants have real collisions, and you're writing data-mangling scripts to rename someone's live products.

2. Every hot index leads with tenant_id. All real queries start from one tenant, so all real indexes should too:

CREATE INDEX orders_tenant_created_idx
    ON orders (tenant_id, created_at DESC);

This is also the quiet escape hatch for the future: with every table keyed and indexed by tenant_id first, sharding by tenant later is a re-plumbing job, not a re-modelling one.

3. Merchant-facing numbers became per-tenant counters. Merchants read invoice numbers out loud to customers. A shop whose orders jump from #14 to #8,207 because the sequence is global looks broken and leaks your platform's volume besides:

UPDATE tenant_counters
   SET last_order_no = last_order_no + 1
 WHERE tenant_id = $1
RETURNING last_order_no;

Renumbering historical orders after merchants have printed receipts is not a migration. It's a breach of trust with people's paperwork.

What ten-second provisioning actually is

The signup magic is just the payoff of the shared-schema choice: creating a shop is a single transaction inserting a tenant row, a domain mapping, default settings and starter categories. No database to create, no schema to clone, no infrastructure to wait on. The architecture decision is the onboarding feature — the thing marketing put on the landing page was a BEGIN and a COMMIT.

What I'd change at ten times the scale

Honest answers: move hot per-tenant caches out of the request path sooner; add per-tenant rate limiting the day one merchant's flash sale first slows the neighbours (noisy tenants arrive earlier than you think); and start measuring per-tenant cost from the first month, because "which merchants lose us money" becomes an unanswerable question once the data is big.

But the core call — shared schema, scoped by default, RLS underneath — I would repeat without hesitation. Multi-tenancy is like a foundation: pouring it is cheap on day one and ruinous on day five hundred, and nobody pours a second foundation under a building that people are already living in.

All postsReply by email

Keep reading

Next posts