Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20267 min read

Multi-tenancy

Every request runs inside one company. AppointMe works out which company from the request, then scopes every write, read and message to it, so one tenant never sees another's data.

Tenant
A company, identified by CompanyId
Picked from
The X-Company-Id request header
Isolation
One shared database, a CompanyId on every row
You touch it when
You add an entity or a read query

Middleware picks the company once per request. Everything downstream reads it from ICurrentCompany.

HTTP requestthe SPA adds X-Company-Id
CompanyResolutionMiddlewareruns each detection strategy in order
ICurrentCompanyholds the company for the rest of the request
EF Core writesa named query filter on each entity
Dapper reads[CompanyId] = @CompanyId in every query
Wolverine messagesthe company travels as the envelope tenant id
Figure 1. One resolution step, three places the company is enforced.
Piece Where What it does
CompanyId AppointMe.Shared/Companies A readonly record struct around a Guid. The tenant key everywhere.
ICurrentCompany AppointMe.Shared/Companies The company for the current flow, stored in an AsyncLocal. Change(companyId) sets it and returns an IDisposable that restores the previous value.
ICompanyDetection AppointMe.Shared/Companies/Detection One way of finding the company in a request. HeaderCompanyDetection is the only one that ships.
CompanyResolutionMiddleware AppointMe.Api/MultiTenancy Asks each detection strategy in turn. The first one that returns a company wins.

The header name is set once, in the API host:

src/AppointMe.Api/Program.csView source ↗
.AddAppointMeMultiTenancy(tenancy => tenancy.FromHeader("X-Company-Id"))

The frontend sends it on every API call. The company the user picked is kept in currentCompanyStore:

src/AppointMe.Frontend/src/lib/axios.tsView source ↗
export const apiClient = async <T = unknown>(config: AxiosRequestConfig): Promise<T> => {
const companyId = currentCompanyStore.get();
const headers = {
// oxlint-disable-next-line typescript/no-misused-spread -- orval passes plain-object headers; an AxiosHeaders instance also keeps its values as own enumerable props
...config.headers,
...(companyId ? { 'X-Company-Id': companyId } : {}),
};

A Wolverine handler that declares a CompanyId parameter gets the current company, or the request fails with 403.

Two handler policies in AppointMe.Api/Wolverine/HandlerContext do the work:

Policy Runs on Does
CompanyContextPolicy Handlers that use CompanyId Reads ICurrentCompany, throws AccessDeniedException("Active company was not specified.") when it’s empty, and stamps the company on the message context so cascaded messages carry it.
TenantContextPolicy Every handler Reads the incoming envelope’s tenant id and sets ICurrentCompany, so async and background handlers run in the company that sent the message.
src/CRM/AppointMe.Crm/Customers/GetCustomers/GetCustomerQueryHandler.csView source ↗
public async Task<GetCustomersResponse> HandleAsync(GetCustomerQuery query, CompanyId companyId,
IPrincipal principal, CancellationToken cancellationToken)
{
principal.Require(CustomerPermissions.View);

Take both CompanyId and IPrincipal. CompanyId tells you which company. Loading IPrincipal is what checks that the caller belongs to it.

Each module’s DbContext registers a named filter on every tenant-scoped entity, so a handler can’t forget to scope a query.

src/CRM/AppointMe.Crm/Database/CrmDbContext.csView source ↗
modelBuilder.Entity<Customer>()
.HasQueryFilter(CustomerFilters.SoftDelete, customer => !customer.IsDeleted)
.HasQueryFilter(CustomerFilters.CompanyId, customer => customer.CompanyId == currentCompany.CompanyId);
Module Filtered by company Not filtered
CRM Customer —
Booking Appointment, Attendee, ServiceProvider —
Organizations Employee, EmployeeInvitation, RolePermissionOverride Company (it is the tenant)
Identity — User. One user can work for several companies.

The filters are named (EF Core 10), so you can switch off one without losing the others. When a user lists their own companies, the Organizations module drops only the company filter and keeps soft-delete:

src/Organizations/AppointMe.Organizations/Me/GetCurrentUserQueryHandler.csView source ↗
var companyMemberships = await dbContext
.Employees.AsNoTracking()
.IgnoreQueryFilters([EmployeeFilters.CompanyId])
.Where(employee => employee.UserId == user.Id)

Dapper never sees EF filters, so every read method takes a CompanyId and filters on it explicitly.

src/CRM/AppointMe.Crm/Customers/Database/CustomersRepository.csView source ↗
public async Task<PagedResult<CustomerDto>> GetAll(string[] searchTokens, PaginationFilter pagination,
CompanyId companyId, CancellationToken cancellationToken)
{
var builder = new ExtSqlBuilder()
.AddPagination(pagination)
.Where("[CompanyId] = @CompanyId", new { CompanyId = companyId.Value })
.Where("[IsDeleted] = 0")

Jobs have no HTTP request, so they set the company on the message they send.

You want to Use Defined in
Invoke a handler for one company bus.InvokeForCompany(companyId, message) AppointMe.Shared/Companies/MessageBusExtensions.cs
Return a cascading message for a company message.WithCompany(companyId) AppointMe.Shared/Companies/CascadingMessageExtensions.cs

Both put the company in the envelope’s tenant id, and TenantContextPolicy restores it on the receiving side. The Booking module’s AttendeeReconciliationJob loops over companies this way, and DemoSeedingSaga seeds a new company’s demo data the same way.

Step Where What
1. Add the column Your aggregate public required CompanyId CompanyId { get; init; }, set by the factory method. See Customer.Register.
2. Name the filter <Feature>/Database/<Entity>Filters.cs public const string CompanyId = nameof(CompanyId);
3. Filter writes The module’s DbContext.OnModelCreating .HasQueryFilter(<Entity>Filters.CompanyId, x => x.CompanyId == currentCompany.CompanyId)
4. Take the company Each handler A CompanyId companyId parameter, plus IPrincipal principal and a principal.Require(...) call
5. Filter reads Your Dapper repository Accept CompanyId; add .Where("[CompanyId] = @CompanyId", …)
6. Migrate The module Add a migration for that module’s DbContext. See Database migrations.
Symptom Cause Fix
403 Active company was not specified. The request had no X-Company-Id, or a background handler ran without a tenant id Send the header. In jobs, use InvokeForCompany or WithCompany.
403 User is not authorized to access requested company. The user isn’t an employee of the company in the header Pick one of the user’s own companies. The SPA lists them from GET /api/v1/me.
A list shows another tenant’s rows A Dapper query is missing the CompanyId predicate Add .Where("[CompanyId] = @CompanyId", …).
A soft-deleted row reappears You called IgnoreQueryFilters() with no arguments, which removes every filter Pass the filter names you want to skip: IgnoreQueryFilters([XFilters.CompanyId]).
Why one shared database instead of one per tenant?2 min · optional

A shared database with a CompanyId column is the cheapest model to run and to migrate. There’s one schema per module, and one migration run covers every tenant. Onboarding a company is an INSERT, not a provisioning job.

The cost is that isolation is logical rather than physical, which is why the filters are applied in three places (EF, Dapper and messages) and why the Dapper rule is spelled out above.

To move to database-per-tenant later, the seam is already there. Swap the EF filter for a per-tenant connection string chosen from ICurrentCompany, and the handlers don’t change.

Why a header, not a subdomain or a claim?1 min · optional

A user can belong to several companies and switch between them without signing in again. Putting the company in the token would force a new token on every switch. A subdomain would tie the URL to one tenant.

The header keeps the token about who you are and the request about which company you’re acting in. Detection is a strategy (ICompanyDetection), so you can add a subdomain or route-based strategy next to the header one.