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

В тот момент, когда вы решаете, что ваше приложение будет обслуживать более одной организации, каждое архитектурное решение разветвляется. Проектирование базы данных, аутентификация, авторизация, биллинг, даже логирование — все это теперь несет в себе вопрос: для какого клиента?
Я запускал многопользовательские бэкенды в совершенно разных областях. Шаблоны, которые выживают в продакшене, редко бывают теми, что выглядят наиболее чисто на доске. Этот пост охватывает решения, которые действительно важны, компромиссы, о которых никто не предупреждает, и конкретные шаблоны реализации, которые вы можете позаимствовать.
Стратегии изоляции баз данных
Это первое и наиболее важное решение. Если вы ошибетесь, вас ждет миграция, которая затронет каждый запрос в вашей кодовой базе.
Существует три основных подхода:
| Стратегия | Изоляция | Сложность | Стоимость | Лучше всего подходит для |
|---|---|---|---|---|
| Общая БД, общая схема | Низкая | Низкая | Самая низкая | SaaS на ранней стадии, < 100 клиентов |
| Общая БД, схема на клиента | Средняя | Средняя | Средняя | Средний масштаб, регулируемые отрасли |
| БД на клиента | Высокая | Высокая | Самая высокая | Корпоративные клиенты, строгие требования соответствия |
Общая база данных, общая схема
Данные каждого клиента хранятся в одних и тех же таблицах. Вы различаете строки по столбцу tenant_id. Именно с этого должны начинать большинство SaaS-продуктов.
Преимущество — простота. Один путь миграции, один пул соединений, простые запросы. Недостаток в том, что отсутствие условия WHERE tenant_id = ? означает утечку данных. На уровне инфраструктуры нет страховки — изоляция полностью обеспечивается кодом вашего приложения.
Этот подход работает до тех пор, пока запросы крупного клиента не начинают ухудшать производительность для всех остальных, или пока команда по комплаенсу корпоративного клиента не спросит, как их данные физически отделены от данных конкурентов. В этот момент вы переходите на следующий уровень.
Схема на клиента
Каждый клиент получает свою собственную схему PostgreSQL (или эквивалент) в рамках общего экземпляра базы данных. Таблицы идентичны во всех схемах, но физически разделены. Вы переключаете путь поиска во время подключения.
Это обеспечивает реальную изоляцию без управления десятками экземпляров баз данных. Миграции более сложны — вы запускаете их N раз — но такие инструменты, как node-pg-migrate или поддержка нескольких схем в Prisma, справляются с этим достаточно хорошо.
Подвох: пулинг соединений становится сложным. Если вы используете PgBouncer, вам нужно тщательно продумать, как переключение схем взаимодействует с пулированными соединениями. Я видел, как это приводило к тонким ошибкам, когда Клиент А на мгновение считывал схему Клиента Б, потому что соединение было возвращено в пул в середине транзакции.
База данных на клиента
Максимальная изоляция. Каждый клиент имеет выделенный экземпляр базы данных (или, по крайней мере, выделенную логическую базу данных). Вы динамически маршрутизируете соединения на основе контекста клиента.
Это дорого и операционно тяжело, но некоторые клиенты готовы за это платить. Сферы здравоохранения и финансовых услуг часто требуют этого. Ключевое преимущество, помимо изоляции, заключается в том, что вы можете масштабировать, создавать резервные копии и восстанавливать данные клиентов независимо. Шумный сосед не сможет обрушить ваш общий экземпляр.
На практике большинство команд используют гибридный подход: общая БД для большинства, выделенные экземпляры для корпоративных аккаунтов, которые за это платят.
Аутентификация и разрешение клиента
Каждый входящий запрос должен ответить на два вопроса: кто этот пользователь? и к какому клиенту он относится?
Существует три распространенные стратегии для разрешения контекста клиента:
На основе поддомена: acme.yourapp.com соответствует клиенту Acme. Чисто, интуитивно понятно, отлично подходит для B2B-продуктов, где каждый клиент получает свое рабочее пространство. Вы извлекаете слаг клиента из заголовка Host.
На основе утверждений JWT: Идентификатор клиента встраивается в токен доступа при входе в систему. Маршрутизация на уровне инфраструктуры не требуется. Хорошо работает для мобильных приложений и SPA, где маршрутизация по поддоменам непрактична.
На основе заголовка: Клиент отправляет заголовок X-Tenant-ID. Просто, но опасно, если не проверять его на соответствие разрешениям аутентифицированного пользователя. Используйте это только для внутренних вызовов между сервисами.
В большинстве систем я комбинирую разрешение по поддомену для начального потока входа в систему с утверждениями JWT для последующих вызовов API. Вот как выглядит промежуточное ПО:
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// 1. Try JWT claim first (authenticated requests)
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
const payload = verifyToken(token);
if (payload?.tenantId) {
req.tenantId = payload.tenantId;
return next();
}
}
// 2. Fall back to subdomain resolution
const host = req.hostname;
const slug = host.split(".")[0];
if (slug === "www" || slug === "app") {
return res.status(400).json({ error: "Tenant not identified" });
}
const tenant = await getTenantBySlug(slug);
if (!tenant || !tenant.isActive) {
return res.status(404).json({ error: "Tenant not found" });
}
req.tenantId = tenant.id;
next();
}
Установите это перед обработчиками маршрутов, и каждая последующая функция будет иметь доступ к req.tenantId. Остальная часть вашего приложения никогда не должна думать о том, как работает разрешение клиента — она просто считывает ID из контекста запроса.
Ограничение области действия каждого запроса к базе данных
Как только у вас есть контекст клиента для каждого запроса, вы должны гарантировать, что он применяется к каждому запросу. Отсутствие ограничения области действия — это утечка данных. Это не то, что можно оставлять на усмотрение разработчика.
Лучший шаблон, который я нашел, — это уровень репозитория, который автоматически обеспечивает ограничение области действия:
import { db } from "../lib/database";
export function scopedQuery(tenantId: string) {
return {
async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.select("*");
},
async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.first();
},
async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
return db(table)
.insert({ ...data, tenant_id: tenantId })
.returning("*")
.then((rows) => rows[0]);
},
async update(
table: string,
where: Record<string, unknown>,
data: Record<string, unknown>
): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.update(data);
},
async remove(table: string, where: Record<string, unknown>): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.del();
},
};
}
В обработчиках маршрутов вы никогда не создаете необработанные запросы. Вы всегда используете интерфейс с ограничением области действия:
app.get("/api/projects", async (req, res) => {
const query = scopedQuery(req.tenantId);
const projects = await query.findMany("projects", { archived: false });
res.json(projects);
});
Если вы используете ORM, такой как Prisma, вы можете добиться того же с помощью промежуточного ПО, которое внедряет фильтр tenant_id в каждый вызов findMany, findFirst, update и delete. Смысл в том, чтобы сделать небезопасный путь сложнее безопасного.
Управление доступом на основе ролей внутри клиентов
Многопользовательская архитектура вводит двухуровневую модель авторизации. Во-первых: принадлежит ли этот пользователь этому клиенту? Во-вторых: что он может делать внутри него?
Я делаю это просто с помощью таблицы tenant_memberships:
CREATE TABLE tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, tenant_id)
);
Роли обычно owner (владелец), admin (администратор), member (участник), а иногда viewer (наблюдатель) или роль, специфичная для домена. Избегайте создания полной матрицы разрешений, если ваш продукт действительно в этом не нуждается — большинство B2B SaaS прекрасно обходятся 3-4 ролями.
Проверка авторизации происходит после разрешения клиента:
export function requireRole(...roles: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const membership = await db("tenant_memberships")
.where({ user_id: req.userId, tenant_id: req.tenantId })
.first();
if (!membership || !roles.includes(membership.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
req.userRole = membership.role;
next();
};
}
// Usage
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
// only owners and admins reach this handler
});
Одно дизайнерское решение, которое избавит от головной боли в будущем: всегда позволяйте пользователю принадлежать к нескольким клиентам. Даже если ваш продукт сегодня является однорабочим пространством, в тот момент, когда вы добавите агентские аккаунты, реселлеров с белой этикеткой или режим консультанта, вам это понадобится.
Флаги функций для каждого клиента
Не каждый клиент должен видеть каждую функцию. Иногда вы ограничиваете доступ по тарифному плану. Иногда вы проводите постепенное развертывание. Иногда корпоративный клиент платит за индивидуальную возможность.
Я использую простую таблицу tenant_features вместо того, чтобы подключать сторонний сервис флагов функций. Для большинства SaaS-продуктов этого более чем достаточно:
// Cache-backed feature flag check
const featureCache = new Map<string, Set<string>>();
export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
if (!featureCache.has(tenantId)) {
const rows = await db("tenant_features")
.where({ tenant_id: tenantId, enabled: true })
.select("feature_key");
featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
}
return featureCache.get(tenantId)!.has(feature);
}
// Invalidate on plan change or manual toggle
export function invalidateFeatureCache(tenantId: string) {
featureCache.delete(tenantId);
}
В обработчиках маршрутов:
app.post("/api/analytics/export", async (req, res) => {
if (!(await hasFeature(req.tenantId, "analytics_export"))) {
return res.status(403).json({
error: "This feature is not available on your current plan",
upgrade: true,
});
}
// proceed with export
});
Когда клиент обновляет свой тарифный план, вы изменяете соответствующие строки функций и аннулируете кэш. Просто, проверяемо и без внешних зависимостей.
Интеграция биллинга
Stripe является стандартом здесь по уважительной причине. Отображение естественное: один Stripe Customer на клиента, одна Subscription на клиента, тарифные планы соответствуют Products и Prices.
Критическое правило проектирования: храните Stripe Customer ID в записи клиента, а не в записи пользователя. Биллинг относится к организации, а не к человеку, который ввел данные кредитной карты.
Поток вебхуков выглядит так:
checkout.session.completed— прикрепите Stripe Customer ID к клиенту, активируйте его подписку.invoice.paid— продлите расчетный период, обновитеtenant.paidUntil.invoice.payment_failed— пометьте клиента как просрочившего платеж, отправьте напоминания о задолженности.customer.subscription.updated— синхронизируйте изменения тарифного плана, обновите флаги функций соответствующим образом.customer.subscription.deleted— понизьте до бесплатного тарифа или приостановите.
Ключ — идемпотентность. Stripe может и будет отправлять один и тот же вебхук несколько раз. Каждый обработчик должен быть безопасен для повторного выполнения с одной и той же полезной нагрузкой события.
Изоляция данных и безопасность
Помимо ограничения области запросов, есть несколько неочевидных вещей, которые следует правильно настроить на раннем этапе:
Безопасность на уровне строк (RLS): Если вы используете PostgreSQL, включите RLS в качестве второй линии защиты. Даже если в коде вашего приложения есть ошибка, сама база данных откажется возвращать строки, которые не соответствуют текущему контексту клиента. Установите app.current_tenant во время подключения и напишите политики на его основе.
Контекст логирования: Каждая строка лога должна включать tenantId. Когда что-то ломается в 3 часа ночи, вам нужно знать, какой клиент затронут, не копаясь в трассировках запросов. Структурированное логирование с полем клиента, встроенным в контекст логгера, делает это автоматическим.
Резервное копирование и удаление данных: Клиенты будут уходить, а некоторые будут ссылаться на GDPR или права на удаление данных. Если все ваши данные находятся в общих таблицах, очистка данных одного клиента означает тщательно составленные операторы DELETE для каждой таблицы. При использовании схемы или базы данных на клиента это DROP SCHEMA или DROP DATABASE. Планируйте это с первого дня.
Ограничение скорости для каждого клиента: Глобальный ограничитель скорости не подходит. Пакетный импорт одного клиента не должен расходовать бюджет скорости другого клиента. Ограничьте скорость по tenantId, а не только по IP или пользователю.
Шифрование в состоянии покоя: Для чувствительных отраслей шифруйте данные клиента в состоянии покоя с помощью ключей для каждого клиента. Это добавляет сложности в управление ключами, но это означает, что отмена доступа клиента так же проста, как уничтожение его ключа. После отключения данные не остаются читаемыми на диске.
Заключительные мысли
Многопользовательская архитектура — это не библиотека, которую вы устанавливаете. Это набор архитектурных решений, которые затрагивают каждый уровень вашего стека, от базы данных до CDN. Хорошая новость в том, что шаблоны хорошо изучены. Сложность заключается в их применении с правильным уровнем изоляции для вашего конкретного рынка и стадии.
Начните с общего доступа ко всему и дисциплинированного уровня ограничения области действия. Добавляйте границы изоляции по мере того, как ваши клиенты будут их требовать. Не переусердствуйте с проектированием для требований соответствия, которых у вас еще нет, но убедитесь, что ваша архитектура может развиваться в сторону более строгой изоляции без переписывания.
Я создавал многопользовательские системы от ресторанных платформ до медицинских SaaS — каждая со своими требованиями к изоляции и масштабированию. Шаблоны в этом посте — это те, которые выдержали испытание во всех из них.
Связанные проекты
RestoHub
Рестораны перестают терять 30% в пользу Uber Eats — они получают собственную систему заказов, меню, сайт и программу лояльности в одной платформе. Полноценный опыт уровня Uber Eats, но ресторан оставляет себе каждый доллар.
TakeCare
Одна медсестра теперь удалённо мониторит 250 пациентов — заменяя ручные телефонные звонки и домашние визиты в крупнейших больницах Квебека. Работает в Jewish General, CHUM и Douglas Mental Health Institute.