Платформа управления рекламными кампаниями и биллингом. Написана по Clean Architecture.
- sqlc вместо reflection-based сканирования (
pgx.CollectRows/RowToStructByPos) — ~50% меньше размер аллокаций. - Transactor и Retryer — теперь независимые компоненты: Retryer, как и раньше, конфигурируется глобально, а также теперь возможно настраивать отдельно на конкретный вызов. Transactor теперь умеет во вложенные транзакции, а не только переиспользование внешней.
- Patch — пересмотрено: было частичное обновление через
PatchQueryBuilder(динамическийUPDATEпо присутствующим полям), сталоRMW. - Usecases — общий интерфейс всех use cases исключен, существенно упрощен код, при этом тестируемость не пострадала.
Всего используется 4 слоя: presentation, application, domain, adapter.
presentation и adapters — оба interface adapters (clean architecture / hexagonal architecture), просто с разных сторон ядра (application+domain). presentation — primary/driving adapter: инициирует вызов use case снаружи. adapters — secondary/driven adapter: вызывается самим use case'ом, реализует порт, который application объявил себе сам. У обоих законно есть свои внешние зависимости, а application/domain (ядро) сторонних библиотек не импортируют вообще.
presentation — переводит конкретный транспорт (HTTP) в вызов use case: разбор запроса, статус-коды, формат ответа. Без него бизнес-логика знала бы про *gin.Context и JSON — смена транспорта или добавление второго (gRPC, CLI) требовала бы переписывать use case.
Сюда же относятся фоновые воркеры (консьюмеры очередей, задачи по таймеру) — они тоже primary-адаптеры, а не adapters. Триггер другой (сообщение из брокера, тик таймера вместо HTTP-запроса), но роль та же: инициировать вызов use case снаружи, ничего не решая по бизнес-логике самим. Сама работа с брокером/очередью при этом — отдельный secondary-адаптер.
DTO (Request/Response) — формат провода (имена JSON-полей, omitempty) отделён от внутреннего представления. Это самый часто меняющийся слой — отдавай entity наружу напрямую, любая правка API-контракта задевала бы domain, а любое поле entity (включая то, что не должно быть публичным) утекало бы в ответ.
JSON для Request/Response генерирует easyjson — MarshalJSON/UnmarshalJSON на каждый тип вместо reflection-based encoding/json по умолчанию, тот же принцип, что и sqlc: меньше аллокаций на сериализацию, без ручного кода под каждый тип.
application — один сценарий = один тип. Валидирует форму входа, вызывает domain, координирует репозиторий/транзакции/часы, но не содержит собственных бизнес-правил. Без него оркестрация растекалась бы либо по хендлерам (транспорт знает бизнес-правила), либо по entity (домен знает про транзакции и БД).
DTO (Input) — свой DTO на каждый use case, не общий на несколько. Не просто переименованный Request: часто собирается из нескольких источников (URL + body + auth-контекст). Отдельный тип на use case — чтобы поля могли разойтись по своей причине, не утягивая за собой сценарии, которым это не нужно. Ещё причина — не дать application зависеть от JSON-тегов presentation: прими use case Request напрямую, второй транспорт (gRPC, CLI, воркер) не смог бы его вызвать без чужого HTTP-специфичного типа.
DTO (Output) — не обязателен по умолчанию, use case может возвращать entity напрямую в presentation. Отдельный Output DTO появляется по факту, когда возврат реально должен отличаться от entity — например, урезать чувствительное поле, которое не должно покинуть систему ни при каких обстоятельствах.
domain — правила и инварианты: что делает Advertiser валидным, какие коды стран допустимы. Чистый Go, без внешних зависимостей. Без него инварианты дублировались бы в нескольких use case или проверялись только CHECK-констрейнтом в БД — с риском разойтись.
Почему не классическая трёхслойка (Presentation / Business Logic / Data Access, по Фаулеру). Там весь бизнес-слой — один Business Logic Layer, и правила, и оркестрация сценария в одном месте, хотя у них разные причины меняться: правило (domain) меняется, когда меняется сам бизнес; оркестрация (application) — когда меняется workflow (новый шаг, другой порядок вызовов). Сейчас domain почти пустой (один конструктор с валидацией) — это про текущую простоту бизнеса, не про бесполезность разделения: по мере роста правил (VO, domain-сервисы, инварианты на несколько сущностей) они лягут сюда, не размывая use case, а новые шаги сценариев — в application, не трогая правила. Окупается уже сейчас, не гипотетически: entity.NewAdvertiser — единственная точка создания Advertiser, и CreateAdvertiser, и PutAdvertiser используют один и тот же конструктор — инвариант нельзя обойти в одном сценарии и забыть в другом, а проверяется чистым unit-тестом без единого мока.
adapters — реализуют порты, которые объявляет application/domain, прячут конкретную инфраструктуру (pgx, sqlc-типы) за интерфейсом. Без них application-код напрямую зависел бы от конкретного драйвера — нельзя подменить в тесте, нельзя сменить хранилище без правки бизнес-кода.
DTO (model / Params) — форма БД (колонки, sqlc-типы) отделена от entity. Изменение схемы (новая колонка, другой тип) не должно менять domain — конвертер поглощает разницу в одном месте, а не в каждом методе репозитория.
Model-структуры генерирует sqlc — прямая типизация вместо reflection-based сканирования (pgx.CollectRows/RowToStructByPos) снижает потребление памяти и исключает ручные ошибки маппинга (несовпадение порядка/типа колонки и поля структуры видно на этапе генерации, не в рантайме).
Конвертацию model↔entity там, где ручного кода становится реально много, берёт на себя goverter — меньше писать руками на больших сущностях, и go generate падает, если добавили поле и забыли его сопоставить — ошибка на этапе сборки, а не молчаливая потеря поля в проде.
Изначальный Patch через PatchQueryBuilder собирал UPDATE по присутствующим в запросе полям, а валидацию каждого поля дублировал вручную прямо в use case — то же условие (например «имя не может быть пустым»), что уже проверяет конструктор сущности. Без сущности на руках (точечный запрос ничего не читает перед записью) use case физически не может обратиться к общей проверке иначе как переписав её инлайн — бизнес-правило расползается по двум местам: одна копия в entity.NewX, вторая, отдельная, в use case.
Отсюда решение: валидация живёт исключительно в domain, никогда не дублируется — ни в SQL (WHERE/CHECK вместо доменной проверки), ни повторным Go-кодом в use case. Причины: (1) при смене СУБД правило, закопанное в SQL-тексте конкретной БД, придётся отдельно искать и переносить руками — при валидации только в Go этого риска нет; (2) дублирование в двух местах не даёт преимущества, только двойную стоимость поддержки при изменении правила — больше не значит лучше.
Но и «всё валидировать через один конструктор» — не вариант: часть инвариантов физически бессмысленна в момент создания (например «нельзя уменьшить бюджет ниже уже потраченного» — у новой сущности потрачено всегда 0), предусмотреть все случаи в одном конструкторе невозможно. Поэтому валидация дробится на мелкие функции по полям (IsValidX), которые при необходимости кросс-полевой проверки собираются в более крупные (IsValidXPair) — но вызывают их не use case'ы напрямую, а сеттеры сущности (SetName, SetCountry), которые заодно и присваивают значение. Конструктор не проверяет поля сам — он собирает пустую сущность и вызывает те же сеттеры, что и патч: один код валидации и присвоения на оба вызывающих места, не переписывая логику заново.
Для апдейта сформировано три разных правила:
- Переход состояния (например Pause/Resume) — CAS: один
UPDATEс условием на текущее состояние записи вWHERE, это проверка состояния, не бизнес-правило, законно живёт в SQL. Годится, пока не нужна точная причина, почему обновилось0строк — CAS не отличает «записи нет» от «guard не выполнен» без отдельного запроса. - Общий PATCH (клиент может прислать любое подмножество полей, заранее не известное) по определению не может обойтись без текущего состояния: то, что не прислали, нужно чем-то заполнить — либо значением из
Get, либоCOALESCEв SQL. Дело не в том, зависят ли поля друг от друга, а в самой динамике формы запроса — отсюда RMW. - Операция с фиксированным набором всегда обязательных полей — обычный
UPDATEкорректен и достаточен, даже если среди этих полей есть кросс-полевая связь: она проверяется прямо на входе, без похода в БД.
PUT запрос приведен в соответствие с RFC 7231 - замена состояния ресурса целиком (клиент присылает готовое представление, сущность создается через конструктор), а PATCH запрос приведен в соответствие с RFC 5789 - набор инструкций, как изменить то, что уже лежит в БД (по определению неприменим без чтения текущего состояния, апдейтится через сеттеры).
Изначально Transactor инжектился прямо в репозиторий, чтобы через GetExecutor(ctx) достать транзакцию из контекста или соединение из пула. Название вводило в заблуждение: по имени казалось, что репозиторий через Transactor может и открывать транзакции, хотя реально он только читал уже открытую. Транзакции открываются только в use case.
Нашел и вторую проблему — с ретраями. RunInTransaction сам был обёрнут в ретрай, и при этом каждый метод репозитория внутри транзакции тоже оборачивал свой запрос в отдельный ретрай. Если запрос падал с ретраебельной ошибкой, Postgres переводил всю транзакцию в aborted-состояние — но метод-ретрай этого не знал и продолжал повторять запрос внутри уже мёртвой транзакции, до исчерпания своих попыток. Наружу в итоге уходила ошибка "current transaction is aborted", а не настоящая причина сбоя.
Решение — развести на три порта:
Transactor—RunInTransaction(ретраер передаётся параметром и оборачивает транзакцию целиком) иRunInNestedTransaction(вложенные транзакции через SAVEPOINT).GetExecutorубран — транзактор больше не выдаёт соединение наружу.Retryer— без изменений:Do(повторить переданную функцию) иWith(переопределить глобальные настройки под конкретный вызов).Executor(новое) —Do: достаёт транзакцию из контекста, если она там есть, и просто выполняет операцию; если транзакции нет — выполняет операцию под ретраем сам.
Теперь ретраится либо вся транзакция целиком (её открывает Transactor), либо одиночный запрос вне транзакции (Executor) — но не оба уровня одновременно.
Раньше ID жили строками во всех слоях, а формат проверялся дважды: булевой проверкой в use case и повторным разбором в адаптере, чтобы собрать типизированный параметр для sqlc. Теперь ID — uuid.UUID во всех слоях, а формат проверяется один раз, в presentation, через uuid.Parse (path-параметры и advertiser_id из тела запроса). Адаптеры больше не парсят строки, а конвертеры model↔entity не могут упасть на ID и потому не возвращают error. Response DTO по-прежнему отдают ID строкой — формат провода не должен зависеть от библиотеки.
Это осознанное исключение из правила «ядро не импортирует сторонние библиотеки». Оценка google/uuid проведена по критериям устойчивости зависимости - насколько ей доверяем, насколько она волатильна и насколько легко заменима. У неё нет собственных зависимостей, формат задан RFC 4122/9562, аналога в stdlib нет.
IDGenerator остаётся отдельным портом, хотя uuid теперь разрешён в ядре: он нужен не ради запрета на импорт, а чтобы изолировать недетерминизм (NewV7 — это случайность и текущее время), как Clock изолирует время. Иначе в тесте нельзя получить предсказуемый ID и вызвать ветку ошибки генерации.
Пакет apputil состоял из трёх несвязанных вещей: булева проверка формата UUID, динамический построитель UPDATE и дженерик-тип функциональных опций. Application-слой зависел от «утилит», в которых нельзя было понять по имени, что именно ему нужно. Теперь пакета нет: проверка UUID переехала в presentation и делается через uuid.Parse, построитель ушёл вместе со старым Patch, а дженерик Option[T] заменён конкретным типом RetryOption рядом с единственным потребителем (Retryer) — так же, как в домене работают AdvertiserOption/CampaignOption. Если опции понадобятся другому компоненту, это будет ещё один такой же маленький тип и цикл в конструкторе, а не общая абстракция ради одного вызывающего.
Разъезд на три порта оказался переходным, а не итоговым вариантом. Executor.Do ретраил одиночный запрос, если транзакции в контексте не было — но решение о том, сколько раз повторить операцию, это часть оркестрации сценария, а оркестрация целиком принадлежит application, не adapters. Прятать часть этой логики в репозитории значило решать её не там, где положено.
Executor убран полностью вместе с портом Querier, который под него был нужен. Репозиторий теперь про ретраи вообще не знает — он просто достаёт соединение через getter.DefaultTrOrDB(ctx, pool) и выполняет запрос. DefaultTrOrDB возвращает открытую в контексте транзакцию, если её туда положил Transactor.Do, иначе — сам пул: то же самое раньше делал Executor, но без промежуточного типа и без поля retryer в каждом репозитории.
Transactorстал тоньше: Do (обычная транзакция) и DoInNestedTransaction (через SAVEPOINT). GetExecutor, отдававший соединение наружу, убран.
Ретраи теперь полностью явные в use case, не передаются в качестве параметра, одинаково и для одиночного вызова, и для транзакции: retryer.Do оборачивает либо прямой вызов репозитория, либо весь transactor.Do целиком. Второй случай и есть причина, по которой раньше городился отдельный Executor: ретраить нужно транзакцию целиком, а не отдельный запрос внутри неё — если Postgres переводит транзакцию в aborted-состояние посреди выполнения, повторяется весь сценарий заново, а не мёртвый запрос внутри уже мёртвой транзакции (ошибка «current transaction is aborted» из предыдущей версии).
Сам ретрай-цикл больше не написан руками — Retryer.Do теперь тонкая обёртка над avast/retry-go: подсчёт попыток, backoff с джиттером и агрегация ошибок всех попыток — за счёт библиотеки. Обёртка вокруг неё всё равно нужна по двум причинам: у retry-go нет собственного типа с методом Do — только свободные функции, инстанс для порта взять неоткуда; и конфигурация конкретного вызова выражена через её собственный Option, который не может протечь в application/port — там нельзя импортировать сторонние типы.
Классификация ретраебельных ошибок (IsRetriableError) объединена в одну функцию на все источники — сеть, Postgres, в перспективе HTTP. Это безопасно потому, что каждая проверка внутри использует errors.As по конкретному типу.