Where the tenant boundary goes in a marketplace
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:
- Sellers look like SaaS tenants. Each store owns its catalogue, its orders, its staff, its settings. Store A must never see Store B's margins or customers.
- Buyers are global. They belong to the platform, not to any store, and the product's core value - discovery, comparison, a single checkout - is precisely the ability to read across sellers.
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.
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.
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.
- A seller whose contract requires physical isolation. Regulated sectors, enterprise procurement, data-residency clauses. Because every tenant-owned row already carries its tenant, one store can be lifted into its own database with a filtered copy and a routing rule, without touching the others.
- Noisy-neighbour effects that tuning can't contain. The same property - rows are labelled - makes tenant-based sharding a mechanical migration rather than a redesign.
- Per-tenant schema divergence. If large sellers start needing bespoke fields, structures, or workflows, a shared schema accumulates nullable columns and conditionals until it's a worse version of schema-per-tenant. That's the signal to give those tenants their own.
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:
- Have we written down, per entity, whether it's tenant-owned, platform-owned or bridging?
- Does the order model let one buyer purchase from two sellers in a single checkout, with independent fulfilment status per seller?
- Is the tenant bound once at the data-access layer, or re-supplied on every query?
- Do tenant-owned tables key and index with
tenant_idleading? - Is there a backstop - RLS or equivalent - and is there a test proving it fires?
- Are search and discovery served from a read model, or from the transactional tables?
- Do we know which of the three flip conditions is most likely for our sellers, and what the migration would look like?
- 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.