Oleg Katrichuk
Назад к журналу
·2 мин чтения

Multi-tenant с первого дня — ADR, который пропускает каждый соло-фаундер

Если ваш SaaS когда-нибудь получит второго клиента, каждая таблица должна нести колонку тенанта с первой миграции. Прикрутить это потом — это не рефакторинг, а переписывание.

SaaS.NETPostgreSQLАрхитектура

В каждом соло-собранном SaaS есть момент, когда вы пишете первый CREATE TABLE и говорите себе, что добавите колонку тенанта позже — когда появится второй клиент. Не добавите. Это решение разветвляется через каждый join, каждый запрос, каждый cache key — и когда вы замечаете, «позже» уже означает четыре недели переписывания, которые ничего не выпускают.

Я принял это решение с самого начала на Futura AI (встраиваемый AI-ассистент для бьюти-салонов): каждая таблица получила tenant_id с первой миграции, а короткий ADR объяснял почему — ещё до того, как появилась сама схема.

Сколько multi-tenant с первого дня реально стоит

Цена — две дополнительные строки кода на таблицу и один фильтр на запрос. Всё.

public sealed class Conversation
{
    public Guid Id { get; init; }
    public Guid TenantId { get; init; }  // <-- вот это
    // ... всё остальное
}

modelBuilder.Entity<Conversation>(b =>
{
    b.HasIndex(x => new { x.TenantId, x.CreatedAt }); // <-- и это
    b.HasQueryFilter(x => x.TenantId == _tenantContext.Current);
});

HasQueryFilter в EF Core означает, что вы пишете db.Conversations.Where(...) так же, как всегда; область тенанта невидима в call-site, а отсутствующий фильтр становится compile-time проблемой (нет контекста тенанта — нет записей), а не data-leak багом в 2 часа ночи.

Сколько стоит прикрутить это потом

Если пропустите с первого дня, каждая таблица, которую вы написали, требует колонку, backfill, индекс и query filter — всё скоординировано через одну продакшн-миграцию без даунтайма. Вы также обнаружите, что:

  • Ваши foreign key пересекают границы тенантов (user_id, на который ссылается таблица без собственного tenant_id).
  • Ваши кеши ключатся id-сущности без префикса тенанта, поэтому первый cross-tenant read после миграции отдаёт не ту запись.
  • Ваши background jobs запрашивают «все разговоры старше 30 дней» без какого-либо tenant scope.

Ничто из этого не является технически сложным. Всё это координационно сложное — и всё это всплывает на проде, где уже есть клиенты. Окно тихо это поправить закрыто.

ADR помещается на салфетке

Вам не нужен длинный документ. Вам нужен письменный запис почему было принято это решение — чтобы вы-будущий (или кто это унаследует) не отменил его в рефакторинге.

# ADR-001: Каждая таблица имеет tenant_id с первого дня

Status: Accepted
Date:   2025-12-XX

Decision
  Каждая персистированная сущность несёт колонку `tenant_id`. Запросы
  скопируются через EF Core query filters, привязанные к
  ambient ITenantContext.

Why
  Прикрутить multi-tenancy после первого клиента означает
  скоординированную продакшн-миграцию по всем таблицам и реальный
  риск cross-tenant утечек. Цена заранее — одна колонка и один фильтр
  на сущность.

Consequences
  - Локальные seed данные должны иметь дефолтного тенанта.
  - Интеграционные тесты должны явно ставить ITenantContext.
  - Один глобальный шаг миграции проверяет, что ни одна сущность не
    осталась без колонки.

Это весь документ. Десять минут на написание — и четверть инженерного времени сэкономлено в тот день, когда это понадобится.

Где это бьёт, если этого не сделать

Я спасал проекты, которые пропустили этот шаг. Паттерн всегда один: работающее приложение, клиенты, и бэклог-item «поддержка нескольких рабочих пространств», который кикают уже шесть месяцев, потому что на самом деле это означает переписать каждый запрос.

Если у вашего продукта может быть второй клиент — а у большинства B2B SaaS может — самый дешёвый день добавить tenant_id — это день, когда вы пишете свой первый CREATE TABLE. Каждый следующий день дороже предыдущего.

Строите что-то похожее?

Расскажите, над чем работаете. Беру небольшое число проектов одновременно.