ADR-0064: Чистые слои и нормализация как инвариант данных
Status: accepted Date: 2026-07-05 Deciders: Maxim Belkanov
Контекст
Аудит backend’а (июль 2026) показал системные протечки слоёв, каждая из которых уже нарушает ранее принятые решения:
- 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.md2 и ADR-0056 (даже разовый read-model SQL живёт в infra-репозитории). - Бизнес-логика в HTTP-слое.
offers/api/http/handler.goна каждом чтенииGET /api/v1/admin/offers/supplier-offersregex-парсит packing из сырых атрибутов иname.search/proposal/infra/http/estimate.goсодержит 25+ функций, которые во время HTTP-запроса классифицируют характеристики, извлекают числа из текста и ранжируют аналоги. ADR-0053 прямо отклонил вариант «нормализовать внутри алгоритма»: нормализация должна быть инвариантом данных, а не локальной эвристикой одного алгоритма. - Дубли таблиц единиц и хелперов.
platform/quantity.DefaultUnitScales— канонический дом единиц, ноnormalizeProvisionerUnitCode,normalizeEnrichmentUnitCode, два разныхunitAliasesи по два экземпляраlooksLikeElectricalValueToken/looksLikeDimensionTokenживут копиями. - Envelope нормализованного значения читается строковыми литералами.
Продюсер (
normalizeCharacteristicValueToSI) собирает jsonb{unit, raw_value, raw_unit, value{amount,precision}, value_canonical, values[], values_canonical}, а 5+ потребителей в catalog/canonical и search/proposal угадывают эти ключи literal-строками. - Электротехника зашита в generic-код. «полюс/ток/А/В/кА» встречаются в generic resolve/rank/match путях, хотя Tracium — мультидоменная платформа.
Решение
- Слои.
internal/core/<bc>/{domain,app}не импортируютpgx/pgxpool, HTTP-фреймворки, внешние SDK и не содержат SQL-строк. Персистентность — интерфейс репозитория вdomain/, реализация вinfra/<tech>/. Транзакции app оркестрирует через доменный порт (TxManager/UnitOfWork);pgx.Txкак параметр методов доменного порта репозитория — допустимая граница (outbox-паттерн сохраняется). - HTTP только маршалит DTO.
api/httpиinfra/httpпарсят запрос в DTO, зовут сервис, маршалят ответ. Никакого парсинга величин, конвертации единиц, коэрцииany→float64, эвристик-классификаторов, доменной лексики. - Нормализация — инвариант данных на записи. Приведение величин, единиц и
UoM/packing к каноническому виду выполняется в ingestion/charnorm через
platform/quantityдо записи в read-side; результат хранится; read-side (включая HTTP) читает готовое и не перепарсивает. Packing (шт,м,уп) по ADR-0053 не является характеристикой товара — это отдельная UoM- нормализация, но принцип тот же: нормализуем при записи, читаем готовое. - Generic-код доменно-агностичен. Предметная специфика (электротехника)
собрана в один явный модуль за доменным интерфейсом
(
CharacteristicSemantics), который generic resolver/matcher/analogs получают через DI. Дубли доменных хелперов недопустимы. Data-driven / справочная модель характеристик — следующий цикл (интерфейс — заготовка). - 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.