Skip to content

Как мы это строим

Для контрибьюторов: как устроен код внутри api. Весь бэкенд следует CleanSlice (NestJS + Prisma) в варианте agentfy — предсказуемая структура, где любая фича выглядит как любая другая.

Коротко: код разбит на слайсы (по одной самодостаточной фиче), слайсы собраны в слои (группы), и зависимости всегда идут только вниз. Впервые здесь? Глоссарий объясняет каждый термин ниже.

Анатомия слайса

slices/<group>/<slice>/
├── <slice>.module.ts        # NestJS-модуль
├── <slice>.controller.ts    # @Controller('plural')  — REST
├── <slice>.prisma           # Prisma-модель в корне слайса (если есть данные)
├── <slice>.tool.ts          # опц.: экспозиция через MCP (@Tool)
├── domain/                  # ЧТО он делает (контракты + логика)
│   ├── index.ts
│   ├── <slice>.types.ts     # IXxxData, ICreateXxxData, XxxTypes (enum)
│   ├── <slice>.gateway.ts   # абстрактный IXxxGateway  ← DI-токен
│   ├── <slice>.service.ts
│   └── errors/              # доменные ошибки (extends BaseError)
├── data/                    # КАК (реализация)
│   ├── <slice>.gateway.ts   # конкретная реализация (через PrismaService)
│   └── <slice>.mapper.ts    # трансформации (sync, без промисов)
└── dtos/
    ├── <slice>.dto.ts · create<Slice>.dto.ts · update<Slice>.dto.ts · filter<Slice>.dto.ts

Поток: Controller → IXxxGateway (абстрактный) → XxxGateway (реализация) → Mapper → Prisma.

Правила

  • Gateway-паттерн — абстрактный IXxxGateway в domain/, конкретный в data/. Prisma и есть репозиторий — никаких классов *Repository.
  • Singular-имена слайсов; camelCase для DTO-файлов; I-префикс для интерфейсов; суффикс Types для enum; #alias-импорты; теги-заголовки @scope/@slice/@layer/@type.
  • Prisma-модели лежат в корне слайса (<slice>.prisma), собираются в infra/prisma. Генерировать надо командой bun run prisma:generate, которая сначала собирает: голый npx prisma generate выходит нулём на устаревшей сборке и отдаёт клиент, который не знает вашей новой модели.
  • Каждый ответ завёрнут в конверт. Контроллер возвращает сырые доменные данные; перехватчик ответа заворачивает их в { success, data }. В Swagger это объявляется декоратором конверта, а не вписыванием обёртки в DTO — иначе получится два описания одной формы.
  • Инвариант живёт на пути записи, а не в DTO. DTO — это край: он даёт быстрый отказ на плохой HTTP-запрос и никогда не видит ни импортёра, ни фоновую задачу, ни тул. Правило, живущее только там, не обеспечено — оно лишь объявлено.
  • Константа объявляется один раз и импортируется. Граница, вписанная в DTO, сервис и форму, разъедется при первой же правке; за этот урок здесь уже платили не однажды.
  • Ресурс чужой команды отвечает not-found, а не forbidden. 403 сообщает вызывающему, что id существует; между арендаторами это само по себе утечка. Id команды, членом которой вызывающий не является, неотличим от id, которого никогда не было.
  • Контроллер не имеет права дотягиваться до слоя данных. Ни *Gateway, ни импорта из data/: контроллер зависит от сервиса, а сервис — от интерфейса гейтвея. Это не совет — проверка границ такое отвергает.
  • Идентификатор команды выдаёт аутентификация и больше ничто. Тип, который принимает арендаторный гейтвей, нельзя собрать из обычной строки, поэтому «взять команду из тела запроса» не компилируется. Это защита от спешки, а не от злого умысла — приведение типов её обходит, — и спешка как раз и случается на практике.
  • Спеки лежат в папке tests рядом с кодом: в корне слайса, в domain/ и в data/, и только на этих трёх уровнях. Со-локация остаётся правилом; изменилось то, что в корне слайса больше не лежит четырнадцать файлов спеков между модулем и контроллером. Jest находит их на любой глубине.

Инвариант слоистости

Самое важное структурное правило:

Зависимости между группами идут только вниз. Верхний слой может зависеть от нижних; нижний слой никогда не импортирует верхний.

При размещении слайса проверь, что он зависит только вниз. Если появляется ребро вверх — инвертируй его (порт/интерфейс, событие или чтение через Prisma), а не ломай слой. См. Слои-слайсы.

Что на самом деле проверяет гейт

make check — это линт, проверка границ и сборка, по всем четырём приложениям: api, app, admin и worker. Две вещи стоит знать раньше, чем решить, что вы что-то сломали:

  • Он сверяет установленное с лок-файлом. Зависимость, которую кто-то добавил вчера, а вы не устанавливали, раньше была здесь невидима и всплывала через сутки как отказ старта в чьём-то терминале. Теперь она названа. Ругань deps на правку, не трогающую package.json, — это ваша среда, а не ваш диф: сделайте установку.
  • Спеки исключены и из проверки границ, и из сборки — по имени файла. Поэтому зелёный гейт ничего не говорит о том, компилируются ли спеки; прогоняйте tsc отдельно, когда правка может их задеть — например, при переписывании путей.

Сама проверка границ берёт порядок групп из cleanslice.json каждого приложения, а не из скрипта, — поэтому один скрипт обслуживает и восемь групп api, и другие наборы app и admin.

MCP-first (правило проекта)

Перед написанием или изменением кода свериться с CleanSlice MCP (get-started, search с 2+ запросами, read-doc). Это строго.