almdev Технический блог

Self-hosted и SaaS из одного кода
Как продукт живёт одновременно на сервере клиента и в облаке без форка.

Режим развёртывания как параметр конфигурации: что реально различается между коробкой и облаком, как скрыть различия генерации URL и почему биллинг прячется за интерфейсом.

Подкатегория: Архитектура

Чтение
6 мин
Технологии / версии
Symfony · Deployment · Конфигурация · SaaS
16 апр 2026 · 6 мин · 4 просмотра · Backend
Один программный сердечник питает локальную и облачную установки
Архитектура 04/2026

Клиент хочет систему обучения у себя, в закрытом контуре, без интернета. Другой клиент хочет ту же систему, но чтобы ничего не устанавливать. Первым порывом было сделать ветку self-hosted и не мучиться. Через месяц эта ветка отстала на сорок коммитов, и я потратил день на перенос одного исправления.

Форк не работает. Работает то, что различия — это конфигурация.

Что различается на самом деле

Прежде чем строить абстракции, я сел и выписал реальные различия. Их оказалось меньше, чем я боялся.

  • Регистрация. В облаке любой может создать организацию. В коробке организация одна, её создаёт установщик, самостоятельная регистрация закрыта или ограничена корпоративным доменом.
  • URL. В облаке ключ организации в адресе обязателен. В коробке он бессмысленный шум: организация всё равно одна.
  • Тарифы и лимиты. В облаке есть план, счётчики и блокировка при превышении. В коробке лимитов нет, платит клиент один раз.
  • Почта и файлы. В облаке — свой SMTP и S3. В коробке — то, что дал клиент, вплоть до локального диска и внутреннего релея.
  • Обновления. В облаке обновляю я, когда захочу. В коробке — администратор клиента, когда решится, и версия может отставать на полгода.

Всё остальное — а это 95 процентов кода — одинаково. Именно это соотношение и объясняет, почему форк проигрывает.

Режим как параметр, а не как ветка

Режим живёт в переменной окружения и превращается в энум:

enum DeploymentMode: string
{
    case SelfHosted = 'self_hosted';
    case Saas = 'saas';

    public function allowsPublicSignup(): bool
    {
        return $this === self::Saas;
    }

    public function requiresOrgKeyInUrl(): bool
    {
        return $this === self::Saas;
    }
}

Методы на энуме — не украшение. Они дают одно место, где записано, что именно означает режим. Когда появится третий вариант — например, облако для одного крупного клиента с собственным доменом, — здесь появятся его базовые возможности. Привязки сервисов, маршруты и тесты всё равно придётся менять отдельно, зато пятьдесят условий по коду не возникнут.

Настройки собраны в один объект, который внедряется куда нужно:

final readonly class TenancySettings
{
    public function __construct(
        public DeploymentMode $mode,
        public string $defaultOrganizationKey,
        public ?string $allowedEmailDomains,
    ) {}
}

Ключ организации, который остался в URL

Самое заметное различие для пользователя — адрес страницы. В облаке /org/acme/courses, в коробке хочется просто /courses.

Я не стал делать два набора маршрутов. Ниже фрагмент общего маршрута:

#[Route('/org/{orgKey}/courses', requirements: ['orgKey' => OrganizationRouteRequirements::PATTERN])]

А в коробке orgKey получает значение по умолчанию из настроек, и генератор URL подставляет его сам:

final class OrgUrlHelper
{
    public function path(string $route, array $params = []): string
    {
        if ($this->settings->mode->requiresOrgKeyInUrl()) {
            $params['orgKey'] ??= $this->context->organization()->getKey();
        } else {
            $params['orgKey'] = $this->settings->defaultOrganizationKey;
        }

        return $this->router->generate($route, $params);
    }
}

Честно говоря, идеального решения тут нет. Адрес в коробке остаётся /org/main/courses, просто ключ всегда один и тот же. Сегмент можно убрать условным импортом или вторым набором маршрутов, но тогда придётся поддерживать два дерева. Я решил, что лишние девять символов в адресе дешевле.

Биллинг за интерфейсом

Тарифы — единственная подсистема, которой в коробке нет вовсе. Заворачивать её в условия было бы больно, поэтому она спрятана за контрактом:

interface TransactionBillingServiceInterface
{
    public function canEnroll(Organization $org, Course $course): bool;

    public function registerEnrollment(Organization $org, Enrollment $enrollment): void;
}

В коробке подключается заглушка, которая разрешает всё и ничего не записывает:

final class StubTransactionBillingService implements TransactionBillingServiceInterface
{
    public function canEnroll(Organization $org, Course $course): bool
    {
        return true;
    }

    public function registerEnrollment(Organization $org, Enrollment $enrollment): void
    {
    }
}

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

Тут есть тонкость, которую я осознал не сразу. Заглушка должна возвращать разрешающий ответ, а не бросать исключение «не поддерживается». Соблазн написать throw new LogicException() большой — кажется, что так честнее. Но тогда любая новая проверка биллинга в облаке ломает коробку при следующем обновлении, причём в проде у клиента.

Ограничение регистрации доменом

В коробке нужно ограничить регистрацию корпоративным доменом почты. Одной проверки строки недостаточно: после неё пользователь обязан подтвердить адрес ссылкой из письма. И даже это доказывает владение корпоративным ящиком, а не текущий статус сотрудника; для строгого контура нужен SSO или список, которым управляет администратор. Политика не завязана на режим напрямую: облачный клиент тоже может захотеть такое ограничение.

final class CorporateDomainPolicy
{
    public function isAllowed(string $email): bool
    {
        $domains = $this->settings->allowedEmailDomains;

        if ($domains === null || $domains === '') {
            return true;
        }

        $domain = mb_strtolower(substr(strrchr($email, '@') ?: '', 1));

        return in_array($domain, array_map('trim', explode(',', mb_strtolower($domains))), true);
    }
}

Пустая настройка означает «без ограничений». До вызова политики адрес уже проверен валидатором email, поэтому пустой домен сюда не попадает. Это не то же самое, что режим развёртывания: одно решение — про то, где живёт приложение, другое — про то, кого пускают.

Как не размножить условия по коду

Главный риск подхода — россыпь if ($mode === DeploymentMode::Saas) в контроллерах. Я держу три правила.

Первое: проверять режим напрямую можно только в конфигурации сервисов и в трёх местах, где это буквально про инфраструктуру. Везде остальное — вопрос к объекту настроек или к отдельной политике.

Второе: если различие затрагивает поведение, а не одну строку, — это разные реализации интерфейса, а не условие. Биллинг сюда попал именно по этому признаку.

Третье: шаблоны про режим не знают вообще. Кнопка «Тарифы» показывается не по mode == 'saas', а по наличию соответствующей возможности:

{% if billing_enabled %}
    <a href="{{ org_path('billing_index') }}">Тарифы</a>
{% endif %}

Разница кажется косметической, а на деле это разница между «фронтенд знает про бизнес-модель» и «фронтенд знает про возможности».

Тесты в двух режимах

Набор тестов один, но режимозависимая группа прогоняется дважды. Группа помечена в PHPUnit, а переменную окружения задаёт CI matrix или команда запуска — сам PHPUnit не назначает разное окружение группам:

DEPLOYMENT_MODE=saas php bin/phpunit --group deployment-mode
DEPLOYMENT_MODE=self_hosted php bin/phpunit --group deployment-mode

Полное дублирование смысла не имеет — большинство тестов от режима не зависит. Я выделил примерно тридцать функциональных тестов, где различие существенно: регистрация, генерация ссылок, доступ к разделу тарифов. Они помечены и гоняются в обеих конфигурациях, остальные — только в облачной.

Это ловит ровно одну категорию ошибок, зато надёжно: «в облаке работает, в коробке 500». Такое было дважды, оба раза из-за ссылки, собранной штатным генератором маршрутов вместо помощника с ключом организации.

Через год после разделения

Подход выдержал. Больше всего пользы принесла не техника, а сам список различий: пока он записан и умещается на экран, любое новое требование можно к нему приложить и спросить, действительно ли оно про режим развёртывания.

Дважды выяснялось, что нет. Ограничение по домену почты я сначала считал признаком коробки, а потом первый же облачный клиент его попросил. Настройка внешнего хранилища файлов — та же история.

Чего я до сих пор не решил: как выкатывать миграции клиенту, который обновляется раз в полгода и перепрыгивает через двенадцать версий. Пока это работает, потому что миграции аддитивные, но однажды это перестанет быть правдой.

#symfony #deployment #konfiguraciya #saas