Stage 7 · Master
The Data Layer
Transactions and Tenant Isolation
Why atomicity and tenant scope solve different problems, how multi-step writes should be modeled in Go, and why a correct transaction can still be dangerously wrong if its predicates ignore the tenant boundary.
Atomicity vs Tenant Isolation
Transactions and tenant isolation are related but not interchangeable. A transaction answers the question 'do these several state changes succeed or fail together?' Tenant isolation answers the question 'are these state changes operating inside the correct customer boundary?' A backend must satisfy both. A perfectly atomic write against the wrong tenant is still a severe data bug.
That distinction matters because beginners often hear 'wrap it in a transaction' as if transactions are a universal safety feature. They are not. Transactions give atomicity, consistency relative to constraints, and controlled visibility under concurrency. They do not automatically add missing WHERE tenant_id = $1 predicates, and they do not infer the intended customer scope of a request.
It does not prove that the chosen work unit was the right one. Correct predicates and correct scope still have to be designed explicitly.
Billing Example — Record Payment
The most instructive Meridian example is payment recording in billing-service. Recording a payment is not a one-row update. The service usually needs to validate the invoice's tenant ownership, create the payment row, update invoice status or outstanding balance, and possibly write an audit or outbox record. Those steps represent one business event. If one succeeds and another fails, the system has told two contradictory stories about the same money movement.
Workflow
Load: The transaction fetches the invoice under the tenant-scoped lock so the current facts are stable.
Step 1 / 5 — Load
Making Tenant Scope Non-Optional
A robust multi-tenant backend should make it awkward to perform an unscoped operation. The easiest way to do that in Go is to require tenant scope in repository method signatures or to derive it from a trusted context populated earlier in the request lifecycle. Meridian already has the second ingredient available: libs/platform/tenancy defines TenantIDHeader, WithTenantID, and TenantIDFromContext. The persistence phase should use that vocabulary consistently instead of letting every package invent its own header and context key conventions.
There are two common designs here. One design passes tenantID explicitly to every repository method. The other reads it from a trusted request context at the service edge and still passes it explicitly from service to repository when clarity matters. The worst design is mixing trusted context, untrusted headers, route params, and free-form strings until nobody can explain which source of tenant truth is authoritative in a given function.
Defense in Depth
Tenant isolation should be defended at multiple layers because the failure mode is so costly. The gateway resolves slug to tenant UUID and forwards X-Tenant-Id. Service middleware should trust only that internal header and write the UUID into context. Repository methods should include tenant predicates in every query. Write queries should often cross-check containment as well: an invoice update can require both tenant_id and invoice_id, and in stricter flows can also verify the resident or unit belongs to the same tenant chain.
update invoices
set status = 'paid'
where tenant_id = $1
and id = $2
and exists (
select 1
from residents
where residents.id = invoices.resident_id
and residents.tenant_id = $1
);The extra check may look redundant on a healthy schema, but redundancy is exactly what catches ancestry drift or coding mistakes before they become cross-tenant data corruption.
| Protection | What it guards | Why it matters |
|---|---|---|
| Gateway tenant resolution | Stops internal services from resolving tenant in ad hoc ways | One service owns slug -> UUID translation |
| Context propagation via libs/platform/tenancy | Gives downstream code one trusted request-scoped tenant source | Reduces header parsing drift |
| Repository tenant predicates | Stops wrong-tenant reads and writes at the query boundary | This is the last line before data exposure |
Current Repository Status
No service currently opens a PostgreSQL connection, begins a transaction, or reads tenant IDs from request context. The only implemented pieces today are the service scaffolds and the shared tenancy helpers in libs/platform/tenancy. This lesson defines how those helpers should be used once persistence arrives.
- Use transactions for multi-step business events such as recording a payment.
- Do not confuse transaction safety with tenant isolation; a query still needs correct tenant predicates.
- Standardize tenant propagation through X-Tenant-Id and context helpers instead of ad hoc conventions.
- Layer defenses: gateway resolution, request context, repository predicates, and containment checks.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.