Multi-tenancy is a simple promise with long consequences: many companies use one application, and their data must never be visible to each other — not sometimes, but always. This is the pattern I use in two production systems (Dexova and Helixio), plus the traps I ran into.
Choose the isolation model deliberately
There are three common options: a separate database per tenant, a separate schema per tenant, or one shared schema with a tenant column. For a SaaS with many small-to-mid tenants, a shared schema is almost always the right starting point: cheapest to operate, one migration run, and onboarding a new tenant is practically free.
The price: isolation becomes entirely the responsibility of application discipline. Every query must be scoped to a tenant — and 'every' here really does mean every.
Tenant context you cannot forget
The classic mistake: relying on every developer to remember to add the tenant filter to a query. Forget once, and data leaks. Tenant context must flow from authentication through the entire execution path — in Go, via a context.Context populated by middleware after the token is validated, then read by the data layer.
The principle: the safe path must be the easiest path. A query helper that takes the tenant from context explicitly turns 'forgot the filter' from a silent bug into code that looks obviously wrong at review time.
RBAC on top of isolation, not instead of it
Tenant isolation answers 'which company's data' — it does not yet answer 'who can do what'. In Helixio I use four roles (Owner, Admin, Member, Viewer) per workspace; in Dexova, per-module roles (e.g. cashier vs manager in POS, with access revocation from the dashboard).
They are different layers, and should not be mixed: the tenant check happens in the data layer, the permission check in the use-case layer. Mixing them makes both hard to test.
Traps that only bite later
- Background jobs: an async job has no request — tenant context must be carried explicitly in the job payload, not pulled from 'the currently logged-in user'.
- Cache keys: a key without a tenant prefix is a data leak waiting for a schedule. Namespace every key per tenant.
- Global vs tenant data: reference tables (e.g. bank lists, public holidays) are intentionally global — mark clearly which ones are global so nobody 'fixes' them by adding a tenant column.
- Exports and reports: the path that most often joins many queries — and the most common place for a single filter to be missed. Isolation testing matters most exactly here.
Summary
Multi-tenancy is not a feature but a property the architecture has to protect: tenant context that flows automatically, a safe path that is the easiest to use, and healthy suspicion toward every path that does not go through a request — jobs, caches, and reports.