Blog
All Blog Posts | Next Post | Previous Post
Multi-Tenancy and Database Sharding in TMS XData
Monday, October 5, 2026
Most SaaS backends start the same way: one database, a TenantId column in every table, and a WHERE TenantId = :tenant in every query. It works well, until the business grows:
- A large customer wants its data in a dedicated database, for compliance or performance reasons.
- The shared database keeps growing, and you want to spread tenants over several database servers.
- A customer stops paying, and you need to suspend it now, not at the next deploy.
- Sign-ups should create a tenant automatically, with its initial data, in whichever database has room.
From there, every team writes the same infrastructure: a table mapping tenants to databases, a connection pool per database, a cache so that table isn't queried on every request, error handling for unknown and suspended tenants, and some way to run background jobs for a given tenant. We have seen several of these in production Delphi servers. They all look alike, and each one took real effort to get right.
The next versions of TMS Aurelius and TMS XData ship that infrastructure built in: a tenant catalog with database sharding, wired into the request pipeline by a single component.
A quick refresher
TMS XData is a framework for building REST APIs in Delphi. It builds on TMS Aurelius, an ORM framework, to query and persist your entities, and runs on TMS Sparkle, a high-performance HTTP server framework.
Each of the three answers one question that a multi-tenant request raises:
| Question | Answered by |
|---|---|
| Who is the tenant? | Sparkle's TTenantMiddleware, which reads it from a header, a query parameter, the URL path, a subdomain or a token claim. |
| Where does its data live? | The new Aurelius tenant catalog: TAureliusTenantCatalog maps each tenant to the database that hosts it. |
| Which rows are theirs? | An Aurelius global filter, which keeps tenants apart inside a database they share. |
The new TXDataTenantConnectionPool component ties them together: for every request it resolves the tenant, finds its database, hands XData a connection to it and enables the tenant filter. Your endpoints stay exactly as they are - no tenant parameter, no extra where clause, no second entity model.
One model, three topologies
A shard, here, is simply a database that hosts one or more tenants, and the catalog maps every tenant to exactly one shard. That single idea covers the usual layouts:
- Sharded: a few databases, several tenants in each. The general case.
- Database per tenant: one tenant per shard.
- Single database: everybody in one. You don't even need a catalog for this one - the tenant middleware plus a global filter is the whole solution.
Because they share one model, moving your biggest customer to a dedicated database later is a change in the catalog, not a rewrite.
Configuration, not code
A sharded server is four components on a data module:
- a
TSparkleTenantMiddlewarein the XData server's middleware list, saying where the tenant id comes from (HeaderName = 'tenant-id', for example) and whether it is mandatory; - a
TAureliusConnectionpointing at the catalog database - the one that stores which tenant lives in which shard; - a
TAureliusTenantCatalog, connected to it; - a
TXDataTenantConnectionPool, with itsCatalogproperty set to the catalog andEnableFilterset toTrue. Set it as thePoolof yourTXDataServerand the server is multi-tenant.
The only routing code you write is one event: given a shard, return a connection to it. The catalog stores just a logical shard id, never a connection string or a password, so credentials stay in your configuration:
procedure TServerContainer.TenantPoolCreateShardConnection(Sender: TObject;
const Shard: TShardInfo; var Connection: IDBConnection);
begin
// FConfig is your configuration file, with one section per shard id
Connection := TFireDacConnectionAdapter.Create(
CreateFDConnection(FConfig, Shard.ShardId), True);
end;
The component pools connections per shard, creating each pool the first time a tenant of that shard shows up.
Starting the server is a handful of idempotent calls, safe to run at every start:
TenantCatalog.UpdateCatalogSchema; // create/update the catalog tables
TenantPool.UpdateShardSchemas; // your tables, in every registered shard
TenantCatalog.AddTenant('acme', 'ACME Corp', 'shard-a');
TenantCatalog.AddTenant('globex', 'Globex'); // no shard given: the allocator picks one
Note that schema updates are an explicit step - they never happen as a side effect of serving a request.
Row isolation you can't forget to apply
When several tenants share a shard, the global filter declared on your entities is what keeps them apart:
[Entity, Automapping]
[FilterDef('Multitenant', '{TenantId} = :tenant_id')]
[FilterDefParam('Multitenant', 'tenant_id', TypeInfo(string))]
[Filter('Multitenant')]
TProduct = class
...
[XDataExcludeProperty]
FTenantId: string;
From there it's automatic, in three places:
- The pool component enables the filter, with the current tenant id, on the object manager of every request. Queries only ever see the tenant's own rows.
- The filter enforcer stamps the tenant id on every insert and refuses writes to rows belonging to other tenants.
XDataExcludePropertykeeps the tenant id out of the JSON payloads, so clients never see it and cannot set it.
Clients then simply say who they are:
GET /tms/api/Product HTTP/1.1
tenant-id: acme
Service operations get the same treatment: TXDataOperationContext.Current.GetManager returns a manager already connected to the current tenant's shard, with the filter enabled.
The tenant comes from the token, not from a header
A header is fine for an internal API, but on a public one any client can send any header. What you want is a tenant derived from something the caller can't forge.
The catalog stores identifiers for that: external keys registered for a tenant - a user id, an API key, a custom domain - which the pool's TenantLookups collection resolves on every request:
// The tenant of the authenticated user: token claim 'sub' -> identifier 'user_id'
TenantPool.TenantLookups.Add(TTenantLookupSource.UserClaim, 'sub', 'user_id');
// The tenant of an API client: header 'x-api-key' -> identifier 'api_key'
TenantPool.TenantLookups.Add(TTenantLookupSource.Header, 'x-api-key', 'api_key');
// The tenant owning a custom domain: request host name -> identifier 'domain'
TenantPool.TenantLookups.Add(TTenantLookupSource.Host, '', 'domain');
You register those keys as tenants gain users, API clients or domains (TenantCatalog.SaveIdentifier('user_id', UserId, 'acme')), and the lookups are cached - including the misses - so they don't hit the catalog database on every request.
They also close the obvious hole: if the header says globex but the access token belongs to an ACME user, the request is rejected with 400 TenantMismatch. A user of one tenant cannot reach another tenant's data by editing a header.
If your tokens come from TMS Sphinx, our OAuth 2.0 and OpenID Connect server framework, you can go further and put the tenant id in the token itself at issue time.
Failure modes a single-tenant server doesn't have
An unknown tenant, a suspended customer, a database that is down for maintenance: each has a right answer, and the component gives it, in the standard XData JSON error format.
| Situation | Status | Error code |
|---|---|---|
| No tenant in the request | 400 |
TenantRequired |
| Tenant not in the catalog | 404 |
TenantNotFound |
| Tenant pending, suspended or disabled | 403 |
TenantNotActive |
| Two sources disagree on the tenant | 400 |
TenantMismatch |
| Catalog database unreachable | 503 |
TenantCatalogUnavailable |
| Shard disabled or unavailable | 503 |
ShardUnavailable |
The 503 responses carry a generic message, so connection errors and other infrastructure details never reach the client. And if you'd rather not let callers discover which tenant ids exist, AntiEnumeration answers inactive tenants with the same 404 as unknown ones.
Running the business, without restarting the server
This is where a catalog pays off. The everyday operations are one-liners, typically exposed through an administrative service:
TenantCatalog.SuspendTenant('acme')andActivateTenant- status is checked on every request, even when the tenant's connections are already pooled, so a suspended customer stops being served at once.TenantCatalog.MoveTenantToShard('acme', 'shard-b')- changes the routing, after you've copied the data.TenantPool.InvalidateShard('shard-a')- drops a shard's pool after rotating its database credentials. Requests in progress finish on their current connections; new ones get a pool built with the new settings. No restart.
Routing information is cached (30 seconds by default), so the catalog database isn't in the path of every request. Changes made through the component invalidate the cache immediately; other server processes pick them up when their entries expire.
New customers provision themselves
AddTenant is more than an insert: it allocates a shard, runs your provisioning code and only then activates the tenant. If provisioning fails halfway, the tenant stays Pending and the next call resumes it, so a sign-up that timed out can simply be retried.
- The shard is chosen for you. The default allocator is a weighted hash over the active shards; round robin, fixed shard, one shard per tenant and a custom callback are also available. Shards marked
Drainingkeep serving their tenants but receive no new ones - that's how you retire a database gracefully. OnProvisionTenantruns inside a tenant scope, with routing already pointing at the new tenant's shard, so seeding initial data is ordinary Aurelius code.OnProvisionShardruns when a tenant needs a shard that doesn't exist yet - your handler creates the database and registers it. Combine it with theTShardPerTenantAllocator, which gives every tenant its own shard, and a sign-up ends with a brand new database, created and ready, before the tenant is activated.
Jobs, imports and schedulers
Not everything happens inside an HTTP request. A nightly report or a queue consumer needs to run "as" a tenant too, and TTenantScope does that for the current thread:
Scope := TTenantScope.Use(TenantId);
try
// TenantPool now routes to this tenant's shard
Manager := TObjectManager.Create(TenantPool.GetPoolInterface.GetConnection);
...
finally
Scope := nil;
end;
Scopes nest, and the same routine works whether it's called from a scheduled job or from a service operation that has to act on another tenant.
And none of this is tied to HTTP. The catalog, the routing cache, the per-shard pools, the tenant scope and the provisioner are Aurelius classes (the new Aurelius.Tenancy.* units), usable from a desktop application, a worker process or a command-line provisioning tool. You can also plug in your own catalog - a client of a central catalog service, for instance - instead of the database-backed one.
Conclusion
Multi-tenancy used to be something every XData team built for itself. Now it's configuration:
- Any topology - shared database, database per tenant, or tenants spread over shards - from one model, with room to move between them as you grow.
- One event handler for routing. Resolution, pooling, caching and row filtering are handled by the components.
- Secure by default: isolation enforced on reads and writes, tenants resolved from tokens rather than headers, mismatch detection, anti-enumeration, and no infrastructure details in error responses.
- Day-2 operations built in: suspend, move and provision tenants, rotate credentials and update every shard's schema, without a restart.
- Beyond requests: the same routing in background jobs, and the whole layer usable without HTTP.
The MultiTenancy demo folder in TMS XData has it running: MultiTenantCatalog for the sharded setup described here, next to the simpler MultiTenantByHeader.
To learn more, read the XData Multi-Tenancy chapter, the Aurelius Multi-Tenancy chapter and Global Filters, and the what's new lists of TMS XData and TMS Aurelius. And visit the product pages of TMS XData and TMS Aurelius, both part of TMS BIZ.
Wagner Landgraf
This blog post has received 4 comments.
2. Monday, October 5, 2026 at 4:17:39 PM
It''s already released and available
Wagner Landgraf
3. Tuesday, October 6, 2026 at 9:58:17 PM
Question - how do include new tenants without restarting the server? Example - new customer, I prepare a new DB (1 tenant 1 DB) and want to start using the new DB. Is this possible? The same applies for removing a DB.
dino
4. Tuesday, October 6, 2026 at 10:14:05 PM
Yes, it''s possible.
Wagner Landgraf
All Blog Posts | Next Post | Previous Post
stephane.wierzbicki