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-Idrequest header - Isolation
- One shared database, a
CompanyIdon every row - You touch it when
- You add an entity or a read query
How a request gets its company
Section titled “How a request gets its company”Middleware picks the company once per request. Everything downstream reads it from ICurrentCompany.
X-Company-Id[CompanyId] = @CompanyId in every query| 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:
.AddAppointMeMultiTenancy(tenancy => tenancy.FromHeader("X-Company-Id"))The frontend sends it on every API call. The company the user picked is kept in currentCompanyStore:
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 } : {}), };Handlers: CompanyId is injected
Section titled “Handlers: CompanyId is injected”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. |
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.
Writes: EF Core query filters
Section titled “Writes: EF Core query filters”Each module’s DbContext registers a named filter on every tenant-scoped entity, so a handler can’t forget to scope a query.
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:
var companyMemberships = await dbContext .Employees.AsNoTracking() .IgnoreQueryFilters([EmployeeFilters.CompanyId]) .Where(employee => employee.UserId == user.Id)Reads: Dapper carries the predicate
Section titled “Reads: Dapper carries the predicate”Dapper never sees EF filters, so every read method takes a CompanyId and filters on it explicitly.
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")Background work: run it for a company
Section titled “Background work: run it for a company”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.
Make your own entity tenant-scoped
Section titled “Make your own entity tenant-scoped”| 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. |
Gotchas
Section titled “Gotchas”| 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.