Article

Where the tenant boundary goes in a marketplace

Kreshnik Rraci ·

Most multi-tenancy guidance is written for SaaS, where every tenant is a sealed box. A marketplace violates that premise in its first user story: a buyer browses several stores, puts items from two of them in one cart, and places one order. The tenant boundary still exists - it just doesn't go where the textbook puts it.

The property that changes the answer

In a B2B SaaS product, tenants never interact. Company A's data and Company B's data share infrastructure for cost reasons and for no other reason. Every design choice pushes toward stronger separation, and the only question is how much separation you can afford.

A marketplace has two populations, and they relate to tenancy differently:

So the honest statement of the problem is: writes are tenant-scoped, reads are frequently cross-tenant, and some entities belong to nobody's tenant at all. Any model that can't express all three will be fought by every feature that touches it.

Three sealed seller tenants, with a single buyer reading across all of them TENANT-OWNED - every write scoped to one store Store A catalogue · orders · staff tenant_id = A Store B catalogue · orders · staff tenant_id = B Store C catalogue · orders · staff tenant_id = C Buyer platform-owned · no tenant cross-tenant reads: search · cart · order history
Sellers behave like SaaS tenants. Buyers belong to the platform, and the product is the reads that cross the boundary.

The three canonical models

The standard options, judged on the questions a marketplace actually asks.

Model Isolation Cross-tenant reads Migrations Per-tenant cost Noisy neighbour
Shared schema - one set of tables, tenant_id on rows Logical only Trivial: it's one query One migration, once Near zero Shared everything
Schema per tenant - one database, N schemas Namespace Painful: UNION across schemas, or a separate read model N migrations, orchestrated Low, grows with N Shared engine
Database per tenant Physical Impossible in-band; needs ETL into a central store N migrations, N connections Meaningful, and it scales linearly Contained

For SaaS, the columns on the right dominate and the answer drifts toward per-tenant schemas or databases as customers get larger and more demanding.

For a marketplace, the cross-tenant reads column dominates, and it dominates early. Search is a cross-tenant read. The home feed is a cross-tenant read. A cart is a cross-tenant write followed by a cross-tenant read. Order history for a buyer spans every store they've ever bought from. Every one of those is the product, and each of the isolated models makes each of them a project.

The pattern: shared schema, scoped ownership

The recommendation for a marketplace, from launch until a specific condition described later: a single shared schema, with a clear rule for which entities are tenant-owned and which are platform-owned, enforced at the data-access layer rather than left to discipline.

1. Classify every entity on day one

Every table gets exactly one of three ownerships, written down before it's created:

Ownership Entities tenant_id?
Tenant-owned Store, product, variant, price, inventory, staff, payout Required, non-null
Platform-owned Buyer, session, cart, review, category taxonomy, search index Absent
Bridging Order and its fulfilment lines, conversation threads On the seller-side rows only

The third category is the one SaaS models don't have, and it is where marketplace schemas go wrong. An order is placed by a platform-owned buyer and fulfilled by one or more tenant-owned stores. Neither side owns the whole thing.

2. Split the order at the seller boundary

The shape that resolves it is a fan-out: one platform-level order, and one tenant-scoped fulfilment per seller involved.

Order  (platform-owned)
  id, buyer_id, placed_at, total, payment_ref

Fulfilment  (tenant-owned)
  id, order_id, tenant_id, status, subtotal, carrier_ref

FulfilmentLine  (tenant-owned)
  id, fulfilment_id, tenant_id, product_id, qty, unit_price_at_purchase

Now every question has a natural home. "Show the buyer their order" reads one Order and its Fulfilments across tenants - a platform read. "Show Store A its open orders" reads Fulfilment WHERE tenant_id = A - a tenant read that never sees Store B's lines, because Store B's lines carry a different tenant_id.

Status lives on the fulfilment, not the order. A buyer's single purchase can be "shipped" from one store and "preparing" at another, and the model should say so rather than collapse it into a lie.

One platform-owned order fanning out into one tenant-owned fulfilment per seller PLATFORM-OWNED Order buyer_id · total · payment_ref TENANT-OWNED - status lives here, not on the order Fulfilment tenant_id = A status: shipped Fulfilment tenant_id = B status: preparing Fulfilment tenant_id = C status: ready one checkout → one order → N fulfilments, each sealed to its store
The fan-out. A buyer reads the order across tenants; a store reads only the fulfilments carrying its own tenant_id.

3. Make tenant_id structural, not decorative

A tenant_id column that exists only to be filtered by is a column somebody will forget to filter by. Three mechanisms turn it from a convention into a guarantee:

Composite keys and leading indexes. Tenant-owned tables key on (tenant_id, id), and every index on a tenant-owned table leads with tenant_id. This does two things: it makes the planner's life easy for the overwhelmingly common per-tenant query, and it makes a query that omits the tenant physically slower - a small, useful pressure toward doing it right.

A scoped repository, not a scoped query. Application code never touches a tenant-owned table directly. It obtains a TenantRepository(tenant_id) and every method on it injects the predicate. The unscoped path exists, is named Platform…, and is used by a small number of clearly labelled callers.

// Tenant-owned: the tenant is fixed at construction, not per call.
const store = repos.forTenant(ctx.tenantId)
await store.products.list({ status: "active" })
// → SELECT … FROM products WHERE tenant_id = $1 AND status = 'active'

// Platform-owned: no tenant, and the type system says so.
await repos.platform.orders.forBuyer(ctx.buyerId)

The point isn't the specific API; it's that the tenant is bound once, in one place, and a call site can't quietly drop it.

Row-level security as the backstop. Databases that support RLS (Postgres does it well) can enforce tenant_id = current_setting('app.tenant') on every read and write to tenant-owned tables. Treat this as the seatbelt, not the steering wheel: it catches the query that escaped the repository, but a system that relies on it has already lost the plot at the application layer. Set it up, test that it fires, and hope never to see it in a log.

4. Move cross-tenant reads off the transactional path

Search, discovery and the home feed read across every tenant, on every request, with relevance and filtering the transactional schema was never designed for. Serve them from a read model - a denormalised index (a search engine, a materialised view, a dedicated table) populated from tenant-owned writes.

Here the tenant becomes a facet, not a boundary. The index carries tenant_id so results can be filtered to a store, grouped by store, or ranked with a per-store boost - but nothing about the index's shape assumes isolation. This is the correct place for the marketplace's fundamentally cross-tenant nature to live, and keeping it out of the transactional schema is what lets that schema stay simple.

The trade-offs

Isolation is logical, so it's only as good as enforcement. A bug in the repository layer can leak one store's data to another. Per-tenant databases can't have that bug. The mitigations above are real, but they are mitigations; the failure mode exists.

One seller can degrade the experience for all of them. A store with a million SKUs and a bulk import at noon shares a database with a store that has forty. Connection pooling, statement timeouts and rate limits on write-heavy endpoints are the tools, and they have to be in place before the first large seller, not after.

Per-seller data export and deletion are harder. "Give me everything about Store A" is a scatter across every tenant-owned table. It's entirely doable - the classification table above is literally the list - but it has to be built, where in a per-database model it's a backup.

The classification is load-bearing and someone has to defend it. Every new table gets asked "who owns this?", and the tempting answers ("both", "it depends") are exactly the ones that reintroduce the problem. A review checklist that asks the question is cheaper than any amount of untangling later.

When it flips

Shared schema stops being the right answer under specific, recognisable conditions - and usefully, tenant_id on every row is what makes leaving it possible.

None of these tend to arrive before product-market fit. Building for them first means paying the isolation tax on every feature during the period when the cross-tenant reads are what you're trying to prove people want.

Checklist

Before committing to a tenancy model for a marketplace:

  1. Have we written down, per entity, whether it's tenant-owned, platform-owned or bridging?
  2. Does the order model let one buyer purchase from two sellers in a single checkout, with independent fulfilment status per seller?
  3. Is the tenant bound once at the data-access layer, or re-supplied on every query?
  4. Do tenant-owned tables key and index with tenant_id leading?
  5. Is there a backstop - RLS or equivalent - and is there a test proving it fires?
  6. Are search and discovery served from a read model, or from the transactional tables?
  7. Do we know which of the three flip conditions is most likely for our sellers, and what the migration would look like?
  8. Have we resisted deciding "it depends" for any table?

A marketplace is a multi-tenant system with a hole deliberately cut in the wall. The work is deciding exactly where the hole goes, and making sure nothing else gets through it.