Skip to content

ADR-011: Эволюция монорепозитория — когда и как выделять сервисы

Дата: 2026-04-04
Статус: Принято
Авторы: Backend Team
Связан с: ADR-010

Контекст

ADR-010 вводит разделение на два деплоя (marketplace + admin) из одного репозитория через APP_MODE. Это Phase 1 — минимальное изменение, дающее изоляцию процессов, сессий и DB-ролей без дублирования кода.

Вопрос: когда и как переходить к дальнейшему разделению?

Текущее состояние кодовой базы

МетрикаЗначение
Shared entities17+ (User, Product, Category, AdminUser, AdminAuditLog...)
Shared repository interfaces17
Shared servicesSessionManager, CsrfTokenManager, AdminAuditLogger, JsonResponder, RequestParser, CursorPaginator, StampedeProtectedCache, Mailer...
Shared middlewareAuthMiddleware, CorsMiddleware, CsrfMiddleware, RateLimitMiddleware, SecurityHeadersMiddleware...
Admin actions36
Marketplace actionsStore (~15), Vendor (~20), Auth (~10), Chat (~5)
Shared code (domain + infra)~80% от общего объёма

При 80% shared code выделение в отдельные репозитории сейчас означает либо дублирование, либо преждевременную абстракцию в composer-пакет с нестабильным API.

Решение: три фазы с измеримыми триггерами перехода

Phase 1 (сейчас)          Phase 2 (6-12 мес)              Phase 3 (12-24 мес)
┌──────────────────┐      ┌──────────────────────┐        ┌─────────────────────┐
│   Один git-репо   │      │    Один git-репо      │        │  Три git-репо        │
│   APP_MODE=       │  →   │    partizap/core как  │   →    │  partizap-core       │
│   marketplace|    │      │    path repository    │        │  partizap-marketplace│
│   admin           │      │    + основное приложение│       │  partizap-admin      │
└──────────────────┘      └──────────────────────┘        └─────────────────────┘

Phase 1 → Phase 2: shared domain в composer-пакет (path repository)

Триггеры перехода (3 из 5 должны выполниться)

#ТриггерКак измеритьТекущее состояние
1Domain стабилизировалсяEntity/repository меняются <2 раз в месяцНет — активная разработка
2Разная каденция деплояAdmin нужно деплоить без marketplace (или наоборот) >3 раз в месяцНет — деплоят вместе
3CI bottleneckПолный тест-suite >5 мин, admin-change запускает marketplace-тесты зряНет
4Разные разработчики>1 человек работает только над admin или только над marketplaceНет
5Merge conflictsКонфликты между admin и marketplace ветками >2 раз в месяцНет

Ожидаемый горизонт: 6-12 месяцев при текущих темпах роста.

Целевая структура Phase 2

partizap/                          # один git-репо
├── core/                          # composer package (path repository)
│   ├── composer.json              # name: partizap/core
│   ├── src/
│   │   ├── Entity/                # Все ORM-entities
│   │   │   ├── User.php
│   │   │   ├── Product.php
│   │   │   ├── Category.php
│   │   │   ├── AdminUser.php
│   │   │   ├── AdminAuditLog.php
│   │   │   └── ...
│   │   ├── Repository/            # ТОЛЬКО interfaces
│   │   │   ├── UserRepositoryInterface.php
│   │   │   ├── ProductRepositoryInterface.php
│   │   │   └── ...
│   │   ├── Enum/                  # ProductStatus, AccountType, Steering...
│   │   │   └── ...
│   │   └── ValueObject/           # Domain primitives
│   │       └── ...
│   ├── migrations/                # Doctrine-миграции (единый source of truth)
│   └── tests/
│       └── Entity/                # Unit-тесты entity logic

└── backend/                       # основное приложение
    ├── composer.json              # requires partizap/core via path
    ├── app/
    │   ├── Actions/
    │   │   ├── Admin/             # 36 admin actions
    │   │   ├── Auth/              # Marketplace auth
    │   │   ├── Store/             # Store actions
    │   │   ├── Vendor/            # Vendor actions
    │   │   └── Chat/              # Chat actions
    │   ├── Application/
    │   │   ├── Middleware/         # Auth, CSRF, CORS, RateLimit, Admin...
    │   │   └── Service/           # SessionManager, CsrfTokenManager, Mailer...
    │   └── Infrastructure/
    │       ├── Persistence/       # Doctrine implementations
    │       │   ├── DoctrineUserRepository.php
    │       │   └── ...
    │       └── Redis/             # RedisConnectionFactory, cache
    ├── config/                    # app.php, routes/, middleware.php, container.php
    └── tests/
        ├── Feature/               # HTTP tests
        └── Integration/           # Repository tests against real DB

Принцип разделения: пакет содержит что (domain model — entities, interfaces, enums), основной репо содержит как (infrastructure, delivery, config).

Как выносить — пошаговый план

Шаг 1: Проверить независимость domain layer

Entity и repository interfaces не должны зависеть от infrastructure:

bash
# Не должно быть ссылок на Application или Infrastructure из Domain
grep -r "use App\\\\Application\|use App\\\\Infrastructure\|use Slim\|use Doctrine\\\\ORM\\\\EntityManager" app/Domain/
# Допустимы: Doctrine ORM mapping attributes (Column, Entity, Table, ManyToOne...)
# Недопустимы: EntityManager, QueryBuilder, Repository implementations

Если найдены нарушения — исправить до выноса: переместить логику из entity в service, заменить конкретные зависимости на interfaces.

Шаг 2: Создать пакет как path repository

json
// partizap/core/composer.json
{
    "name": "partizap/core",
    "type": "library",
    "autoload": {
        "psr-4": {
            "Partizap\\Core\\": "src/"
        }
    },
    "require": {
        "php": "^8.3",
        "doctrine/orm": "^3.0"
    }
}
json
// partizap/backend/composer.json — добавить
{
    "repositories": [
        {
            "type": "path",
            "url": "../core"
        }
    ],
    "require": {
        "partizap/core": "^1.0"
    }
}

composer install создает symlink vendor/partizap/core → ../../core. Изменения в core/src/ мгновенно видны без composer update. Нет overhead при разработке.

Шаг 3: Перенести файлы

bash
# Создать структуру
mkdir -p core/src/{Entity,Repository,Enum,ValueObject}
mkdir -p core/migrations core/tests/Entity

# Entity
git mv backend/app/Domain/Entity/*.php core/src/Entity/

# Repository interfaces (только interfaces, не Doctrine implementations)
git mv backend/app/Domain/Repository/*Interface.php core/src/Repository/

# Enums
git mv backend/app/Domain/Enum/*.php core/src/Enum/

# Migrations
git mv backend/migrations/ core/migrations/

Шаг 4: Обновить namespaces

Замена во всех PHP-файлах:

App\Domain\Entity\*         → Partizap\Core\Entity\*
App\Domain\Repository\*     → Partizap\Core\Repository\*
App\Domain\Enum\*           → Partizap\Core\Enum\*
bash
find backend/app/ backend/tests/ backend/config/ -name "*.php" -exec sed -i '' \
    's/App\\Domain\\Entity/Partizap\\Core\\Entity/g;
     s/App\\Domain\\Repository/Partizap\\Core\\Repository/g;
     s/App\\Domain\\Enum/Partizap\\Core\\Enum/g' {} +

Проверка полноты:

bash
# Не должно остаться ссылок на старый namespace
grep -r "App\\\\Domain\\\\Entity\|App\\\\Domain\\\\Repository\|App\\\\Domain\\\\Enum" backend/app/ backend/tests/ backend/config/
# Ожидание: пустой вывод

Шаг 5: Обновить Doctrine config

php
// backend/config/doctrine.php
$entityPaths = [
    dirname(__DIR__) . '/vendor/partizap/core/src/Entity',
];
php
// backend/config/doctrine-migrations.php
'migrations_paths' => [
    'Partizap\\Core\\Migrations' => dirname(__DIR__) . '/vendor/partizap/core/migrations',
],

Шаг 6: Проверить

bash
cd backend/
composer dump-autoload
php vendor/bin/phpstan analyse
php vendor/bin/phpunit

Все тесты должны проходить без изменений — path repository создает symlink, Doctrine находит entities по новому пути.

Phase 2 → Phase 3: отдельные git-репозитории

Триггеры перехода (3 из 4 должны выполниться)

#ТриггерКак измерить
1Отдельные командыAdmin и marketplace разрабатываются разными людьми/командами
2Shared package стабиленpartizap/core имеет semver-releases, breaking changes <1 раз в квартал
3Разные tech requirementsРазные PHP-версии, фреймворки, или масштабирование (marketplace горизонтально, admin — нет)
4API boundaryAdmin и marketplace общаются через API, не через shared DB objects напрямую

Ожидаемый горизонт: 12-24 месяца. Для большинства проектов такого масштаба Phase 2 достаточна навсегда.

Целевая структура Phase 3

partizap-core/              # git repo → private composer package
├── composer.json           # name: partizap/core, semver tags
├── src/
│   ├── Entity/
│   ├── Repository/         # interfaces
│   ├── Enum/
│   └── ValueObject/
├── migrations/
└── tests/

partizap-marketplace/       # git repo
├── composer.json           # requires partizap/core ^1.x
├── app/
│   ├── Actions/
│   │   ├── Store/
│   │   ├── Vendor/
│   │   ├── Auth/
│   │   └── Chat/
│   ├── Application/        # Middleware, Services
│   └── Infrastructure/     # Doctrine repo implementations
├── config/
└── tests/

partizap-admin/             # git repo
├── composer.json           # requires partizap/core ^1.x
├── app/
│   ├── Actions/Admin/
│   ├── Actions/AdminAuth/
│   ├── Application/        # Middleware, Services
│   └── Infrastructure/     # Doctrine repo implementations
├── config/
└── tests/

Проблема дублирования Application/Infrastructure

Application/ (middleware, services) и Infrastructure/ (Doctrine repos) дублируются между marketplace и admin. Два подхода:

Подход A: Расширить partizap/core (рекомендуется для команды <10 человек)

Перенести shared infrastructure в пакет:

partizap/core/
├── src/
│   ├── Entity/
│   ├── Repository/            # interfaces
│   ├── Enum/
│   ├── Infrastructure/        # Doctrine implementations
│   │   └── Persistence/
│   │       ├── DoctrineUserRepository.php
│   │       └── ...
│   └── Application/           # Shared middleware и services
│       ├── Middleware/
│       │   ├── AuthMiddleware.php
│       │   ├── CorsMiddleware.php
│       │   ├── CsrfMiddleware.php
│       │   └── RateLimitMiddleware.php
│       └── Service/
│           ├── SessionManager.php
│           ├── CsrfTokenManager.php
│           └── AdminAuditLogger.php
└── migrations/

Плюс: ноль дублирования. Минус: partizap/core становится толстым пакетом с зависимостями на Redis, Slim, Doctrine ORM. Обновление пакета затрагивает оба сервиса.

Подход B: Два пакета (для команды >10 человек или при разных фреймворках)

partizap/domain     # Entity, Repository interfaces, Enum — zero dependencies
partizap/infra      # Doctrine repos, Middleware, Services — depends on domain + Slim + Doctrine

Плюс: чистое разделение, partizap/domain не тянет infrastructure-зависимости. Минус: два пакета для поддержки, дополнительный overhead при изменениях.

Рекомендация: на Phase 3 использовать подход A (расширенный partizap/core). Переходить к подходу B только если core-пакет начнёт тормозить разработку из-за частых breaking changes в infrastructure-части.

Как извлечь core в отдельный git-репо

bash
# 1. Извлечь core/ с сохранением git-истории
cd partizap/
git subtree split --prefix=core -b core-only

# 2. Создать отдельный репо
cd /tmp && git init partizap-core
cd /tmp/partizap-core
git pull ~/partizap core-only
git remote add origin git@127.0.0.1:team/partizap-core.git
git push -u origin main

# 3. Удалить core/ из монорепо
cd ~/partizap/
git rm -r core/
git commit -m "chore: extract partizap/core to separate repo"

# 4. Обновить composer.json в backend/
json
{
    "repositories": [
        {
            "type": "vcs",
            "url": "git@127.0.0.1:team/partizap-core.git"
        }
    ],
    "require": {
        "partizap/core": "^1.0"
    }
}

Теперь partizap/core — версионированный пакет. Workflow:

  1. Изменение entity → PR в partizap-core → merge → git tag v1.1.0
  2. cd partizap-marketplace && composer update partizap/core
  3. cd partizap-admin && composer update partizap/core

Миграции при separate repos

Миграции остаются в partizap/core — единый source of truth для DDL. Запускаются из marketplace-деплоя:

bash
# marketplace deploy script
cd /var/www/partizap/production
composer install
# Миграции из vendor/partizap/core/migrations/
./vendor/bin/doctrine-migrations migrate --no-interaction

Admin-деплой не запускает миграции — работает с той же БД.

Frontend: pnpm workspaces monorepo (реализовано)

Frontend уже прошёл аналогичную эволюцию — админка выделена в отдельное Nuxt-приложение внутри pnpm workspaces monorepo (DEV-300):

partizap-frontend/
├── apps/main/        ← marketplace Nuxt app (@partizap/main)
├── apps/admin/       ← admin Nuxt app (@partizap/admin)
└── packages/shared/  ← shared entities, API client, utils (@partizap/shared)

Решения

АспектВыборОбоснование
Shared codeSymlinks + auto-imports~/shared/ и ~/entities/ пути работают через symlinks на packages/shared/
AuthОтдельный useAdminAuthStoreРазные entity (AdminUser vs User), cookie (PARTIZAP_ADMIN_SESSION), TOTP flow
SSRSPA-only для adminАдминка не нуждается в SSR, упрощает auth flow
CI/CDPath-based triggers (TODO)Изменение в packages/shared/ триггерит оба apps

Отличия от backend-подхода

Backend использует APP_MODE в одном деплое — один PHP-процесс обслуживает один режим. Frontend пошёл дальше — два отдельных Nuxt-приложения с разными nuxt.config.ts, package.json, и dev-серверами. Это обусловлено тем, что:

  1. Nuxt нативно поддерживает workspaces и layers
  2. Фронтенд-код admin на 100% отличается от marketplace (разные pages, layouts, auth)
  3. Shared code (entities, API client) — это 20% кодовой базы, а не 80% как на бэкенде

Альтернативы, рассмотренные и отклонённые

АльтернативаПочему отклонена
Сразу separate repos (Phase 3 без Phase 1-2)80% shared code → массивное дублирование или нестабильный composer-пакет при активной разработке domain layer
Git submodules вместо path repositorySubmodules добавляют friction при git clone, git pull, CI. Path repository — zero overhead при разработке
Monorepo навсегдаДопустимо при команде 1-3 человек. Но планировать Phase 2 стоит — когда domain стабилизируется, выделение упростит CI и тестирование
Отдельная БД для adminДва Doctrine connection усложняют код. Admin нуждается в чтении marketplace-данных (users, products). Одна БД + PostgreSQL roles — достаточная изоляция
API gateway между admin и marketplaceOverengineering для текущего масштаба. Admin напрямую читает те же таблицы. API gateway имеет смысл при микросервисной архитектуре с отдельными БД

Риски

РискМитигация
Преждевременная декомпозиция — overhead пакета при нестабильном domainНе переходить к Phase 2 до выполнения 3/5 триггеров. Path repository минимизирует overhead
Namespace rename ломает git blameОдин коммит на всю миграцию namespace. git log --follow работает для отдельных файлов
Doctrine не находит entities после выноса в пакетОбновить $entityPaths в doctrine.php. Проверить composer autoload dump
Миграции в пакете — кто их запускает?Только marketplace-деплой. Документировать в CLAUDE.md и deploy scripts
Два пакета (domain + infra) driftНе переходить к подходу B до реальной необходимости. Один пакет проще поддерживать

Последствия

  • Phase 1 (APP_MODE) не требует изменений в структуре кода — только config и routes
  • Phase 2 (path repository) требует rename namespace (App\Domain\*Partizap\Core\*) — одноразовый breaking change
  • Phase 3 (separate repos) требует отдельных CI/CD pipelines для каждого репо + semver-дисциплину для core-пакета
  • На каждой фазе миграции запускаются только из одного деплоя (marketplace)
  • Триггеры перехода проверяются ежеквартально на ретроспективе