3. Monorepo vs polyrepo
Где живет ваш код: Эпическая битва репозиториев
«Архитектура программного обеспечения начинается не с диаграмм компонентов, а с того, как исходный код организован на диске и как инженеры взаимодействуют с системой контроля версий».
— Принцип инфраструктурной ясности
В предыдущих лекциях мы спроектировали идеальный гексагональный каркас микросервиса (1. Структура микросервисного проекта на Go) и договорились избегать токсичных разделяемых библиотек, разрушающих независимость команд (2. Shared libraries vs copy paste).
Но теперь перед нами встает фундаментальный организационно-инфраструктурный вопрос: где физически будет жить исходный код нашей системы?
Представьте, что в компании разрабатывается 50 микросервисов. Исторически индустрия разделилась на два непримиримых лагеря:
- Polyrepo (Multirepo): «Много репозиториев» — один микросервис = один независимый Git-репозиторий.
- Monorepo: «Единый репозиторий» — весь исходный код компании (все микросервисы, общие контракты, утилиты и инфраструктурные манифесты) хранится в одном общем Git-дереве.
Для инженера на Go этот выбор — не просто вопрос вкуса или привычки работы с git push. Он фундаментально влияет на то, как компилятор Go разрешает транзитивные зависимости, как работает языковой сервер gopls в вашей IDE, как устроены модули go.mod и насколько быстро система способна компилироваться при масштабировании.
В этой лекции мы беспристрастно взвесим плюсы и минусы обоих подходов, исследуем эволюцию мультимодульности в Go и разберем архитектурную киллер-фичу современных версий языка — Go Workspaces (go.work).
Polyrepo: Иллюзия полной независимости
Подход Polyrepo (или Multirepo) — это естественный выбор по умолчанию для большинства стартапов и молодых команд. Вы создаете новый микросервис payment-api — вы нажимаете кнопку «New Repository» на GitHub или GitLab.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph GitHub["Организация в GitHub (Polyrepo)"]
R1["repo: payment-api\n(go.mod, Dockerfile)"]
R2["repo: order-service\n(go.mod, Dockerfile)"]
R3["repo: billing-service\n(go.mod, Dockerfile)"]
R4["repo: protobuf-contracts\n(Общие .proto схемы)"]
end
R1 -.->|"go get contracts"| R4
R2 -.->|"go get contracts"| R4
R3 -.->|"go get contracts"| R4
Плюсы подхода
- Жесткие границы команд (Закон Конвея):
Каждая команда является полновластным хозяином своего репозитория. Разработчик из команды «Платежи» (командаA) физически не может случайно задеть или сломать код команды «Каталог» (командаB). - Гранулярный контроль доступа (RBAC):
Компания может тонко раздавать права: подрядчикам доступен только репозиторий внешнего шлюза, а доступ к финансовому ядру открыт лишь узкому кругу проверенных сотрудников. - Предельно простой CI/CD:
Пайплайны прямолинейны и изолированы: коммит в репозиторийpayment-apiзапускает сборку и тесты ровно одного этого сервиса. Никакой магии с вычислением графа затронутых компонентов.
Фатальный недостаток: Dependency Hell (Ад зависимостей)
Однако идиллия независимости заканчивается в ту же секунду, когда сервисам требуется взаимодействовать друг с другом по сети через формальные контракты (gRPC / Protocol Buffers).
Представьте стандартную бизнес-задачу: бизнесу потребовалось добавить промокоды. Для этого нужно ввести новое поле discount_code в контракт Order.proto.
Круги ада в модели Polyrepo:
- Шаг 1: Разработчик открывает репозиторий контрактов
protobuf-contracts. Добавляет строчку в.protoфайл. Создает Pull Request (PR 1), ждет аппрувов от архитекторов, вливает в веткуmaster. - Шаг 2: Релизится новая версия контрактов в Git: вешается git tag
v1.5.0. - Шаг 3: Разработчик переходит в репозиторий
order-service. Выполняет командуgo get github.com/company/protobuf-contracts@v1.5.0. Обновляет код сервиса заказов для сериализации нового поля. Создает PR 2, проходит CI/CD, вливает вmaster. - Шаг 4: Разработчик идет в репозиторий
billing-service(который вычитывает эти заказы). Снова выполняетgo get, обновляет зависимости, адаптирует код биллинга. Создает PR 3, проходит ревью, вливает вmaster.
Это классический Diamond Dependency Problem (Проблема ромбовидных зависимостей). Чтобы выкатить одну крошечную фичу, затрагивающую смежные сервисы, инженер обязан открыть три разных Pull Request в три независимых репозитория в строгой хронологической последовательности!
А если на шаге 4 выяснится, что в контракте Order.proto была допущена концептуальная ошибка в типе поля? Весь этот бюрократический ритуал приходится повторять заново. Скорость инженерной разработки и поставки фич (Time-to-Market) падает до нуля.
Monorepo: Все яйца в одной корзине (правильно сложенные)
Monorepo — это архитектурный подход, принятый на вооружение технологическими титанами мировой индустрии (Google, Meta, Uber, Yandex). Весь исходный код бэкенд-сервисов, разделяемых контрактов, утилит и конфигураций хранится в едином Git-репозитории.
Главное преимущество: Атомарные коммиты
Вернемся к нашей задаче с добавлением поля discount_code.
В монорепозитории разработчик решает эту задачу в рамках ОДНОГО атомарного Pull Request:
- Редактирует контракт
contracts/order.proto. - Запускает генератор кода: файлы обновляются локально в том же дереве.
- Добавляет поддержку промокодов в
services/order. - Обновляет логику чтения в
services/billing. - Отправляет единый PR на ревью.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph PolyrepoWorkflow["Polyrepo: Разрозненный ад"]
P1["1. PR: protobuf-contracts"]:::process --> Tag["2. Git Tag v1.5.0"]
Tag --> P2["3. PR: order-service (go get)"]
Tag --> P3["4. PR: billing-service (go get)"]
end
subgraph MonorepoWorkflow["Monorepo: Атомарный коммит"]
AtomicPR["Один Pull Request:\n• contracts/order.proto\n• services/order\n• services/billing"]:::entry
Compiler["go build / go test ./..."]:::process
Merge["Merge в master (Всегда консистентно!)"]
AtomicPR --> Compiler --> Merge
end
Mechanical Sympathy компилятора:
В монорепозитории вся мощь компилятора Go встает на защиту архитектуры. Стоит запустить команду go test ./... в корне, как компилятор мгновенно проанализирует граф связей и скажет:
«Ты обновил контракт заказа, но забыл инициализировать новое поле в billing-service в файле handler.go:42!».
Вы получаете обратную связь от компилятора за доли секунды прямо на своем ноутбуке. Ветка master всегда остается 100% консистентной: ситуация, когда сервис и клиент используют несовместимые версии API, становится невозможной физически.
Go под капотом: Как правильно готовить Monorepo
Долгое время экосистема Go Modules не была оптимизирована под монорепозитории. Разберем тернистый путь эволюции подходов к управлению зависимостями.
Подход 1: Один глобальный go.mod
Файл go.mod размещается в корне репозитория, определяя единый список зависимостей для всех 50 микросервисов:
- Плюсы: Никаких конфликтов версий внутри компании, один
go.sum. - Минусы: Версионный тупик. Если сервису
payment-apiдля интеграции с новым облачным SDK требуется обновить фреймворкginили библиотекуgrpc, это изменение принудительно обновитginиgrpcво всех остальных 49 сервисах компании! Релиз блокируется, так как апгрейд ломает старый код в других отделах.
Подход 2: go.mod на каждый сервис (Мультимодульность)
Каждый микросервис живет в своей директории и имеет персональный файл go.mod:
monorepo/
├── contracts/
│ └── go.mod # github.com/company/mono/contracts
├── services/
│ ├── order/
│ │ └── go.mod # Зависит от contracts
│ └── billing/
│ └── go.mod # Зависит от contracts
Но как сервису order локально ссылаться на изменения в модуле contracts, если эти изменения еще не закоммичены и не запушены на GitHub?
Темные времена (до Go 1.18): Костыли с директивой replace
Разработчикам приходилось прописывать в go.mod сервиса локальный путь:
replace github.com/company/mono/contracts => ../../contracts
Это была бесконечная головная боль: разработчики забывали удалить replace перед коммитом, локальные пути ноутбуков утекали в репозиторий, а удаленный сервер CI/CD падал с ошибкой разрешения путей.
Светлое будущее: Go Workspaces (go.work)
Начиная с версии Go 1.18, появился официальный, элегантный механизм управления мультимодульными проектами — Go Workspaces.
В корень монорепозитория помещается файл go.work:
go 1.22
use (
./contracts
./services/order
./services/billing
)
Директива use сообщает тулингу Go, какие локальные модули объединены в единое рабочее пространство.
Архитектурные ловушки Monorepo
Если монорепозиторий столь удобен, почему не все компании используют его? Потому что за монорепо приходится платить ощутимый «инфраструктурный налог».
1. Долгий CI/CD и вычисление изменений (Affected Targets)
В Polyrepo вы пушите коммит в один репозиторий, и CI собирает один бинарник за 30 секунд.
Если в Monorepo вы настроите наивный CI, который при каждом пуше компилирует все 50 сервисов и прогоняет 15 000 тестов, каждый пайплайн будет длиться по 45 минут, блокируя всю команду.
Решение: Инфраструктурная команда обязана настроить умный CI (Smart Change Detection). Скрипты пайплайна анализируют затронутые файлы через Git:
git diff --name-only origin/master...HEAD
Если изменения затронули только директорию services/order, CI запускает сборку и тестирование исключительно для сервиса order. Если же изменился контракт в contracts/, CI вычисляет граф зависимостей и запускает тесты всех зависимых потребителей.
2. Кэш сборки (Build Cache)
В сверхкрупных монорепозиториях (Google, Uber) для ускорения компиляции разворачивают специализированные распределенные системы сборки — Bazel или Please, которые кэшируют артефакты компиляции на удаленных серверах.
Однако для Go-разработчиков в проектах малого и среднего масштаба (до сотни сервисов) тяжелый Bazel избыточен. Стандартный компилятор Go оснащен превосходным встроенным кэшем GOCACHE (Content-Addressable Cache). При грамотном пробросе кэша компилятора в CI-раннеры сборка сервисов на Go занимает считанные секунды без сторонних монструозных фреймворков.
3. Отсутствие дисциплины (The Big Ball of Mud)
В монорепозитории у разработчиков возникает соблазн нарушить архитектурную изоляцию: если сервис order лежит на соседней ветке каталога, почему бы просто не импортировать приватную функцию из billing?
Решение: Строжайшее соблюдение границ Bounded Context и использование защитных механизмов компилятора Go:
- Приватный код каждого микросервиса обязан быть заперт внутри директории
internal/(см. 1. Структура микросервисного проекта на Go). - На уровне CI настраиваются архитектурные линтеры (например,
go-cleanarchили правилаgolangci-lint), запрещающие перекрестные импорты между независимыми сервисами.
Вопросы для собеседования
Резюме
- Polyrepo: Дает надежную изоляцию кода и простой CI, но парализует разработку при межсервисных изменениях контрактов (Diamond Dependency Hell).
- Monorepo: Обеспечивает атомарные изменения: вы обновляете контракт и всех его потребителей в одном Pull Request, а компилятор Go гарантирует консистентность всей распределенной системы.
- Go Workspaces (
go.work): Стандарт де-факто для локальной разработки мультимодульных монорепозиториев на Go. Забудьте о директивеreplaceнавсегда. - Инфраструктурный налог Monorepo: Монорепозиторий требует продуманного CI/CD с селективным запуском тестов (
git diff --name-only) и контролем архитектурных границ с помощью директорииinternal/.
Мы выбрали структуру хранения кода и организовали удобное рабочее пространство с помощью go.work. Но теперь перед нами встает следующая практическая задача: как превратить этот исходный код в работающие production-контейнеры? Как настроить быстрый, надежный и безопасный конвейер непрерывной интеграции и доставки в Kubernetes? В следующей лекции мы детально разберем: 4. CI/CD для микросервисов.