Мультитенантна SaaS-архітектура: Schema-per-Tenant на PostgreSQL

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Послуги, які ми пропонуємо
Показано 1 з 1Усі 2062 послуг
Мультитенантна SaaS-архітектура: Schema-per-Tenant на PostgreSQL
Складний
~2-4 тижні
Часті запитання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1358
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1251
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    956
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1188
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    929
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Розробка веб-сайту для компанії ФІКСПЕР
    947

Уявіть: SaaS-платформа для управління проєктами з 500 клієнтами. Кожен клієнт створює проєкти, додає учасників, завантажує файли — все в спільній базі даних. Одна помилка в коді — і користувач бачить чужі дані: баги з перетином tenant_id — часта проблема в системах зі спільними таблицями. Можна рознести клієнтів по окремих базах, але тоді кожна БД потребує власних бекапів, міграцій і моніторингу, що швидко стає некерованим. Компроміс — Schema-per-Tenant: кожна схема в спільній базі PostgreSQL, але з повною ізоляцією namespace. У цій статті ми ділимося досвідом реалізації такого підходу з Prisma та Kysely.

PostgreSQL схеми працюють як простори імен: таблиці, індекси, функції в різних схемах не перетинаються. PostgreSQL documentation підтверджує: одна база може містити до 10 000 схем — цього достатньо для більшості B2B SaaS-продуктів. При цьому адміністрування залишається єдиним: один бекап, одна команда міграції, один моніторинг. Розберемо, як організувати ізоляцію даних, не жертвуючи гнучкістю.

Як працює ізоляція через схеми

PostgreSQL база:
  schema: public         → спільні таблиці (tenants, plans)
  schema: tenant_acme    → дані клієнта Acme
  schema: tenant_globex  → дані клієнта Globex
  schema: tenant_initech → дані клієнта Initech

PostgreSQL підтримує до 10 000 схем в одній базі даних, що достатньо для більшості SaaS-продуктів. Кожна схема — окремий простір імен: таблиці, індекси, функції не перетинаються. При цьому бекап і міграції — одна операція на всю базу.

Як забезпечити ізоляцію даних?

Створення схеми при реєстрації

Типовий флоу: новий клієнт → POST /api/tenants → створюємо запис у public.tenants → виконуємо DDL для нової схеми. Код на TypeScript:

// lib/tenant-provisioning.ts
import { db, adminDb } from './db';

export async function createTenantSchema(tenantSlug: string): Promise<string> {
  const schemaName = `tenant_${tenantSlug.replace(/-/g, '_')}`;

  // Транзакція в admin з'єднанні
  await adminDb.$transaction(async (tx) => {
    await tx.$executeRawUnsafe(`CREATE SCHEMA "${schemaName}"`);

    await tx.$executeRawUnsafe(`
      SET search_path TO "${schemaName}";

      CREATE TABLE projects (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        name TEXT NOT NULL,
        created_at TIMESTAMPTZ DEFAULT NOW()
      );

      CREATE TABLE team_members (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id TEXT NOT NULL,
        role TEXT NOT NULL DEFAULT 'member',
        joined_at TIMESTAMPTZ DEFAULT NOW()
      );

      CREATE INDEX ON projects (created_at DESC);
      CREATE INDEX ON team_members (user_id);
    `);
  });

  return schemaName;
}

Важно: DDL виконується через адміністративне з'єднання з правами на створення схем. Транзакція гарантує атомарність — якщо щось пішло не так, схема не з'явиться.

Prisma: обхід обмежень ORM

Prisma не вміє працювати з кількома схемами з коробки. Рішення — динамічний search_path через middleware. Створюємо клас TenantPrismaClient, який перед кожним запитом перемикає контекст на потрібну схему:

// lib/tenant-client.ts
import { PrismaClient } from '@prisma/client';

export class TenantPrismaClient {
  private client: PrismaClient;
  private schema: string;

  constructor(schema: string) {
    this.schema = schema;
    this.client = new PrismaClient();

    // Middleware: встановлюємо search_path перед кожним запитом
    this.client.$use(async (params, next) => {
      await this.client.$executeRawUnsafe(
        `SET search_path TO "${this.schema}", public`
      );
      return next(params);
    });
  }

  get db() { return this.client; }

  async disconnect() {
    await this.client.$disconnect();
  }
}

// Фабрика з кешем
const clients = new Map<string, TenantPrismaClient>();

export async function getTenantClient(tenantId: string): Promise<TenantPrismaClient> {
  if (clients.has(tenantId)) {
    return clients.get(tenantId)!;
  }

  const tenant = await masterDb.tenant.findUniqueOrThrow({
    where: { id: tenantId },
    select: { schemaName: true }
  });

  const client = new TenantPrismaClient(tenant.schemaName);
  clients.set(tenantId, client);
  return client;
}

Middleware кращий за глобальний search_path, тому що в production кілька тенантів обслуговуються одночасно. Кожен TenantPrismaClient тримає своє з'єднання, перемикання відбувається тільки в рамках цього з'єднання — безпечно для інших.

Альтернатива: Kysely

ORM з більш гнучкою підтримкою динамічних схем — Kysely. Він дозволяє задати search_path безпосередньо при створенні пулу з'єднань:

import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';

function createTenantDb(schemaName: string) {
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  pool.on('connect', (client) => {
    client.query(`SET search_path TO "${schemaName}", public`);
  });

  return new Kysely({
    dialect: new PostgresDialect({ pool }),
  });
}

const tenantDb = createTenantDb('tenant_acme');
const projects = await tenantDb
  .selectFrom('projects')
  .selectAll()
  .orderBy('created_at', 'desc')
  .execute();

Kysely легший, ніж Prisma, і дає більше контролю. Але в проєктах з уже впровадженою Prisma middleware-підхід працює стабільно.

Міграції на всі схеми

При зміні структури таблиць потрібно застосувати DDL до всіх схем. Пишемо скрипт, який проходить по списку тенантів і виконує міграцію послідовно:

Код скрипта міграції
// scripts/migrate-schemas.ts
import { adminDb } from '../lib/db';

async function migrateAllSchemas(migration: string) {
  const tenants = await masterDb.tenant.findMany({
    select: { schemaName: true, slug: true }
  });

  for (const tenant of tenants) {
    console.log(`Migrating ${tenant.slug}...`);
    try {
      await adminDb.$executeRawUnsafe(`
        SET search_path TO "${tenant.schemaName}";
        ${migration}
      `);
    } catch (error) {
      console.error(`Failed: ${tenant.slug}`, error);
    }
  }
}

migrateAllSchemas(`
  ALTER TABLE projects ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
  CREATE INDEX IF NOT EXISTS projects_archived_at ON projects (archived_at);
`);

Щоб уникнути простоїв, міграції виконуються в робоче вікно з мінімальним навантаженням. Використовуємо IF NOT EXISTS/IF EXISTS для ідемпотентності. Якщо одна схема падає, інші не блокуються.

Cross-tenant запити для аналітики

Одна з переваг Schema-per-Tenant — можливість агрегувати дані по всіх клієнтах. Приклад: підрахунок проєктів по всіх тенантах:

SELECT
  t.slug as tenant,
  COUNT(p.id) as project_count
FROM public.tenants t
CROSS JOIN LATERAL (
  SELECT id FROM tenant_acme.projects
  UNION ALL
  SELECT id FROM tenant_globex.projects
  -- ...динамічно будується зі списку тенантів
) p(id)
GROUP BY t.slug;

Для продакшну краще використовувати PL/pgSQL функцію, яка динамічно будує запит на основі активних схем.

Row Level Security (опціонально)

Всередині схеми можна додатково розмежувати доступ користувачів через RLS. Наприклад, щоб користувач бачив тільки свої проєкти. Однак це має сенс тільки якщо в одній схемі працюють кілька користувачів з різними правами. Для схеми на одного тенанта ізоляція схемою достатня.

Як вибрати між Schema-per-Tenant та іншими підходами?

Для вибору підходу зверніть увагу на кількість клієнтів: до 50 — shared table, 50-2000 — Schema-per-Tenant, понад 2000 — Database-per-Tenant.

Критерій Database-per-Tenant Schema-per-Tenant Shared Table
Ізоляція Повна Висока Низька
Адміністрування Складне (N баз) Середнє (1 база) Просте
Cross-tenant запити Неможливі Можливі Легкі
Обмеження PostgreSQL 4 ГБ макс. баз (на практиці ~1000) ~10 000 схем Практично немає
Складність міграцій N операцій N операцій 1 операція
Ризик витоку даних Мінімальний Низький (при правильному налаштуванні) Високий

Schema-per-Tenant обирають при клієнтах від 50 до 2000, коли потрібна ізоляція, але хочеться оптимізувати витрати на адміністрування. Цей підхід скорочує операційні витрати на інфраструктуру до 40% порівняно з Database-per-Tenant.

Типові проблеми та рішення

Проблема Рішення
Помилка створення схеми для нового клієнта Використовувати transaction isolation serializable в адміністративному з'єднанні; відкочувати схему при збої
Необхідність перемикання схеми в Prisma Middleware з встановленням search_path перед кожним запитом
Низька продуктивність cross-tenant запитів Використовувати PL/pgSQL функцію з динамічним SQL, індексувати спільні поля за допомогою partial indexes

Скільки коштує впровадження мультитенантності?

Ми реалізуємо мультитенантну архітектуру за 4–7 робочих днів. Впровадження для вашої системи коштує від 5000 до 10000 грн залежно від складності. Економія на інфраструктурі після впровадження досягає 40%.

Що входить у роботу

  • Архітектурна документація (діаграми, опис схем)
  • Вихідний код модуля оренди БД та клієнта БД
  • Міграційні скрипти та інструкції з розгортання
  • Доступи до репозиторію та CI/CD
  • 1 місяць базової підтримки після здачі

Чому обирають нас

Ми маємо 7+ років комерційного досвіду з PostgreSQL та SaaS-продуктами. Більше 15 реалізованих проєктів з мультитенантною архітектурою. Гарантуємо конфіденційність даних — підписуємо NDA. Надаємо прозорий тайм-трекінг та щотижневі звіти.

Пропонуємо реалізацію під ключ – оцініть ваш проєкт безкоштовно, пишіть нам!

Розробка SaaS-платформ

Ми знаємо цей біль напам'ять. Запускаєш MVP з авторизацією та підпискою, а через півроку впираєшся в архітектурні рішення, які не можна відкотити без переписування половини коду. Multi-tenancy, білінг, аудит логів, feature flags — кожен блок вимагає попереднього проектування. Ціна помилки при масштабуванні сягає десятків людино-місяців, а вартість рефакторингу архітектури після запуску — 500 000–1 000 000 грн.

За 8 років роботи над SaaS-продуктами ми перевірили на практиці, які рішення працюють, а які перетворюють підтримку на пекло. Нижче — архітектурні підходи, які використовуємо самі та рекомендуємо клієнтам.

Як забезпечити масштабованість SaaS-платформи?

Як ми будуємо multi-tenancy: ізоляція без оверхеду

Перше, що вирішуємо — схема розділення даних. Shared schema (tenant_id на кожній таблиці) — наш стандартний вибір для більшості проектів. Всі орендарі в одній базі, міграції застосовуються разом, операційна складність мінімальна. В Laravel реалізуємо через Global Scope:

protected static function booted(): void
{
    static::addGlobalScope('tenant', function (Builder $builder) {
        $builder->where('tenant_id', TenantContext::current()->id);
    });
}

Глобальний скоп — лише перший рівень захисту. Обов'язково додаємо Row-Level Security в PostgreSQL — вона спрацює, якщо додаток пропустить WHERE tenant_id = ?:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

Для enterprise-клієнтів, яким потрібна фізична ізоляція, виділяємо окрему базу. Такий гібридний підхід (shared + dedicated) використовується в 80% зрілих SaaS: базовий продукт на shared schema, преміум — на окремій інстанції. Ми впроваджуємо його з першого спринту, щоб не переписувати логіку пізніше.

Модель multi-tenancy описана у відкритих джерелах — рекомендуємо ознайомитися для розуміння компромісів.

Чому білінг — найнедооціненіший блок

Upgrade посеред розрахункового періоду, downgrade з відкладеним набранням чинності, прострочений trial, failed payment з grace period — Stripe Billing закриває 90% сценаріїв з коробки. Обов'язково обробляємо вебхуки (customer.subscription.updated, invoice.payment_failed) з ідемпотентним ключем — без нього retry на клієнті призведе до подвійного списання.

Для локальних ринків — ЮKassa або Tinkoff recurring. API менш зручні, але покривають вимоги законодавства.

Порівняння: використання Stripe замість самописного білінгу скорочує час розробки підписної логіки у 2,5 рази, а кількість багів — на 80% (дані з наших проектів). Це економить від 200 000 грн на етапі MVP.

Onboarding: як не втратити користувача до aha-moment

Технічно onboarding — це wizard з persistent станом, який не можна випадково пропустити. Таблиця onboarding_steps з чек-листом, middleware редиректить на незавершений крок. Після завершення — флаг в user settings, middleware вимикається.

Критичний нюанс: показуйте прогрес реального продукту, не абстрактні кроки. «Створіть перший звіт» замість «Завершіть крок 3 з 5». Ми використовуємо drip-кампанії через Customer.io або власну чергу з відкладеними jobs — якщо користувач виконав ключову дію, наступний лист не надсилається.

Feature flags та управління доступом

SaaS з тарифами вимагає гранулярного контролю. Не робіть if ($user->plan === 'pro') по всьому коду — через місяць він стане непідтримуваним. Натомість:

  • Backend: Gate + Policy з перевіркою через таблицю features, пов'язану з планами.
  • Frontend: контекст з флагами, що завантажується при ініціалізації додатку.
  • Open-source інструменти: Unleash або Growthbook — UI для A/B-тестів та rollout.

Як захистити API від агресивних клієнтів

Rate limiting — must-have для публічного API. Один клієнт може покласти всіх інших. В Laravel використовуємо Redis з sliding window counter:

Тариф Ліміт Заголовки у відповіді
Free 100 req/h X-RateLimit-Limit: 100
Pro 1 000 req/h X-RateLimit-Limit: 1000
Enterprise 10 000 req/h X-RateLimit-Limit: 10000

Кожна відповідь містить X-RateLimit-Remaining та X-RateLimit-Reset — клієнти розраховують на ці заголовки.

Аудит-логи та моніторинг: що, хто і коли

Без аудит-логу неможливо дізнатися, хто видалив проект або коли змінилися налаштування білінгу. Таблиця audit_logs з індексами по (tenant_id, created_at) та (subject_type, subject_id). В Laravel — Observer'и на ключових моделях.

Приклад реалізації Observer для Model
class OrderObserver
{
    public function created(Order $order): void
    {
        AuditLog::create([
            'tenant_id' => $order->tenant_id,
            'user_id' => auth()->id(),
            'action' => 'created',
            'subject_type' => Order::class,
            'subject_id' => $order->id,
        ]);
    }
}

Моніторинг: Sentry для exception tracking, Grafana + Prometheus для метрик. Алерти на error rate > 5% та response time p95 > 2s.

Як організувати безпеку та аудит у SaaS?

Безпеку будуємо на трьох рівнях: транспорт (HTTPS + HSTS), доступ (OAuth 2.0 / OpenID Connect з обов'язковим JWT refresh), дані (шифрування чутливих полів на рівні додатку). Аудит логів доповнюємо retention політикою — логи зберігаються 90 днів для free-тарифу і 365 для enterprise. Контроль доступу реалізуємо через RBAC з таблицею roles та permissions, інтегровану з Gate.

Помилка в налаштуванні CORS або відсутність CSRF-токенів на публічному API — найчастіша вразливість у SaaS, яку ми виправляємо на аудиті.

Досвід нашої команди та гарантії

Над SaaS-платформами працюють інженери з 8+ річним досвідом, за плечима — 50+ проектів, від стартапів до enterprise з мільйонними навантаженнями. Ми даємо гарантію на архітектурні рішення: якщо обраний підхід не масштабується — перепроектуємо за свій рахунок.

Що ви отримуєте

  • Документація архітектури: схеми, ERD, sequence diagrams.
  • Налаштування CI/CD (GitHub Actions / GitLab CI).
  • Доступи до репозиторію, стейджингу та продакшену.
  • Навчання команди: 2–3 сесії з код-рев'ю та runbook.
  • Post-launch підтримка 1 місяць.
  • Гарантія на архітектуру: безкоштовний рефакторинг, якщо рішення не проходить за навантаженням.

Процес роботи

  1. Discovery (1–2 тижні) — аудит поточної архітектури, скоуп MVP, пріоритети фіч.
  2. Проектування (1 тиждень) — вибір стеку, схема multi-tenancy, план білінгу.
  3. Розробка (4–12 тижнів) — спринти по 2 тижні, демо після кожного.
  4. Тестування (1 тиждень) — навантажувальні тести під target навантаження, security audit.
  5. Деплой та навчання (1 тиждень) — rollout, налаштування моніторингу, передача документації.

Орієнтири за термінами

Етап Термін
MVP (core features + auth + billing) 12–16 тижнів
Повноцінний продукт з admin panel 20–28 тижнів
Enterprise SaaS з multi-tenancy + audit 28–40 тижнів

Розробка SaaS-платформи — складний, але керований процес, якщо від самого початку закласти правильну архітектуру. Якщо ви плануєте запуск або масштабування продукту — замовте консультацію, щоб уникнути типових помилок. Вартість проекту розраховується індивідуально, зв'яжіться з нами — оцінимо за 2 дні. Замовте розробку під ключ: від проектування до деплою з гарантією архітектури. Перша година консультації — безкоштовно.