Как построить мультиарендный SaaS на Prisma и Postgres
Пошаговая архитектура мультиарендного SaaS на Prisma + Postgres: схемы, RLS, индексы, middleware, миграции, прод-подводные камни и стоимость запуска.

Ошибки в мультиарендности дорого стоят: одна неверная политика или забытый фильтр — и данные клиентов протекут между арендаторами. Дороже только миграция с «наивной» модели на правильную на живом продукте. Ниже — практическая схема, как собрать мультиарендный SaaS на Prisma и Postgres так, чтобы не переписывать все через полгода.
Если вам нужен короткий ответ: в большинстве случаев берите общую базу со столбцом tenant_id, включайте Row Level Security (RLS) в Postgres, прокладывайте tenant_id из аутентификации в сессию БД через SET LOCAL, а на уровне Prisma добавляйте middleware для подстраховки. Схемы/базы на арендатора годятся для редких кейсов (регуляторика, крупные VIP-аккаунты с терабайтами данных) и ощутимо дороже в сопровождении.
Варианты мультиарендности и как они бьются с Prisma
Чтобы не спорить вкусовщиной, смотрим на три классики: общий набор таблиц с tenant_id, отдельные схемы, отдельные базы. Ниже — сравнение с учетом Prisma, миграций и прод-операций.
| Модель | Плюсы | Минусы | Совместимость с Prisma | Когда брать |
|---|---|---|---|---|
Общие таблицы + tenant_id | Простой код, одна миграция, общие индексы и кэш | Нужна строгая изоляция на уровне БД (RLS), риск «забыть фильтр» | Идеально: одна схема, единый клиент, Prisma Migrate «из коробки» | 80% SaaS, быстрый старт и масштаб |
| Отдельная схема на арендатора | Псевдо-изоляция, можно разные расширения per-tenant | Миграции N раз, рост схем, боль для DWH/репортинга | Частично: Prisma поддерживает multi-schema, но манифест разрастается | Средние проекты с четкой сегментацией и умеренным ростом |
| Отдельная база на арендатора | Максимальная изоляция, независимые бэкапы/лимиты | Наибольшие затраты: миграции, DevOps, кросс-аналитика | Худшее: много клиентов/строк подключения, отдельные миграции | Жесткая регуляторика, крупные enterprise-контракты |
В нашей практике как билдеров SaaS общая таблица + RLS дает лучший баланс скорости и безопасности. Если сомневаетесь — начинайте с tenant_id. Перейти на схемы/базы позже сложно, но возможно через репликацию и dual-write; наоборот — больнее.
Архитектура: минимальная, но готовая к росту
Компоненты, которые закрывают 90% задач для мультиарендного SaaS:
- Вход: Cloudflare или Nginx для TLS/статического кэша и защиты L7;
- Backend: Node.js (NestJS/Express/Fastify) + Prisma Client;
- БД: Postgres 14/15+ (Managed: RDS/Aurora, Cloud SQL, Supabase, Neon);
- Кэш: Redis для сессий и быстрых счетчиков;
- Файлы: S3-совместимое хранилище с пряниками (pre-signed URLs);
- Очереди и фоновые задачи: BullMQ или Temporal;
- Платежи: Stripe (планы/лимиты пер-тенант);
- Аналитика: ClickHouse или BigQuery (асинхронная выгрузка из OLTP);
- Мониторинг: Grafana + Prometheus, Sentry, pgbouncer + pg_stat_statements.
Поток запроса:
- JWT (или cookie) приходит с
tenantIdиuserId. 2) Бэкенд поднимает контекст запроса (AsyncLocalStorage), 3) Открывает транзакцию Prisma и делаетSET LOCAL app.current_tenant_id = ..., 4) Все чтения/записи идут под RLS и доп. фильтрами в middleware, 5) Ответ сериализуется по лимитам плана.
Если нужен мульти-регион и DR, учитывайте ограничения кросс-регионных ссылок и секретов. У нас есть отдельная заметка про такие нюансы в AWS CloudFormation/CDK кросс-регион — логика применима и для Terraform.
Схема Prisma: индексы, уникальность и тенант-контекст
Главное правило моделирования: все арендаторо-зависимые сущности содержат обязательный tenantId. Пер-арендорные уникальности описываются составными индексами.
// schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(uuid()) @db.Uuid
name String
plan String // basic/pro/enterprise
createdAt DateTime @default(now())
users User[]
projects Project[]
}
model User {
id String @id @default(uuid()) @db.Uuid
email String
role String // owner, admin, member
tenantId String @db.Uuid
tenant Tenant @relation(fields: [tenantId], references: [id])
createdAt DateTime @default(now())
@@unique([tenantId, email])
@@index([tenantId])
}
model Project {
id String @id @default(uuid()) @db.Uuid
tenantId String @db.Uuid
tenant Tenant @relation(fields: [tenantId], references: [id])
slug String
name String
createdAt DateTime @default(now())
@@unique([tenantId, slug])
@@index([tenantId])
}
Простые правила дисциплины:
- Любая связь — через
tenantId; избегайте кросс-арендорных FK. - Все «уникальности» — в виде
@@unique([tenantId, field]). - Идемпотентные операции опираются на пару
(tenantId, externalId).
Изоляция через Postgres RLS + Prisma
RLS защищает даже от случайно забытых фильтров в коде. Мы используем сессионный параметр app.current_tenant_id и политику на таблицах.
-- Пример для таблицы project
ALTER TABLE "Project" ENABLE ROW LEVEL SECURITY;
-- Только строки своего арендатора видны и модифицируемы
CREATE POLICY project_tenant_isolation_select ON "Project"
FOR SELECT
USING (tenantId = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY project_tenant_isolation_write ON "Project"
FOR INSERT, UPDATE, DELETE
USING (tenantId = current_setting('app.current_tenant_id', true)::uuid)
WITH CHECK (tenantId = current_setting('app.current_tenant_id', true)::uuid);
Важно: политика должна закрывать и SELECT, и INSERT/UPDATE/DELETE. Для всех арендаторо-зависимых таблиц — аналогичные политики.
В приложении tenantId нужно ставить на сессию БД в рамках транзакции. С Prisma это делается так:
// context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestContext = new AsyncLocalStorage<{ tenantId: string; userId?: string }>();
export function withRequestContext<T>(ctx: { tenantId: string; userId?: string }, fn: () => Promise<T>) {
return requestContext.run(ctx, fn);
}
// prisma-helpers.ts
import { prisma } from './prisma';
import { requestContext } from './context';
export async function withTenant<T>(fn: (tx: typeof prisma) => Promise<T>) {
const ctx = requestContext.getStore();
if (!ctx?.tenantId) throw new Error('Tenant context is missing');
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET LOCAL app.current_tenant_id = ${ctx.tenantId}::uuid`;
return fn(tx);
});
}
Дополнительная подстраховка — middleware в Prisma, которое автоматически добавляет tenantId в where для массовых операций. Оно не заменяет RLS, но снижает шум от «не те данные в UI».
// prisma-middleware.ts
import { Prisma } from '@prisma/client';
import { requestContext } from './context';
const MODELS_WITH_TENANT = new Set(['User', 'Project']);
export function tenantGuardMiddleware(): Prisma.Middleware {
return async (params, next) => {
const ctx = requestContext.getStore();
const tenantId = ctx?.tenantId;
if (tenantId && params.model && MODELS_WITH_TENANT.has(params.model)) {
const massOps = new Set(['findMany', 'updateMany', 'deleteMany', 'count']);
if (massOps.has(params.action)) {
params.args ||= {};
params.args.where = { ...(params.args.where || {}), tenantId };
}
if (params.action === 'create') {
params.args ||= {};
params.args.data = { ...(params.args.data || {}), tenantId };
}
// Для findUnique нельзя добавить доп. фильтр — полагаемся на RLS
}
return next(params);
};
}
Подводные камни:
- pgbouncer в режиме transaction pooling не держит сессионные параметры между транзакциями. Используйте session pooling или ставьте
SET LOCALвнутри каждой транзакции, как выше. - Любые запросы «в обход» RLS (суперпользователь, миграции) должны выполняться отдельной ролью. В приложении — роль без BYPASS RLS.
Аутентификация и границы арендатора
Откуда брать tenantId:
- Поддомен
acme.example.com→ маппингacme → tenantIdв Redis/Postgres, кэш на 5–15 минут. - Параметр в JWT (Auth0/Clerk/Своя OAuth):
tenant_idкак custom claim. JWT проверяется на edge/бэкенде. - SSO (SAML/OIDC) — маппинг email-домена или групп к
tenantId.
Правила безопасности:
- Пользователь может быть в нескольких арендаторах — контекст обязателен на каждый запрос, а не «берем первый попавшийся».
- Фоновые задачи (BullMQ/Temporal) передают
tenantIdявно; воркеры открывают транзакцию сSET LOCAL. - Экспорт/импорт данных — всегда фильтруется и подписывается
tenantId.
Биллинг и лимиты (Stripe):
- План → пер-арендорные квоты (число пользователей, проектов, запросов в API), проверяются в middleware/воркерах.
- Идентификаторы в Stripe:
metadata.tenantIdдля быстрого траблшутинга и дашбордов.
Если вы добавляете AI-функции (резюме, автокомплит), посмотрите наш обзор прод-моделей OpenAI GPT‑5 vs Claude vs DeepSeek — полезно при выборе поставщика и ограничений по данным арендатора.
Миграции, онбординг и удаление данных
С общей схемой миграции просты: Prisma Migrate генерирует SQL и прогоняется один раз. Рекомендации:
- Миграции только через CI (не руками в проде). Блокировка
prisma migrate deployс явным подтверждением. - Семплы/дефолты для нового арендатора — отдельный скрипт seed внутри транзакции
withTenant. - Удаление арендатора: soft-delete + фоновое каскадное удаление пакетами по 1–5k строк, лог-аудит. RLS на время удаления — через сервисную роль с BYPASS RLS, но с ограничением по
tenant_idв WHERE (двойная страховка). Полное физическое удаление — по требованию GDPR.
Для подхода «схема на арендатора» придётся:
- Поддерживать генерацию/применение миграций для каждой схемы.
- Управлять поиском по множеству схем (
search_path) и поддержкой в Prisma (multi-schema конфиг). - Думать о лимитах числа объектов в Postgres и времени деплоя.
Если ощущение, что миграции стали хрупкими — это не ощущение.
Производительность: индексы, кардинальность и лимиты
Профиль типичного SaaS: много мелких селектов, периодические агрегации и массовые апдейты флагов. Что важно:
- Индексы: все внешние ключи и
tenantIdв начале составного индекса (@@index([tenantId, createdAt])). Для поиска поslug—@@unique([tenantId, slug]). - Кардинальность:
tenantIdдолжен быть селективным. Если один «монолитный» арендатор — подумайте о партиционировании поtenantIdили дате (hash/list partitioning). - EXPLAIN ANALYZE: проверяйте, что планировщик использует нужные индексы. Часто спасает
WHERE tenant_id = $1 AND created_at > now() - interval '30 days'вместо сканирования годовых таблиц. - Кэш: Redis на дорогие агрегаты (лидерборды, usage counters) с TTL и инвалидацией по событию.
- Ограничение связок в Prisma: внимательно с
include— можно сломать N+1. Иногда лучше два точечных запроса, чем один с вложенными массивами и сортировками. - Пул соединений: 50–100 коннектов обычно достаточно для начала. Серверлесс — Prisma Accelerate/Proxy или максимум 10–20 коннектов + pgbouncer. Не забывайте, что каждый воркер очереди — тоже потребитель пула.
Небольшая инженерная ирония: лучший оптимизатор — это индекс, который вы добавили еще до того, как метрика просела.
Что ломается в продакшене
- Утечки контекста через pgbouncer: transaction pooling и
SETне дружат. Решение — session pooling илиSET LOCALвнутри каждой транзакции. - Забытый фильтр в одном из
updateMany→ массовый апдейт по всем арендаторам. Лечится RLS + middleware. - Медленные
count(*)на больших таблицах: используйте частичные денормализации, материализованные вьюхи на аналитику или приблизительную оценку (HyperLogLog/складывать счетчики). - Миграции с блокирующими DDL:
ALTER TABLE ... ADD COLUMN NOT NULL DEFAULT ...может держать блокировки. Делайте через «двухшаговую» схему: добавить nullable, бэкфилл пакетами, затемSET NOT NULLв окно низкой нагрузки. - Фоновая задача без
tenantIdв контексте: воркер зацепит «первый попавшийся». Жёсткая валидация входа + контракт очереди:data: { tenantId, ... }. - Мульти-регион: кросс-регионные реплики отстают; нельзя делать кросс-регионные транзакции. Фичи, требующие глобальной согласованности, централизуйте или введите «leader region».
Стоимость и бизнес-контекст
По опыту студии:
- MVP (аутентификация, мультиарендность, 3–5 основных CRUD, биллинг, ролевая модель, базовая аналитика): 6–10 недель, команда из 2–3 инженеров. Бюджет ориентиром: 25–60k USD, в зависимости от дизайна/интеграций.
- Хостинг на старте: Postgres managed (100–300 USD/мес), Redis (15–50), S3 + CDN (10–100), Cloudflare (20+), мониторинг (20–100). С ростом трафика БД станет основным расходом.
- Где экономить нельзя: миграции/бэкапы, мониторинг, политика RLS. Где можно отложить: сложный поиск, многоуровневые роли, кастомные домены (если нет enterprise-сделок).
- Дорогостоящие ошибки: смена модели мультиарендности постфактум; отсутствие аудита данных; ручные миграции; попытки «сделать без RLS и без тестов».
ROI мультиарендности в лоб: единая кодовая база, единая инфраструктура, чёткая сегментация тарифов. Сильная изоляция на уровне БД снижает юридические риски — аргумент для enterprise-продаж.
Пошаговый план внедрения
- Определите модель: начинаем с общей схемы и
tenant_id. - Пропишите Prisma-модели:
tenantIdв каждой сущности, составные уникальности, индексы. - Включите RLS и создайте политики для всех таблиц.
- Проложите контекст запроса (AsyncLocalStorage) и
withTenant-транзакции сSET LOCAL. - Добавьте Prisma middleware для массовых операций и автозаполнения
tenantId. - Интегрируйте аутентификацию (JWT/SSO) с claim
tenant_idи проверкой владения. - Настройте миграции через CI, бэкапы и алерты по долгим транзакциям/блокировкам.
- Напишите тесты на изоляцию: попытка читать/писать чужой
tenantIdдолжна проваливаться на уровне БД.
FAQ
Какой подход изоляции выбрать для старта?
В 80% случаев — общая схема с tenant_id и RLS. Это даёт простые миграции, низкие косты и достаточную безопасность.
Совместима ли Prisma с RLS Postgres?
Да. RLS применяется в БД прозрачно. Важно выставлять SET LOCAL app.current_tenant_id в транзакции и не использовать роли с BYPASS RLS в приложении.
Что с pgbouncer и Prisma?
Используйте session pooling или обеспечьте, что каждый запрос проходит внутри транзакции с SET LOCAL. В transaction pooling сессионные параметры теряются между запросами.
Когда нужны отдельные схемы или базы на арендатора?
Когда регуляторика или объём данных/нагрузка одного арендатора выбивается из всех метрик остальных, либо нужен изолированный SLA/бэкапы. Цена — сложность миграций и DevOps.
Как тестировать мультиарендность?
Интеграционные тесты с двумя арендаторами: операции в tenantA не должны видеть данные tenantB. Тестируйте и позитивные, и негативные сценарии, включая фоновые джобы.
Подойдёт ли серверлесс (Vercel/Cloudflare Workers) для Prisma?
Да, но следите за количеством соединений. Используйте Prisma Accelerate/Proxy или pgbouncer, и держите все обращения к БД короткими и батчируйте запросы.
Ключевые выводы
- Общая схема +
tenant_id+ RLS — базовый паттерн для 80% SaaS. - Прокладывайте
tenantIdиз аутентификации до БД черезSET LOCALв транзакции. - Prisma middleware — подстраховка, но RLS в Postgres остаётся источником правды.
- Следите за pgbouncer и режимами пула; не теряйте сессионные параметры.
- Миграции только через CI; никаких блокирующих DDL в часы пик.
- Индексы и кардинальность решают 90% проблем производительности раньше, чем вы это заметите.
Если вы строите мультиарендный SaaS и хотите, чтобы архитектура выдержала рост и аудит, команда MTBYTE спроектирует и реализует его под ключ. Напишите нам: /contact.
СЛЕДУЮЩИЙ ШАГ
Понравилось как мыслим?
Применяем те же принципы в клиентских проектах: AI, автоматизации, продукты, которые не умирают после релиза.