Files
SciMesh/users/ARCHITECTURE.md
T
2026-07-26 14:38:24 +03:00

8.4 KiB
Raw Blame History

Архитектура координатора

Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит запрос», в конце — «куда добавлять новое».


1. Четыре слоя

   infra       конфиг, пул БД, часы, HTTP-сервер, reaper    ← драйверы
   transport   HTTP-хендлеры          ← входящее: кто зовёт нас
   storage     репозитории на SQL     ← исходящее: кого зовём мы
   usecase     операции + ПОРТЫ (интерфейсы)                ← прикладные правила
   domain      Task, Job и их инварианты                    ← бизнес-правила

                       ┌── transport ──┐
   domain ◄── usecase ◄┤               ├◄── infra
                       └── storage ────┘

transport и storage — один и тот же слой (в книгах он зовётся «адаптеры»), просто разделённый по направлению: транспорт принимает запросы снаружи, storage обращается наружу сам. Так путь к файлу говорит о его роли, а не о категории.

Единственное правило: зависимости идут только внутрь. domain не импортирует ничего из проекта. usecase видит только domain. transport и storage не знают друг о друге.

Проверить в любой момент:

go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal
# пусто = правило соблюдено

2. Где что лежит

Файл Что внутри Строк
domain/task.go Task и все переходы состояний: аренда, завершение, провал, истечение ~245
domain/job.go Job, разбиение на чанки, вывод статуса из счётчиков задач ~107
domain/errors.go Нарушения бизнес-правил (ErrLeaseConflict, ErrStaleAttempt, …) ~18
usecase/ports.go Порты: TaskRepository, JobRepository, TxManager, Clock ~79
usecase/task.go Операции над задачей: claim, renew, complete, fail, expire ~200
usecase/job.go Операции над job: create, status, results, stitch ~180
usecase/dto.go Входные структуры юзкейсов ~51
transport/http/server.go Роутер и сборка middleware ~60
transport/http/handlers.go По хендлеру на эндпоинт ~180
transport/http/dto.go JSON-форматы запросов и ответов ~118
transport/http/middleware.go request-ID, access-лог, bearer-авторизация ~103
transport/http/errors.go Маппинг доменных ошибок в HTTP-коды ~55
storage/postgres/task_repo.go SQL по задачам, включая атомарный claim ~109
storage/postgres/job_repo.go SQL по job'ам ~39
storage/postgres/tx.go TxManager: транзакция через контекст ~65
infra/*.go Конфиг, пул, часы, сервер, reaper ~240
cmd/coordinator/main.go Composition root — единственное место со всеми конкретными типами ~73

3. Трасса запроса: POST /tasks/claim

Как воркер получает задачу. Четыре остановки, по одной на слой:

  ①  transport/http/handlers.go → handleClaim
        разбирает JSON, отдаёт usecase.ClaimTaskInput
        │
        ▼
  ②  usecase/task.go → ClaimTask.Execute
        сначала подчищает протухшие аренды, потом просит одну задачу
        через ПОРТ TaskRepository (реализацию не знает)
        │
        ▼
  ③  usecase/ports.go → TaskRepository.ClaimNext
        контракт: «атомарно выдай одну задачу»
        │
        ▼
  ④  storage/postgres/task_repo.go → claimNextSQL
        SELECT ... FOR UPDATE SKIP LOCKED + UPDATE одним запросом

Обратно поднимается *domain.Task, юзкейс сужает его до domain.ClaimedTask (воркеру не отдаём version, max_attempts и чужие ошибки), хендлер превращает в JSON. Пустая очередь — это nil, nil на шаге ② и 204 на ①.

Трасса POST /tasks/{id}/result такая же, но с одним отличием: решение принимает сущность, а не юзкейс.

  handlers.go → CompleteTask.Execute → tx.WithinTx(
        GetForUpdate  →  task.CompleteWith(...)  ←── ЗДЕСЬ правила
                              │                      (чужая аренда? устаревший
        Update  ←─────────────┘                       attempt? повтор того же
        syncJobStatus                                 манифеста?)
  )

4. Куда добавлять новое

Хочу… Правлю
новое бизнес-правило (когда задачу можно повторить) domain/task.go + тест рядом
новую операцию (отменить job) usecase/job.go + порт в ports.go, если нужен новый запрос к БД
новый HTTP-эндпоинт transport/http/handlers.go + маршрут в server.go + DTO в dto.go
новый SQL-запрос storage/postgres/*_repo.go
новую настройку infra/config.go + .env.example
поменять код ответа на ошибку transport/http/errors.go

Правило при сомнении: если код можно описать фразой «когда X, то Y» без упоминания HTTP, SQL и конфигов — это domain. Если он оркеструет несколько шагов и транзакцию — usecase. Если знает про JSON — transport, про SQL — storage.


5. Три вещи, которые надо понять один раз

Порты объявляет потребитель. TaskRepository описан в usecase/ports.go, а реализован в storage/postgres. Поэтому usecase не импортирует storage — стрелка зависимости смотрит внутрь, хотя вызов на рантайме идёт наружу.

Транзакция едет в контексте. TxManager.WithinTx кладёт pgx.Tx в контекст по неэкспортируемому ключу; репозитории достают её через conn(ctx, pool). Благодаря этому юзкейс говорит «сделай это атомарно», ни разу не упомянув pgx.

Атомарный claim нельзя разложить на шаги. ClaimNext — один SQL-запрос, потому что SELECT + отдельный UPDATE вернул бы гонку, при которой одну задачу выдают двум воркерам. Поэтому ClaimTask.Execute выглядит тонким: там нечего оркестровать, вся гарантия — внутри запроса.


6. Что уже работает, а что заглушка

Работает: слои и проводка, роутинг, авторизация, access-лог, маппинг ошибок, транзакции, graceful shutdown, миграции, весь domain с 12 юнит-тестами без БД.

Заглушки (ErrNotImplemented → HTTP 501): методы репозиториев. SQL для двух главных операций уже написан в task_repo.goclaimNextSQL и expireLeasesSQL, осталось их подключить.