10 KiB
Цифровая визитка
GraphQL API, которое отдаёт публичную визитку Ильи Братских: профиль, навыки, опыт, проекты и связанные с ними факты. Контракт смотрится в Apollo Sandbox.
Факты взяты с bratskih.tech/about и из публичных репозиториев, которые этой странице соответствуют. Годы хранятся так, как они указаны в резюме: месяц не выдумывается. В список навыков не добавлены технологии только из-за того, что они использованы в этом репозитории.
Стек
NestJS 11, TypeScript, Prisma ORM 7, PostgreSQL, Apollo Server, Docker.
Запуск с нуля
Нужны Docker и свободные порты 3000 и 5432.
docker compose up --build
Когда контейнер api станет healthy, Sandbox открывается на http://localhost:3000/graphql. Корень сайта перенаправляет туда же. Проверка живости: GET /health.
Пустая база не требует ручных шагов. Процесс при старте применяет миграции и записывает визитку, если её содержимое ещё не совпадает с кодом.
Запрос из задания:
query {
profile {
name
description
skills {
name
}
experience {
company
position
}
projects {
name
}
}
}
Более полный пример лежит в examples/profile.graphql.
Остановить и удалить данные:
docker compose down -v
Локальная разработка
Copy-Item .env.example .env
docker compose up -d postgres
npm install
npm test
npm run start:dev
npm test генерирует Prisma Client и гоняет модульные тесты. База для них не нужна. npm run test:e2e поднимает приложение на PostgreSQL из .env и проверяет Sandbox, пример запроса и число SQL-обращений. Локальный старт перезаписывает schema.gql. В контейнере схема держится в памяти: процесс не пишет в файловую систему образа.
Структура
src/
main.ts миграции, затем HTTP
config/ проверка окружения
health/ / и /health
prisma/ клиент и migrate deploy
profile/
domain/ визитка, период, план связей
application/ сценарий чтения
infrastructure/ Prisma, include, отображение строк
presentation/ GraphQL-типы, selection set, ошибки
seed/ декларативные данные и их запись
prisma/schema.prisma
prisma/migrations
Архитектура
Визитка — один агрегат. Корень — Profile. Навыки, места работы, достижения, проекты, образование и конкурсы не живут отдельно и не имеют собственного сценария изменения. Поэтому это один модуль profile, а не четыре сервиса с одинаковой зависимостью от профиля.
Слои внутри модуля разделены по ответственности:
- GraphQL знает про selection set и форму ответа. Он не собирает SQL.
- Сервис проверяет идентификатор и решает, что профиль существует. Он не знает про Prisma.
- Репозиторий один раз читает агрегат. Состав
includeзависит от плана связей. - Период вроде
2023 — н.в.считается при чтении. В базе лежат годы, чтобы не хранить уже отформатированную строку.
Запись в базу есть только у сида. Публичного изменения данных нет: визитка — опубликованная модель чтения, а правка содержания проходит через код и ревью. Для этого сервиса мутации были бы другим продуктом.
Модель
Profile связан с дочерними строками каскадом. Навык уникален в пределах профиля. Проект ссылается на навыки через ProjectSkill, поэтому один и тот же навык не копируется в каждый проект. Подсветки проекта и достижения — отдельные упорядоченные строки, а не массив внутри JSON: их можно дочитать отдельным запросом и не тащить, если поле не выбрано.
Порядок на сайте задаёт sortOrder. Год окончания null означает текущее место. Статус проекта тоже может отсутствовать, если резюме его не называет.
Вложенные поля и база
Корень profile смотрит на дерево запроса, включая фрагменты и алиасы, и строит план: какие связи вообще нужны. Репозиторий передаёт в Prisma include только для этих связей. Поля ответа читают уже загруженный объект, отдельных resolver-ов на каждую связь нет.
Для запроса из задания план такой: навыки, места работы без достижений, проекты без навыков и подсветок. Достижения, образование и ссылки в этот запрос не попадают и не читаются.
Prisma получает include только по выбранным связям. Драйвер PostgreSQL умеет relation joins, поэтому несколько связей могут уйти одним SQL, но состав этого SQL всё равно ограничен планом. Число обращений не растёт вместе с числом строк: e2e на PostgreSQL проверяет, что два места работы отдают достижения одним чтением, а запрос без достижений таблицу достижений не открывает. Форму include для примера из задания фиксирует profile-query.spec.ts.
DataLoader здесь не используется. Он нужен, когда каждый field resolver ходит в базу сам и одинаковые ключи надо склеить. В этом графе состав запроса известен в корне, и вторая очередь загрузки была бы лишней. Если появится список профилей, которые догружаются из разных родителей, DataLoader встанет на границу репозитория, а не вместо плана.
Старт и данные
main сначала запускает prisma migrate deploy, затем Nest. Сид выполняется в onApplicationBootstrap, уже после соединения с базой.
Сид идемпотентный. Содержимое визитки хешируется, хеш лежит в Profile.contentHash. Совпадает — запись не трогается, идентификаторы живут между перезапусками. Не совпадает — агрегат заменяется в одной транзакции. Повторный старт двух процессов сериализуется pg_advisory_xact_lock, чтобы они не создали две визитки.
Prisma 7 ходит в PostgreSQL через драйвер pg, без отдельного бинарного query engine. Строка подключения для CLI лежит в prisma.config.ts, для приложения её проверяет схема окружения: это должен быть PostgreSQL URL, порт по умолчанию 3000. Клиент Prisma CLI при отсутствии переменной подставляет локальный URL из Compose, чтобы prisma generate не требовал поднятую базу. Сам API без DATABASE_URL не стартует.
Образ собирается в два этапа. В нём остаётся Prisma CLI, потому что миграции применяет тот же процесс, который потом слушает порт. Отдельный init-контейнер не нужен: docker compose up --build на пустом volume достаточен.
Что сознательно не делалось
Нет авторизации и мутаций: визитка публичная. Нет пагинации: объём фиксирован и мал. Нет общего маппера «любой GraphQL-запрос в любой Prisma include»: схема маленькая, явный план читается и тестируется.