ABP series · 2026-02-02
How ABP's ICurrentTenant Works Under the Hood (and When It Fails)
A practical look at ABP Framework tenant resolution: the request pipeline, the unit of work, connection string selection — and the three moments where ICurrentTenant silently returns null.
ICurrentTenant.Current is the most-used interface in an ABP multi-tenant app, and the least understood. When it returns null, half your queries misbehave. This is what actually happens between the HTTP request and your repository call.
The chain
- Tenant resolution middleware reads the tenant from the route (
{tenantName}), a subdomain, or a header, and callsCurrentTenant.Change(tenantId). CurrentTenant.Changecreates a new scope withAsyncLocal— the value flows down the async call chain, including across awaits, but not across threads you spawn manually (Task.Run,ThreadPool).- The unit of work reads
ICurrentTenant.IdatBeginTransactiontime to decide which connection string to use. - Repositories and
IQueryablefilter by the resolved tenant id inside the UoW’s data filter.
Where it breaks
- Manual threads: the
AsyncLocalnever reaches aTask.Runbody. Any tenant-dependent work inside it runs as null. - Timing vs. lazy loading: if you
Changethe tenant after the UoW already opened its connection, the filter uses the new id but the connection is the old tenant’s. Same session, wrong database. IUnitOfWorkManager.Beginin a new scope: some ABP modules begin a fresh UoW that re-resolves the tenant fromICurrentTenant— fine — but if you also resetCurrentTenantin that scope, the new UoW picks up null.
Debugging in one step
Log this at the start of the first repository call:
Logger.LogInformation("tenant={TenantId} conn={ConnString}", CurrentTenant.Id, _dbContext.Database.GetConnectionString());
If tenant is non-null but conn is the host’s, the UoW opened its connection before tenant resolution — reorder the pipeline. If both are null, the ambient scope was lost — find the thread hop. Two log lines, every time, beats reading stack traces.