Appearance
ADR-011: Эволюция монорепозитория — когда и как выделять сервисы
Дата: 2026-04-04
Статус: Принято
Авторы: Backend Team
Связан с: ADR-010
Контекст
ADR-010 вводит разделение на два деплоя (marketplace + admin) из одного репозитория через APP_MODE. Это Phase 1 — минимальное изменение, дающее изоляцию процессов, сессий и DB-ролей без дублирования кода.
Вопрос: когда и как переходить к дальнейшему разделению?
Текущее состояние кодовой базы
| Метрика | Значение |
|---|---|
| Shared entities | 17+ (User, Product, Category, AdminUser, AdminAuditLog...) |
| Shared repository interfaces | 17 |
| Shared services | SessionManager, CsrfTokenManager, AdminAuditLogger, JsonResponder, RequestParser, CursorPaginator, StampedeProtectedCache, Mailer... |
| Shared middleware | AuthMiddleware, CorsMiddleware, CsrfMiddleware, RateLimitMiddleware, SecurityHeadersMiddleware... |
| Admin actions | 36 |
| Marketplace actions | Store (~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 должны выполниться)
| # | Триггер | Как измерить | Текущее состояние |
|---|---|---|---|
| 1 | Domain стабилизировался | Entity/repository меняются <2 раз в месяц | Нет — активная разработка |
| 2 | Разная каденция деплоя | Admin нужно деплоить без marketplace (или наоборот) >3 раз в месяц | Нет — деплоят вместе |
| 3 | CI bottleneck | Полный тест-suite >5 мин, admin-change запускает marketplace-тесты зря | Нет |
| 4 | Разные разработчики | >1 человек работает только над admin или только над marketplace | Нет |
| 5 | Merge 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 разрабатываются разными людьми/командами |
| 2 | Shared package стабилен | partizap/core имеет semver-releases, breaking changes <1 раз в квартал |
| 3 | Разные tech requirements | Разные PHP-версии, фреймворки, или масштабирование (marketplace горизонтально, admin — нет) |
| 4 | API boundary | Admin и 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:
- Изменение entity → PR в
partizap-core→ merge →git tag v1.1.0 cd partizap-marketplace && composer update partizap/corecd 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-interactionAdmin-деплой не запускает миграции — работает с той же БД.
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 code | Symlinks + auto-imports | ~/shared/ и ~/entities/ пути работают через symlinks на packages/shared/ |
| Auth | Отдельный useAdminAuthStore | Разные entity (AdminUser vs User), cookie (PARTIZAP_ADMIN_SESSION), TOTP flow |
| SSR | SPA-only для admin | Админка не нуждается в SSR, упрощает auth flow |
| CI/CD | Path-based triggers (TODO) | Изменение в packages/shared/ триггерит оба apps |
Отличия от backend-подхода
Backend использует APP_MODE в одном деплое — один PHP-процесс обслуживает один режим. Frontend пошёл дальше — два отдельных Nuxt-приложения с разными nuxt.config.ts, package.json, и dev-серверами. Это обусловлено тем, что:
- Nuxt нативно поддерживает workspaces и layers
- Фронтенд-код admin на 100% отличается от marketplace (разные pages, layouts, auth)
- Shared code (entities, API client) — это 20% кодовой базы, а не 80% как на бэкенде
Альтернативы, рассмотренные и отклонённые
| Альтернатива | Почему отклонена |
|---|---|
| Сразу separate repos (Phase 3 без Phase 1-2) | 80% shared code → массивное дублирование или нестабильный composer-пакет при активной разработке domain layer |
| Git submodules вместо path repository | Submodules добавляют 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 и marketplace | Overengineering для текущего масштаба. 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)
- Триггеры перехода проверяются ежеквартально на ретроспективе