ADR-0064: Чистые слои и нормализация как инвариант данных

Status: accepted Date: 2026-07-05 Deciders: Maxim Belkanov

Контекст

Аудит backend’а (июль 2026) показал системные протечки слоёв, каждая из которых уже нарушает ранее принятые решения:

  1. SQL/pgx в application-слое. ingestion/app/char_coverage_reconcile.go держит *pgxpool.Pool и гоняет сырой SELECT count(*) + INSERT … ON CONFLICT прямо из app. CRUD-сервисы delivery/pricing/stock/offers и ingestion lease/orchestrator держат пул ради Begin(). Это нарушает backend/AGENTS.md 2 и ADR-0056 (даже разовый read-model SQL живёт в infra-репозитории).
  2. Бизнес-логика в HTTP-слое. offers/api/http/handler.go на каждом чтении GET /api/v1/admin/offers/supplier-offers regex-парсит packing из сырых атрибутов и name. search/proposal/infra/http/estimate.go содержит 25+ функций, которые во время HTTP-запроса классифицируют характеристики, извлекают числа из текста и ранжируют аналоги. ADR-0053 прямо отклонил вариант «нормализовать внутри алгоритма»: нормализация должна быть инвариантом данных, а не локальной эвристикой одного алгоритма.
  3. Дубли таблиц единиц и хелперов. platform/quantity.DefaultUnitScales — канонический дом единиц, но normalizeProvisionerUnitCode, normalizeEnrichmentUnitCode, два разных unitAliases и по два экземпляра looksLikeElectricalValueToken/looksLikeDimensionToken живут копиями.
  4. Envelope нормализованного значения читается строковыми литералами. Продюсер (normalizeCharacteristicValueToSI) собирает jsonb {unit, raw_value, raw_unit, value{amount,precision}, value_canonical, values[], values_canonical}, а 5+ потребителей в catalog/canonical и search/proposal угадывают эти ключи literal-строками.
  5. Электротехника зашита в generic-код. «полюс/ток/А/В/кА» встречаются в generic resolve/rank/match путях, хотя Tracium — мультидоменная платформа.

Решение

  1. Слои. internal/core/<bc>/{domain,app} не импортируют pgx/pgxpool, HTTP-фреймворки, внешние SDK и не содержат SQL-строк. Персистентность — интерфейс репозитория в domain/, реализация в infra/<tech>/. Транзакции app оркестрирует через доменный порт (TxManager/UnitOfWork); pgx.Tx как параметр методов доменного порта репозитория — допустимая граница (outbox-паттерн сохраняется).
  2. HTTP только маршалит DTO. api/http и infra/http парсят запрос в DTO, зовут сервис, маршалят ответ. Никакого парсинга величин, конвертации единиц, коэрции any→float64, эвристик-классификаторов, доменной лексики.
  3. Нормализация — инвариант данных на записи. Приведение величин, единиц и UoM/packing к каноническому виду выполняется в ingestion/charnorm через platform/quantity до записи в read-side; результат хранится; read-side (включая HTTP) читает готовое и не перепарсивает. Packing (шт, м, уп) по ADR-0053 не является характеристикой товара — это отдельная UoM- нормализация, но принцип тот же: нормализуем при записи, читаем готовое.
  4. Generic-код доменно-агностичен. Предметная специфика (электротехника) собрана в один явный модуль за доменным интерфейсом (CharacteristicSemantics), который generic resolver/matcher/analogs получают через DI. Дубли доменных хелперов недопустимы. Data-driven / справочная модель характеристик — следующий цикл (интерфейс — заготовка).
  5. Envelope значения — типизированная структура. Ключи jsonb-envelope нормализованного значения определяются константами в пакете-владельце; доступ по строковым литералам вне него запрещён.

Соблюдение закрепляется линтером backend/cmd/layerlint (CI-job в backend-checks, make-target backend-layerlint): запрет pgxpool/SQL в app/domain, envelope-ключи вне platform/charvalue, доменная лексика вне platform/charsemantics — ошибки; regex/map[string]any-эвристики в HTTP-пакетах — warn. Долг до-ADR зафиксирован в allowlist’ах линтера (сокращать, не расширять). Плюс расширенный backend/.go-arch-lint.yml (platform-{charvalue,pgtx,charsemantics,quantity} по слоям) и пункты 26–30 + PR-чеклист в backend/AGENTS.md.

Альтернативы

  • Оставить преобразования на чтении. Отклонено: каждый читатель платит CPU за парсинг на каждый запрос; представления рассинхронизируются (admin UI, estimate, matcher парсят по-разному); ADR-0053 уже отклонил этот путь; блокирует мультидоменность (каждый новый домен добавлял бы ветки в HTTP).
  • Разрешить SQL в app «для мелких случаев». Отклонено: ADR-0056 показал, что даже on-demand read-model SQL дисциплинированно живёт в infra; исключения размывают правило до неисполнимости.
  • Сразу строить data-driven движок характеристик. Отложено осознанно: сначала изоляция за интерфейсом без изменения семантики (этот рефакторинг), потом конфигурируемая модель отдельным циклом.

Последствия

  • Read-side эндпоинты становятся дешевле (нет regex на каждый item) и консистентными между поверхностями.
  • Новые предметные области подключаются без правок generic-слоёв.
  • Переходный период: packing у старых офферов до backfill’а читается через временный fallback, помеченный TODO.

Ссылки

  • ADR-0052 (backend conventions hardening), ADR-0053 (единицы СИ), ADR-0056 (admin read-model SQL в infra), ADR-0035 (search layering), ADR-0030 (money).
  • backend/AGENTS.md #1, #2, #4, #9, #20, #21 и новые пункты 26–30.
  • План: docs/plans/2026-07-04-arch-layer-normalization-refactor.md.