Мультитенантный SaaS: что мне на самом деле пришлось разобрать
Опубликовано 30 октября 2025 г. · 6 мин чтения
BookUp начался потому что меня это раздражало
Каждый барбершоп и салон здесь ведёт бронирования через Telegram DM и бумажный блокнот. Работает, пока не перестаёт — двойные брони, пропущенные записи, невозможность видеть свою неделю одним взглядом. Так что я построил то, что хотел бы у них видеть.
Бизнес-идея простая: одна платформа, много бизнесов. Каждый бизнес получает собственную страницу бронирования, собственное расписание, собственный список клиентов.
Сложная инженерная часть — сделать "каждый бизнес получает своё пространство" настоящим. Это мультитенантность, и вот что она на самом деле включает.
Поддомены: то, что видит клиент
Когда клиент бронирует в барбершопе Мустафы, он идёт на mustafa.bookup.uz. Не на bookup.uz/businesses/mustafa — этот URL говорит "вы снимаете место в чужом приложении." Поддомен говорит "это место Мустафы."
В Next.js это работает в middleware. Каждый запрос приходит, читаю заголовок host, отрезаю базовый домен, получаю слаг тенанта.
// middleware.ts
const host = req.headers.get('host') ?? '';
const slug = host.replace('.bookup.uz', '');
if (slug && slug !== 'www') {
return NextResponse.rewrite(
new URL(`/t/${slug}${req.nextUrl.pathname}`, req.url)
);
}Приятное: wildcard DNS решает это бесплатно. Одна DNS-запись, каждый поддомен идёт на тот же сервер. Когда новый бизнес регистрируется — он сразу в сети.
Изоляция данных: та часть, которая кусает
Одна база данных, общие таблицы, в каждой строке тенанта есть столбец tenant_id. Это стандартный подход.
Часть, которая кусает, если не осторожен: вы где-то забываете добавить фильтр по тенанту. Строите запрос "последние брони", он отлично работает в dev с данными одного тенанта, а в production вы случайно показываете брони одного бизнеса на дашборде другого.
@Injectable()
export class BookingsService {
constructor(
private readonly db: DatabaseService,
@Inject(REQUEST) private readonly request: TenantRequest,
) {}
async getUpcoming() {
return this.db.bookings.findMany({
where: { tenant_id: this.request.tenantId, starts_at: { gt: new Date() } }
});
}
}Что хотел бы сделать с первого дня: применять это на уровне модуля, а не сервиса.
Онбординг: не оставляйте людей в пустой комнате
Новый тенант регистрируется, выбирает слаг, попадает на дашборд. Пустое расписание. Нет услуг. Нет персонала.
Что я сделал: автоматически создаю три примерные услуги и заполнителя сотрудника. Тенант видит, как выглядит его страница бронирования с данными. Может удалить примеры, но первое впечатление — "о, вот как это работает", а не "с чего вообще начать."
Что бы сказал себе раньше
ID тенанта должен быть в токене аутентификации, контексте запроса и конструкторе запросов с первого дня.
Поддомены лучше путевых префиксов. URL транслирует владение, а владение делает продукт ощущаться премиальным.
Пустое состояние онбординга — первый реальный опыт пользователя. Инвестируйте туда рано.