Tech Series: Multi-tenant design in practice
Part 5 of the inveazy tech series. Soft tenancy leaks data across branches. When an operator switches warehouses, every hub query must already know which workspace — the browser never decides.
Soft tenancy kills data integrity. TenantId discipline saves it.
When an operator switches from Warehouse North to Storefront South, they expect clean separation. Here is how every query enforces it — and why the browser can never be trusted with that decision.
Part 5 of the inveazy tech series. Part 4 closed on a promise: the same Razor and /api host that paints hub grids also has to keep isolation as habit — including on the queries that feed those grids. This post is where that habit lives — in the columns, the session context, and the guardrails. Not a lecture on tenancy theory.
Series: ← Front-end patterns that stay maintainable · Earlier: Web app and API architecture · Next → DevOps and environments
The rule: The company owns billing and branding. The workspace owns inventory, orders, and operational data. Every hub query knows its workspace — or it does not run.
Company / install (billing, branding, public site)
├── Workspace A — TenantId = A (warehouse)
└── Workspace B — TenantId = B (retail counter)
Soft tenancy means a shared table with a tenant column that queries do not filter — rows from every workspace can appear on one grid. Hard walls means separate infrastructure for every operating unit (overkill when you only need a second warehouse under the same company site).
Why soft tenancy fails (and hard walls break the wrong things)
The two failure modes
Soft tenancy is the shared table with a tenant column that nobody filters when the UI is in a hurry — Warehouse North’s orders quietly appear next to Storefront South’s. That is a data breach of the soft kind: wrong inventory, wrong customers, wrong invoices.
Hard walls everywhere is the opposite mistake: treating the landing page, blog, and storefront as if each warehouse needed its own marketing site. Operators do not want that. Engineers should not invent it.
Where inveazy draws the line
inveazy draws the line once and repeats it. A paid install is already a private site and database for that customer — the commercial boundary from checkout to https://{slug}.inveazy.com is the provisioning chapter later in this series (Part 3 is the host contract that chapter will sit on). Inside that install, teams still need more than one operating unit. Those units are workspaces. Mixing them on a sales-order list is not a styling bug.
The organizational boundary: billing, branding, public site
Call this the Company (the install / subscription envelope). In product UI you may still see the word Organization — here we mean your company on inveazy, not a CRM account you sell to.
The Company owns subscription and billing, who may administer the install, public branding, and the public surfaces that stay company-wide: one landing page, one web storefront, one blog. When someone switches from Warehouse North to Storefront South, they do not get a second marketing site. They get a different operational tenant under the same company skin.
Vocabulary: Company = billing + branding + public site. CRM “organization” = a customer you track. Workspace = the signed-in operating unit (the tenant).
The operational boundary: workspace and TenantId
The workspace is where inventory, sales orders, CRM pipeline cards, POS tickets, and project work items live. Users belong to workspaces. Roles and module flags are evaluated in that context. A pick list for Warehouse North must not quietly include bins from Storefront South because a developer forgot a predicate.
Note: SandBox is a practice workspace for first-time setup when an install seeds sample data. Treat it as a real TenantId with disposable story data — not as a backdoor into live workspaces.
| Company (install) | Workspace (tenant) |
|---|---|
| Billing and subscription | Inventory, bins, pick lists |
| Public branding and landing | Sales orders and invoices |
| One storefront, one blog | CRM pipeline and contacts for that unit |
| Admin of the install | POS tickets, projects, workspace users (incl. SandBox) |
Why it matters: Operators ask “which location am I working?” Engineers must ask “which TenantId is on this request?” Those two questions should always agree.
TenantId as a coding habit
Every operational table that belongs to a workspace carries TenantId. The host assigns it at login from the authenticated session and workspace membership — not from a query string the browser typed. Controllers do not invent a tenant from client input. Hub JavaScript does not send “trust me, I’m warehouse 7.” Lists, detail loads, and writes go through services that already know the signed-in workspace. That is the same “SQL stays on the server” contract from Part 4, with one more non-negotiable filter.
Here is what that query looks like: the tenant id is a parameter, passed from the session, never from user input.
Browser → Host (resolves TenantId from session)
→ Query (WHERE TenantId = @tenantId)
→ Rows for that workspace only
WHERE t.TenantId = @tenantId
AND t.IsDeleted = 0
-- @tenantId from host context, not the browser’s wishlist
We filter at the app layer; the database filters too. Row-level security (RLS) in SQL Server is a database-level filter that acts as a second defense: even if the app layer forgets a WHERE clause, the database refuses rows outside the principal’s tenant predicate. Defense in depth is the design. What we do not publish — and will not sketch here — is how to turn that belt off, how to impersonate another tenant, or how to “fix the UI” by soft-deleting across workspaces. Those are incident pathways, not blog tutorials.
Keeping public and hub routes separate
Not every row is workspace-scoped. Public blog posts, landing blocks, and company site profile are meant to be seen as the company, not as Warehouse North’s private ledger. Hub modules — inventory, CRM, accounting, POS — are the opposite: they must never leak across workspaces because a list endpoint omitted a predicate.
That split shows up in the host from Part 3. Anonymous marketing and public blog routes resolve content for the company site. Signed-in /apps and /api routes resolve a workspace. Mixing those contexts is how you get a storefront that shows another branch’s draft orders.
| Public routes (company) | Hub routes (workspace) |
|---|---|
/ · /pricing · /blog/… |
/apps/… · /api/… |
| No operational TenantId on the grid | TenantId from session on every query |
| Shared company branding | Workspace-scoped stock and CRM |
Public: company / marketing surfaces
Hub: workspace (TenantId) — always
Why soft cleanup fails
When a grid shows the wrong customers, the reflex in some teams is to “clean up” the database until the UI matches: soft-delete the other workspace’s rows, merge tenants, or run a script across every TenantId because staging felt messy. That is how production catalogs die. The right fix is almost always the query, the session context, or the workspace the user is actually in — not a cross-tenant DELETE dressed up as hygiene.
The same refusal applies after a dacpac republish. Part 2 covered seed lockdown: day-2 publishes must not re-MERGE demo story data into a live site. Multi-tenant isolation is the sibling rule: day-2 operations must not “normalize” one workspace by erasing another.
If the UI is wrong, fix the app’s tenant context. Do not punish the catalog.
FAQ: Tenancy and workspaces
Is every customer in one shared database?
A paid customer install is its own site and database boundary — that commercial isolation is the provisioning story. Inside one install, multiple workspaces share that database with TenantId isolation. Do not collapse those two layers into one sentence when you evaluate the product.
Can a user see more than one workspace?
Yes, when membership allows it. They still work in one workspace at a time for hub screens. Switching workspace changes tenant context; it does not merge inventories on one grid.
Does the browser send TenantId?
No. The browser is not the source of truth — and it will happily lie if you ask it to. It sends cookies and calls same-origin /api. The host decides the workspace. Client-supplied tenant ids are wishful thinking.
How does this relate to Parts 3 and 4?
Part 3 is the host contract (HTML vs JSON). Part 4 is how a grid gets rows through that contract. This post is the filter those rows must always carry: which workspace, resolved by the host, enforced in SQL — with RLS as a named belt, not a party trick.
What this contract keeps in place
The Company owns billing, branding, and company-wide public surfaces. The workspace owns operational data. Hub lists and writes run with a session-derived TenantId; they do not freestyle across tenants to make a demo look tidy. Public routes stay company-shaped; hub routes stay workspace-shaped. Isolation is habit in services and SQL, reinforced in the database where RLS applies — and never “fixed” by cross-tenant cleanup scripts.
One company. Many workspaces. Every hub query knows which — or it does not run.
What’s next: Part 6 — DevOps and environments — covers laptop Docker SQL, shared catalogs that are not disposable, Azure customer sites, and the config layers that keep secrets and seed posture from leaking between those worlds. Multi-tenant discipline only matters if the environment you publish into is treated with the same respect. We will link that post here when it is live.
Operator walkthroughs live in the separate how-to series.