# 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.

Source page: https://appointme.dev/docs/concepts/multi-tenancy/

* **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

## How a request gets its company

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

*Figure 1. One resolution step, three places the company is enforced.*

1. **HTTP request** — the SPA adds `X-Company-Id`

2. **CompanyResolutionMiddleware** — runs each detection strategy in order

3. **ICurrentCompany** — holds the company for the rest of the request

4. Then, in parallel:

   * **EF Core writes** — a named query filter on each entity
   * **Dapper reads** — `[CompanyId] = @CompanyId` in every query
   * **Wolverine messages** — the company travels as the envelope tenant id

| 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.cs

```cs
.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.ts

```ts
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 } : {}),
    };
```

The header alone grants nothing

The header only says which company the caller *wants*. Access is checked when the handler loads the caller’s [principal](/docs/concepts/identity-and-principal/). `UserPrincipalFactory` looks the user up among that company’s employees, and if the user isn’t one it throws `AccessDeniedException`, which the API returns as `403`.

## 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.                                             |

src/CRM/AppointMe.Crm/Customers/GetCustomers/GetCustomerQueryHandler.cs

```cs
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

**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.cs

```cs
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.cs

```cs
var companyMemberships = await dbContext
    .Employees.AsNoTracking()
    .IgnoreQueryFilters([EmployeeFilters.CompanyId])
    .Where(employee => employee.UserId == user.Id)
```

## Reads: Dapper carries the predicate

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

src/CRM/AppointMe.Crm/Customers/Database/CustomersRepository.cs

```cs
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")
```

Gotcha

A Dapper query without the `[CompanyId] = @CompanyId` predicate returns rows from every tenant. Nothing enforces this predicate: it’s a convention, so check it in code review for every new read query.

## 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

| 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](/docs/guides/database-migrations/). |

## 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?

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?

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.

## Next

* [Identity & principal](/docs/concepts/identity-and-principal/) (Concept)
* [Permissions](/docs/concepts/permissions/) (Concept)
* [Identity vs principal in .NET](/blog/identity-vs-principal-in-dotnet) (Blog)
