Skip to content

Latest commit

 

History

174 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AdPace

Платформа управления рекламными кампаниями и биллингом. Написана по 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), которые заодно и присваивают значение. Конструктор не проверяет поля сам — он собирает пустую сущность и вызывает те же сеттеры, что и патч: один код валидации и присвоения на оба вызывающих места, не переписывая логику заново.

Для апдейта сформировано три разных правила:

  1. Переход состояния (например Pause/Resume) — CAS: один UPDATE с условием на текущее состояние записи в WHERE, это проверка состояния, не бизнес-правило, законно живёт в SQL. Годится, пока не нужна точная причина, почему обновилось 0 строк — CAS не отличает «записи нет» от «guard не выполнен» без отдельного запроса.
  2. Общий PATCH (клиент может прислать любое подмножество полей, заранее не известное) по определению не может обойтись без текущего состояния: то, что не прислали, нужно чем-то заполнить — либо значением из Get, либо COALESCE в SQL. Дело не в том, зависят ли поля друг от друга, а в самой динамике формы запроса — отсюда RMW.
  3. Операция с фиксированным набором всегда обязательных полей — обычный UPDATE корректен и достаточен, даже если среди этих полей есть кросс-полевая связь: она проверяется прямо на входе, без похода в БД.

PUT запрос приведен в соответствие с RFC 7231 - замена состояния ресурса целиком (клиент присылает готовое представление, сущность создается через конструктор), а PATCH запрос приведен в соответствие с RFC 5789 - набор инструкций, как изменить то, что уже лежит в БД (по определению неприменим без чтения текущего состояния, апдейтится через сеттеры).

Transactor/Retryer — пересмотренный подход

Изначально 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) — но не оба уровня одновременно.

Идентификаторы — uuid.UUID, валидация один раз на входе

Раньше 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

Пакет apputil состоял из трёх несвязанных вещей: булева проверка формата UUID, динамический построитель UPDATE и дженерик-тип функциональных опций. Application-слой зависел от «утилит», в которых нельзя было понять по имени, что именно ему нужно. Теперь пакета нет: проверка UUID переехала в presentation и делается через uuid.Parse, построитель ушёл вместе со старым Patch, а дженерик Option[T] заменён конкретным типом RetryOption рядом с единственным потребителем (Retryer) — так же, как в домене работают AdvertiserOption/CampaignOption. Если опции понадобятся другому компоненту, это будет ещё один такой же маленький тип и цикл в конструкторе, а не общая абстракция ради одного вызывающего.

Transactor/Retryer — Executor исключён, ретраи полностью в use case

Разъезд на три порта оказался переходным, а не итоговым вариантом. 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 по конкретному типу.

About

Ad platform with real-time billing in Go: campaign/budget management, event ingestion and spend enforcement via Kafka, with YQL analytics on YTsaurus.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages