– сравнивать реализации сложнее – всё раскидано по разным местам.
Монорепо решает это простым способом: всё рядом. В результате вы быстрее видите, чем отличаются подходы, а не боретесь с организацией.
Общая структура проекта
Мы используем фиксированную структуру папок. Она будет одинаковой на протяжении всей книги:
text
/apps/frontend-next
/apps/api-ts
/apps/api-py
/apps/api-java
/apps/api-go
/packages/shared-contract
/infra
/tests/e2e
Дальше разберём смысл каждой папки, и какие правила мы будем соблюдать.
apps/: приложения (то, что запускается)
Папка apps/ содержит запускаемые приложения: фронтенд и несколько вариантов бэкенда.
/apps/frontend-next
Фронтенд на Next.js. Это «лицо» продукта и удобный способ быстро проверить, что API реально работает.
Что обычно лежит внутри:
– страницы и компоненты UI;
– клиент для API (чаще всего сгенерированный);
– настройки окружений (.env.local), базовый URL до API.
Главная договорённость: фронтенд не должен знать, какой именно бэкенд работает внутри. Он должен опираться на контракт: если API соответствует OpenAPI, фронтенд работает.
/apps/api-ts
Бэкенд на TypeScript (например, NestJS). Он будет полезен как «референс» для многих команд: TypeScript часто выбирают в компаниях, где много фронтенда.
Обычно внутри:
– контроллеры/роуты;
– сервисы (бизнес-логика);
– слой доступа к данным;
– конфиг приложения и переменные окружения.
/apps/api-py
Бэкенд на Python (например, FastAPI). Хорош для скорости разработки, удобных деклараций схем и читабельного кода.
Обычно внутри:
– роуты (эндпоинты);
– pydantic‑модели (или аналоги);
– зависимости (DI), конфиги;
– миграции БД (если используем).
/apps/api-java
Бэкенд на JVM (чаще всего Spring Boot; в книге мы можем использовать Java или Kotlin). Это классический вариант для корпоративных систем.
Обычно внутри:
– контроллеры;
– сервисы и репозитории;
– конфигурация, профили окружений;
– сборка (Gradle/Maven).
/apps/api-go
Бэкенд на Go (например, chi или gin). Его сильные стороны – производительность, простота деплоя, предсказуемое потребление памяти.
Обычно внутри:
– роутер и хендлеры;
– слой бизнес-логики;
– слой хранения;
– конфиг и сборка в один бинарник.
Общее правило для apps/*
Каждое приложение в apps/ должно уметь:
1. Запускаться в dev‑режиме одной командой (например, dev).
2. Поднимать соединение с инфраструктурой из infra (Postgres/Redis).
3. Иметь health-check (например, GET /health).
4. Иметь настройку порта через переменные окружения.
Эти правила сильно упрощают e2e‑тесты и CI: тестам не важно, на чём написан API – важно, что он доступен и соответствует контракту.
packages/: общее и переиспользуемое
Если apps/ – это «то, что мы запускаем», то packages/ – это «то, что мы переиспользуем».
/packages/shared-contract
Это одна из ключевых папок в книге. Здесь живёт контракт между фронтом и бэкендом – OpenAPI‑описание, а также всё, что из него генерируется.
Типичное содержимое:
– openapi.yaml – главный файл контракта;
– папка со сгенерированными TypeScript‑типами;
– сгенерированный API‑клиент для фронтенда и тестов;
– скрипты для генерации (чтобы процесс был одинаковым у всех).
Почему это важно:
– контракт лежит в одном месте;
– фронтенд и тесты используют один и тот же источник;
– мы уменьшаем шанс «фронт ожидал одно, бэкенд вернул другое».
Правило: если меняется API, сначала меняем OpenAPI в shared-contract, потом обновляем реализации.
infra/: инфраструктура проекта
Папка infra/ – это всё, что не является кодом приложения, но нужно для работы системы.
Что здесь может быть
– docker-compose.yml для локальной инфраструктуры (Postgres, Redis, очереди);
– конфиги и скрипты инициализации;
– папки под Kubernetes‑манифесты (если дойдём до них);
– Terraform‑файлы (если будем описывать облачную инфраструктуру).
На старте нам чаще всего достаточно docker-compose, потому что это быстрый способ поднять окружение, одинаковое у всех.
Почему инфраструктура в отдельной папке?
Чтобы она была общей для всех бэкендов. Нам не нужно четыре отдельных compose‑файла – иначе мы будем править одно и то же в четырёх местах.
tests/e2e: одни тесты на все API
Папка /tests/e2e содержит end-to-end тесты, которые запускаются против любого из бэкендов. Это важный принцип книги: мы сравниваем реализации честно, по одинаковым проверкам.
Что делают e2e‑тесты:
– поднимают окружение (или подключаются к уже запущенному);
– делают реальные HTTP‑запросы к API;
– проверяют статус-коды, тело ответа, ошибки;
– (опционально) сверяют ответы со схемой OpenAPI.
Как это выглядит на практике:
– вы запускаете api-ts и гоняете e2e – всё зелёное;
– переключаете на api-go и гоняете те же e2e – тесты должны пройти без изменений.
Если тесты завязаны на конкретную реализацию – это плохие e2e.
Хорошие e2e завязаны на контракт.
Минимальные договорённости по именованию и портам
Чтобы всё собиралось без «магии», вводим простые правила:
1. У каждого бэкенда свой порт по умолчанию (например: 3001/3002/3003/3004).
Но порт всегда можно переопределить через переменную окружения.
2. У каждого бэкенда общий базовый префикс API (например, без префикса или /api – главное, чтобы одинаково везде).
3. Фронтенд читает API_BASE_URL из env и не «хардкодит» адрес.
4. E2E тесты читают E2E_BASE_URL (или аналог) – один параметр, чтобы тесты знали, куда стучаться.
Это мелочи, но они экономят огромное количество времени.
0.2.8. Инструменты, которые удобно поставить для работы с монорепо
Ниже – набор программ, которые заметно упрощают жизнь. Ничего “обязательного” здесь нет, но с ними будет легче.
Для работы с Git и кодом
– VS Code – универсальный редактор для всего монорепо.
– IntelliJ IDEA – очень удобна для Spring Boot/Gradle/Maven.
– Git client (по желанию) – если вам проще визуально смотреть историю и конфликты.
Для инфраструктуры
– Docker Desktop (macOS/Windows) или Docker Engine (Linux).
– Postman или Insomnia – быстро вручную дергать API (хотя у нас будет OpenAPI и тесты).
Для работы с API-контрактом
– Плагин OpenAPI для редактора (подсветка, подсказки, валидация).
– Любой HTTP клиент в IDE (в VS Code/JetBrains есть встроенные варианты).
Для языков
– Node.js (лучше через менеджер версий), плюс pnpm.
– Python (через pyenv/uv – как удобнее).
– JDK (например, 21) для JVM-проекта.
– Go (желательно фиксировать версию в проекте).
Что мы получаем в итоге
После того как структура задана, у нас появляется понятная картина:
– В apps/ лежат разные реализации, которые можно запускать по очереди.
– В packages/shared-contract лежит единый контракт API и генерация типов/клиентов.
– В infra/ лежит общая инфраструктура, которую не нужно дублировать.
– В tests/e2e лежат тесты, которые одинаково проверяют любой бэкенд.
Эта структура – фундамент книги. Дальше мы будем добавлять функциональность (эндпоинты, модели, базы данных, авторизацию), но базовые правила останутся прежними: контракт один, тесты одни, реализаций много.
Небольшая правка по ответам из предыдущего раздела
Перед новой главой уточним два момента, чтобы дальше не было путаницы.
Где лежит контракт API и почему он один?
В общем смысле вы правы: контракт – это соглашение. Но в нашем проекте это соглашение должно быть зафиксировано в коде в одном месте, иначе оно начнёт «плавать» между реализациями.
В монорепозитории книги контракт лежит здесь:
– /packages/shared-contract/openapi.yaml
Почему он один:
– единый источник правды: фронтенд, тесты и все бэкенды сверяются с одним файлом;
– нет расхождений «в TS так, а в Go чуть иначе»;
– проще развивать API: изменения проходят через одно место и проверяются автоматически.
Где будут e2e‑тесты и почему они не должны зависеть от конкретного языка?
В нашем проекте e2e‑тесты лежат в:
– /tests/e2e
Они не зависят от языка, потому что их цель – проверять поведение системы через HTTP, то есть:
– одинаковые URL и методы,
– одинаковые статус-коды,
– одинаковые тела ответов,
– одинаковые ошибки.
То, написан сервер на Python или Java, для e2e вообще не важно – важен контракт и фактическое поведение.
Что относится к apps/, а что – к packages/?
– apps/ – запускаемые приложения (frontend и каждый бэкенд).
– packages/ – общие библиотеки/артефакты, которые переиспользуются несколькими приложениями (в нашем случае – контракт и генерация типов/клиентов).
Почему инфраструктура вынесена в infra/, а не разбросана по приложениям?
Потому что инфраструктура у нас общая. Postgres/Redis/очередь – не «часть конкретного api-ts», они нужны всем реализациям.
Если размазать infra по приложениям:
– придётся дублировать docker-compose и конфиги;
– версии сервисов начнут расходиться;
– станет сложнее запускать и поддерживать окружение.
Папка infra/ делает инфраструктуру одной, а значит – повторяемой и предсказуемой.
Контракт как основа: OpenAPI-first
В этой книге мы идём подходом OpenAPI-first: сначала описываем API как контракт, а потом реализуем его на разных языках.
Это дисциплина, которая сначала кажется «лишней бюрократией», но очень быстро окупается:
– фронтенд получает типы и клиент без ручной работы;
– e2e‑тесты знают, что проверять, и могут дополнительно сверять схему;
– разные бэкенды реализуют один и тот же договор, без «творчества»;
– изменения API становятся управляемыми: видно, что сломается.
Наша цель в этой главе простая:
написать openapi.yaml, научиться генерировать из него типы/клиенты и заставить CI проверять, что контракт валиден.
Где живёт OpenAPI в нашем монорепо
Мы договорились: контракт лежит в одном месте.
Структура пакета:
text
/packages/shared-contract
openapi.yaml
/generated
/scripts
– openapi.yaml – главный файл спецификации.
– generated/ – сюда можно складывать сгенерированные артефакты (типы, клиенты).
– scripts/ – скрипты генерации и проверки.
Папки generated и scripts – опциональны, но они помогают держать порядок: «ручной код» отдельно, «генерация» отдельно.
Пишем первый openapi.yaml
Начнём с минимального, но реального API. Нам нужно что-то, что:
1) легко проверить e2e‑тестами;
2) пригодится для health-check;
3) задаст стиль ответов и ошибок.
Сделаем два эндпоинта:
– GET /health – проверка, что сервис жив.
– GET /api/version – показывает версию API (или приложения), чтобы удобно сравнивать окружения.
Создаём файл:
/packages/shared-contract/openapi.yaml
yaml
openapi: 3.0.3
info:
title: Demo API
version: 0.1.0
description: |
Контракт API для учебного проекта.
Все реализации (TS/Python/Java/Go) обязаны ему соответствовать.
servers:
– url: http://localhost:3001
description: Local dev (пример)
tags:
– name: system
description: Системные эндпоинты
paths:
/health:
get:
tags: [system]
summary: Health check
description: Проверяет, что сервис доступен
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "/components/schemas/HealthResponse"
/api/version:
get:
tags: [system]
summary: API version
description: Возвращает версию приложения/контракта
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "/components/schemas/VersionResponse"
components:
schemas:
HealthResponse:
type: object
additionalProperties: false
required: [status]
properties:
status:
type: string
enum: [ok]
VersionResponse:
type: object
additionalProperties: false
required: [version]
properties:
version:
type: string
example: "0.1.0"
Почему мы сразу добавили additionalProperties: false
Это важная деталь. Она означает: в ответах не должно быть неожиданных полей.
Если это не включать, сервер может вернуть лишнее, фронт случайно начнёт это использовать, а потом вы захотите убрать поле – и получите «тихий» breaking change.
С additionalProperties: false API становится строже, зато стабильнее.
Договоримся о стиле ошибок (на будущее)
Пока в контракте нет ошибок, но уже сейчас полезно задать единый формат. Тогда все реализации будут возвращать одно и то же.
Добавим схему ошибки в components/schemas (даже если эндпоинты пока её не используют):
yaml
components:
schemas:
ApiError:
type: object
additionalProperties: false
required: [code, message]
properties:
code:
type: string
example: "VALIDATION_ERROR"
message:
type: string
example: "Invalid request"
details:
type: object
additionalProperties: true
description: Дополнительная информация (опционально)
Позже мы начнём ссылаться на ApiError в ответах 400/401/404/500. Сейчас важно: мы заранее закрепили формат.
Генерируем клиентов и типы
OpenAPI полезен сам по себе как документация. Но настоящая сила – в генерации.
Что мы обычно хотим генерировать:
– TypeScript типы для фронтенда и e2e‑тестов;
– TypeScript API клиент (чтобы не писать fetch вручную);
– опционально: клиенты для других языков (если нужно).
Какие инструменты можно использовать
Ниже несколько популярных вариантов. В книге можно выбрать один (или даже сменить позже), главное – чтобы процесс был повторяемым.
– OpenAPI Generator – умеет генерировать почти всё, поддерживает массу языков.
– Swagger Codegen – старый, но встречается.
– Для TS:
– генерация типов отдельно (например, из схем),
– генерация клиента (fetch/axios).
Практический совет: для старта проще всего выбрать один генератор, который делает TS‑клиент и типы, и пользоваться им везде.
Куда складывать результат
Есть два подхода:
1) Коммитить generated в репозиторий
Плюсы: быстро, всё видно, CI проще.
Минусы: лишние диффы, больше шума в PR.
2) Не коммитить, генерировать в CI и локально
Плюсы: чище репозиторий.
Минусы: нужно следить, чтобы все запускали генерацию.
Для учебного проекта допустимы оба. Важно не «что лучше в вакууме», а чтобы команда договорилась.
В нашей структуре логично держать генерацию здесь:
– packages/shared-contract/generated/
Как встроить генерацию в рабочий процесс
Нам нужно, чтобы генерация была:
– одинаковой у всех
– воспроизводимой
– не зависела от ручных действий
Минимальная схема работы:
1. Меняем openapi.yaml.
2. Запускаем генерацию (скрипт).
3. Фронтенд и тесты используют обновлённые типы/клиент.
Даже если вы пока не пишете реальные команды, полезно уже сейчас завести «точку входа», например:
– packages/shared-contract/scripts/generate
– packages/shared-contract/scripts/validate
Важно: не размазывать генерацию по разным местам. Контракт и его обслуживание должны жить рядом.
Валидация контракта в CI
Контракт имеет смысл только тогда, когда он валиден и изменения контролируются.
В CI нам нужны проверки:
1) OpenAPI файл корректный YAML
2) OpenAPI соответствует схеме OpenAPI 3.0.x
О проекте
О подписке
Другие проекты