diff --git a/CLAUDE.md b/CLAUDE.md index 6cdeee9..ec383aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,23 @@ Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Прилож его Biome и юнит-тесты Vue. Сборка — Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. +## Проектирование + +**Чистая архитектура.** Зависимости направлены внутрь, к домену: домен не знает +ни хранилища, ни транспорта, знание о внешнем мире приходит интерфейсом порта, а +реализацию подставляет точка входа. Направления держат тесты-сканеры +`internal/archrules`, а не договорённость. + +**Модель домена ведётся тактическими шаблонами DDD** — сущность и корень +агрегата, объект-значение, доменное событие, репозиторий, служба домена, +фабрика. Анемичной модели не заводим: поведение записи живёт в домене, а +прикладной слой назначает порядок шагов, а не правила. + +Слои, их дома, что каждому знать нельзя и чем шаблон занят сегодня — +[docs/architecture.md](docs/architecture.md), «Слои и модель домена». Здесь это +не повторяется: перечень растёт вместе с моделью, и вторая копия разошлась бы с +ним молча. + ## Инварианты Что нарушать нельзя. diff --git a/docs/architecture.md b/docs/architecture.md index 61172a1..e7bc721 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,9 +77,11 @@ задача возвращается в работу, нормирует [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения. -- **Ядро зависит от интерфейсов.** `internal/service` знает только +- **Чистая архитектура.** Зависимости направлены внутрь, к домену: внутренний + слой не знает внешнего никогда. `internal/service` знает только `internal/contract`; ffmpeg, Yandex и хранилище подставляются в точке входа - `cmd/transcriber`. Правило механизировано тестами-сканерами `internal/archrules`, и + `cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель + домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и они же держат обратные направления: транспорты не знают друг о друге, адаптер не знает ни ядра, ни транспортов. *Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http` @@ -87,6 +89,55 @@ роутер этого хранилища, а не наш сервер поверх него. Правила на это направление нет намеренно. +## Слои и модель домена + +**Подход — чистая архитектура.** Зависимость идёт только внутрь: домен не знает +ни хранилища, ни транспорта, а знание о внешнем мире живёт интерфейсом в портах +и реализацией в инфраструктуре. + +| Слой | Дом | Что живёт | Чего не знает | +| --- | --- | --- | --- | +| Домен | `internal/entity` | сущности, объекты-значения, доменные события, инварианты значениями | ничего, кроме стандартной библиотеки и единой точки времени `internal/clock` | +| Порты | `internal/contract` | интерфейсы репозиториев и внешних служб, типизированные ошибки | реализаций | +| Прикладной слой | `internal/service` | шаги конвейера: порядок, повтор, приговор | адаптеров и входов | +| Инфраструктура | `internal/adapter` | репозитории, ffmpeg, Yandex, шаги схемы | ядра и входов | +| Входы | `internal/controller` | HTTP и пул воркеров | друг друга | +| Сборка | `cmd/transcriber` | подстановка реализаций в порты, подъём сервера и пула | — | + +Направления держат тесты-сканеры `internal/archrules` — все, кроме чистоты +самого домена. **Её не держит ничто**: правила смотрят ядро, входы и адаптеры, а +импорт внешней библиотеки в `internal/entity` сегодня пройдёт молча. + +**Модель домена ведётся тактическими шаблонами DDD.** Шаблон называется здесь +вместе со своим сегодняшним предметом — перечень растёт вместе с моделью: + +- **Сущность** — `entity.AudioRecord`: у неё идентичность и поведение + (`MoveToState`, `Halt`, `Resume`, `Postpone`), а не набор полей при сервисе; +- **корень агрегата** — она же: файлы, тексты, структура, попытки распознавания и + журнал событий принадлежат записи и живут её идентификатором, а правит агрегат + держатель захвата; +- **объект-значение** — `entity.Stage` со своими сроками, `entity.StuckLimits`, + `entity.Replica`, `entity.RecognitionResult`: сравниваются по значению и своей + идентичности не имеют; +- **доменное событие** — `entity.RecordEvent`: что случилось с записью, чьей + рукой и чем кончилось; +- **репозиторий** — интерфейсы `internal/contract`, реализации под + `internal/adapter/repo`; +- **служба домена** — правило, не принадлежащее одной сущности, живёт функцией + пакета домена (`entity.WorkingStages`, `entity.SanitizeOriginalFilename`); +- **фабрика** — `entity.NewInProgressResult` и соседи: значение приходит + согласованным, а не заполняется полями снаружи. + +**Анемичной модели не заводим.** Новое поведение записи ищет дом сначала в +домене; прикладной слой назначает порядок шагов, а не правила. Признак нарушения +наблюдаем: правило о записи, записанное в `internal/service` условием над её +полями, принадлежит `internal/entity`. + +Место, где подход нарушен сегодня, названо изъятием в «Принципах»: транспорт +знает адаптер хранилища, потому что HTTP-поверхность и есть роутер этого +хранилища. Изъятие снимает задача `storage-without-pocketbase` — своя отдача +файла и свои маршруты возвращают транспорту независимость от инфраструктуры. + ## Компоненты Каждый — строкой со ссылкой на capability, а не пересказом её требований.