Как мы это строим
Для контрибьюторов: как устроен код внутри 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). Это строго.