Oleg Katrichuk
Назад до журналу
·2 хв читання

Multi-tenant з першого дня — ADR, який пропускає кожен соло-фаундер

Якщо ваш SaaS колись отримає другого клієнта, кожна таблиця має нести колонку tenant з першої міграції. Прикрутити це згодом — це не рефакторинг, а перепис.

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. Кожен наступний день дорожчий за попередній.

Будуєте щось подібне?

Розкажіть, над чим працюєте. Беру невелику кількість проєктів одночасно.