Обновить
16K+
18
Владимир Синявский@vsinyavsky

AI-native Software Engineer

Отправить сообщение

затем я и пишу, чтобы кто-то вслепую не наступил на то, о чём даже не знал, что туда можно наступить ))

мне нравится жанр "кейсы с граблями", сам же их перечитывать буду когда в следующий раз столкнусь

не скрываю - для причесывания больших текстов я юзаю ллм, чисто как инструмент

черновик, каждый кейс в нём, структура, рассуждения, выводы, грабли в которые я наступал, примеры кода, комменты под постами, и голова - мои собственные не сгенерированные )
ллм только сглаживает формулировки в длинных кусках - стиль подкручу, спасибо за честный фидбэк

если направленно искать, на хабре можно найти что угодно на любой вкус

Особенно весело, весело когда сообщения приходят не в том порядке

true story

иногда можно завязаться не таймстемпы, но их тоже можно не дождаться или получить кривыми (бывало и такое), но машина состояний работает лучше, когда разрешённые переходы зашиты в домене, и поздний промежуточный статус просто не перезатирает финальный, это то что в грабле CanTransition

Ну вы хоть вычитывайте после генерации (:

да не, это была отсылка к мему же - but for me it was tuesday

да, поиск по бизнес-ключу orderId вместо провайдерского id - к этому мы тоже пришли

провайдерский id появляется поздно и не на всех событиях, а orderId есть с момента создания намерения - outbox+бэкофф закрывают гонку "нотификация пришла раньше коммита"

про идемпотентность по намерению, а не по техническому id, у меня был отдельный разбор https://habr.com/ru/articles/1039860/

А что вы ожидали при начале разработки и интеграции?

ПМ задал четкие ожидания: фокус на запуск в краткие сроки mvp оплат по qr-коду и интеграция с 2мя шлюзами, к дедлайну все основные функции должны быть готовы к подключению первым n мерчантам. Оплаты по qr стали тогда новым направлением для компании, до этого делали только оплаты картой (2022 год).

В разработке и тестах интеграции (с любой внешней системы) изначально нужно опираться на принципы

На этом кейсе я и хочу показать на что мы опирались в начале проекта и как поэтапно трансформировалось наше мышление после столкновения с граблями в реальности. В конце статьи составил список наших инсайтов, на них мы сами опирались при интеграции следующих провайдеров в этом проекте.

Проблема точно не в монолите, а в отсутствии структуры - недавно работал с похожим случаем: 10-летний легаси монолит, горы компонентов , очень слабая структура, незадокументированные решения живущие только в коде, high coupling, low cohesion, старый стек, вот это всё. И на горячую нужно поднять альтернативную версию 60% функциональности ядра на новом стеке. Тоже сталкивались со всеми этими проблемами...

Хочу накинуть тему для дискуссии: архтесты хороши, чтобы поддерживать уже правильно проведённые границы, но архтесты не помогают их найти, и вы сами отмечаете это как пробел. Тут ловушка: как только конфиг зелёный, неудачно проведённая граница застывает в коде - инструмент защищает именно её и переразбить контекст становится дороже, чем было бы без него. На моём опыте изоляция контекстов это заслуга не архтестов, а проектной работы до них (мы границы искали Event Storming'ом). Я бы так и формулировал: сначала найти границы и проверить их на реальных сценариях изменений, а потом цементировать тулами, иначе рискуешь намертво закрепить не ту нарезку.

Про идемпотентность консьюмера через inbox по event_id - да, нужная защита от at-least-once.

На практике я видел ловушку уровнем выше и стоит она дорого: event_id спасает от повторной доставки одного и того же события, но не от двух разных событий с одним бизнес-намерением. Клиент ретраит по таймауту, на write-стороне рождается новый event_id - и inbox честно пропускает его как новый потому что по id он и правда новый. Лечили дедупом ещё и по ключу намерения операции, а не только по id события. По сути это как две идемпотентности на разных слоях: техническая по event_id у консьюмера и смысловая по ключу намерения на входе команды.

Вот это в точку, особенно "агрегаты от сценариев". Уточню только, что границу двигают инварианты, а сценарии их и задают.

И тот самый "единый" агрегат, который по флагам грузится то с одними связями, то с другими и ведёт себя по-разному - по мне это та же протечка, что у меня в посте в сценарии чтения чтения, только уже на записи - одну модель растянули на разные сценарии. Поэтому когда правила расходятся, лучше разводить на модели под задачу, чем гнать всё через один агрегат.

Спасибо, давно в комментах не было такого предметного разговора :)

Разделять доменную модель и модель хранения это ок.

Но проблему, о которой говорит @monco83 это не убирает. Чтобы сохранить, всё равно надо понимать что именно поменялось - или это отслеживает ORM или ты сам сравниваешь и обновляешь или переписываешь весь агрегат целиком. Так что ORM тут по-прежнему важен, просто работа по отслеживанию изменений переезжает в тот код, который копирует данные из домена в модель хранения.

А загрузку всего агрегата ради одной правки, конкурентность и базу на хранимых процедурах разделение моделей не решает вообще - это отдельные задачи.

спасибо, рад что вам зашло ))

Про сохранение агрегата по всем пунктам согласен: load-save агрегат держится на change tracking ORM-ки (как UoW) или атомарном документе (в монге или kv) и вменяемой конкурентности, а на базе с хранимками вообще начинает воевать с инфраструктурой. DDD моделирует домен - агрегат и есть граница согласованности с оптимистичным локом на версию его корня, но только если хранилище в эту версию умеет.

А вот про загрузуку всего поста для того, чтобы добавить один коммент (работа через агрегаты) - стоило бы отдельно поговорить.

С удовольствие поддержу эту дискуссию. Только хочу набросить встречный тезис: по-моему проблема этого примера в том что граница агрегата проведена по вложенности, а не по инварианту. Между постом и комментом нет общего инварианта, который надо проверять на каждом изменении - значит коммент это отдельный агрегат со ссылкой на PostId, и грузить ради него весь пост незачем 🤷‍♂️

Предположу, что это лучше ложится на другой пример: корзина с товарными позициями - вот это наоборот, классический настоящий агрегат - там инвариант есть (итог, лимиты, резерв стока, скидки), поэтому грузишь корзину и позиции целиком. У поста с комментами такого инварианта нет, и связки "корень - часть" тоже нет.

Ну или допустим, повесим на пост правило "не больше n комментов" или кэш счётчика - и инвариант как-будто появлякется, и тут можно подумать дальше... А у вас в проде попадался случай, где он там реально возникал и заставлял загружать целое?

путаницы нет, статья как раз вокруг этой темы

мне очень интересно услышать вашу версию: расскажите подробнее как вы пишете слой данных и не имеете проблем с БД под капотом у доменного репозитория 🤔

мм, понял о чём вы 👌

CQRS в статью я сознательно не добавлял, чтобы не уводить в сторону, а HotelSearchQuery - это не совсем "классический query из cqrs", хотя и называется схоже - просто как демонстрация варианта прочитать напрямую из провайдера БД, в обход Repository, обертка может быть любой

В целом, тут я согласен с вами - опционально, для записи можно написать CommandHandler который внутри будет использовать IBookingRepository с соблюдением всех инвариантов для Booking - они не противоречат друг другу

как в обсуждение репозитория у вас попали команды? про них речи вообще не было

автор смешивает между собой понятия репозитория и объектов типа Query / Command

Что query не должен наследлвать репозиторий - тут согласен, это разные штуки. HotelSearchQuery - это отдельный класс для чтение из БД, а не наследник репозитория. В финале я предлагаю такую концепцию:

  • IBookingRepository - запись делаем через репозиторий с методами по смыслу для конкретно этого агрегата, он не содержит методы для тех действий, которые с ним совершить нельзя

  • HotelSearchQuery - читаем через read-query прямо из провайдера, а не через IRepository<T>, это намеренно

потому что $facet через контракт репозитория невозможно вызвать, с этого примера статья и начинается. Как только читающий query начнёт использовать репозиторий, на чтении вернётся та проблема, ради которой всё и затевалось.

Generic IRepository<T> торчащий наружу методы не допустимые для конкретного агрегата - это то, что я оспариваю. Ну а если юзать в качестве приватной базы под конкретным репозиторием - вопросов нет.

репозиторий разделяет код и задаёт правильное направление зависимостям, а не экономия на переходе с sql на nosql

Так меня и не обманули, в посте я написал в точности это: репозиторий ценен разделением кода и направлением зависимостей, а "экономия на смене БД" - это ложное обещание

Про сигнатуру по кругу не пойду, выше уже отвечал

Так да, в том и суть: "написать новую реализацию" = "переписать все запросы под новый стор", это и есть дорогая часть, а паттерн репозиторий продавали как то, что её экономит! а в nosql даже сигнатура одинаковой не остаётся

Проекты и кейсы на них, каркас статьи, рассуждения, тезисы и выводы, грабли что я видел и наступал сам, решения и ошибки, что я сделал - это всё мой опыт за годы работы в ИТ.

Что касается нейронки - длинные ответы накидываю с ассистентом и правлю, да. Но кейсы, рассуждения и выводы мои, про facet на поиске отелей и мой проект нейронка ничего не знает. Юзаю как инструмент, но голова своя 🙂

это радует, разбор от коллег это одна из целей для чего вообще я начал писать ))

я много лет всё откладывал и прокрастинировал, хотя материалов и кейсов накопилось много, есть о чем рассказать, вот пытаюсь писать так, чтобы было интересно коллегам и читать, и вовлекаться в обсуждение, надеюсь это так )

Если мы говорим о смене одной SQL-базы на другую, то современный EF как правило справляется достаточно хорошо

Про SQL→SQL полностью согласен: переносимость между реляционными базами даёт сам EF, репозиторий тут ничего не добавляет. Вы это на трёх СУБД и показали - правки в основном в миграциях, а остальное EF тащит сам.

Вообще говоря, у SQLite in-memory достаточно высокая совместимость с PostgreSQL, чтобы ее можно было использовать в юнит-тестах вместо живой базы

Про тесты хочу развить мысль. В посте я "ругал" именно EF InMemory и подобные ему: это не реляционный провайдер, нет констрейнтов, своя трансляция, своё поведение на GroupBy. SQLite in-memory - совсем другое, это настоящий SQL-движок с реальной семантикой. Поэтому ваш расклад честный: 99% на sqlite , потому что там настоящий SQL, и 1% на testcontainers. И вот этот 1% как раз должен ловить места, где sqlite и postgres расходятся - collation, отдельные типы, конкурентность. Тот самый разъезд сортировки по collation, на котором я в посте обжёгся, живёт в этом 1%.

Ну а пытаться написать абстракцию, которая одинаково хорошо скрывает как SQL, так и NoSQL-базы, имхо гиблое дело - либо абстракция будет течь, либо это будет работать страшно неэффективно.

Про мысль "сменить источник = написать новую реализацию и подставить" - по-моему, тут про 2 разные вещи. Репозиторий как шов для DI и подмены реализации - полезно, не спорю. Но мечта "сменим базу, домен не заметит" - про другое и она-то и дорогая: новую реализацию написать мало, под новый стор переписываются сами запросы, а часто и модель данных, потому что возможности у сторов разные. Абстракция переносит сигнатуры, а не запросы. Для SQL→SQL это и не нужно (EF справляется), а для SQL→NoSQL, как вы и говорите, она либо течёт, либо работает неэффективно.

Это один из самых полезных комментов, спасибо :)

зачем себя ограничивать генерик репозиторем, когда можно пользоваться обычным))

О, на последней фразе мы как раз сходимся, я ровно это и предлагаю как вывод в финале статьи: вместо Generic Repository<T> взять обычные конкретные репозитории.

Теперь про термины, чтобы не путаться:

репозиторий торчит наружу

окей, тут сказал неудачно. Да, интерфейс репозитория должен быть виден клиентскому коду, иначе он бесполезен. Я не про видимость, а про форму контракта:

  • с одной стороны generic Repository<T> с CRUD по типу T

  • с другой - конкретный IBookingRepository с методами по смыслу

...оба видны клиенту, но первый предлагает один набор операций на все типы сразу, второй - набор конкретных операций к конкретному агрегату. Generic-репо торчит наружу всеми методами, даже теми, которых по смыслу у агрегата не должно быть.

репозиторий перестаёт быть генерик

уточню, чтобы не путать - тут про контракт, от которого зависит клиентский код. Как только он зависит от именованного метода по смыслу, а не от Repository<T> с GetById/Find по любому T, контракт в этой точке уже конкретный. Это и есть ваш "обычный с кучей операций". Я против дженерика по T наружу, не против репозитория как такового.

Тут вы вообще последовательность теряете...

в пункте 1 - ок, скомкал, попробую развернуть:

  • про саму базу - $facet Mongo умеет, считает в один проход

  • про обёртку над базой как "обобщённый хранилищенезависимый репозиторий" - к $facet через неё не подобраться, не протекая провайдером или не выгружая всю выборку в память.

Мой тезис: умеют и движок, и драйвер - не умеет generic-обёртка поверх них. Поэтому в решении мы и спускаемся к драйверу напрямую. Никакого "Mongo → никто → Mongo": база умеет всегда, а нам мешает только обобщённый контракт над ней.

Так что в главном мы с вами заодно. Единственное уточню: запись я веду через конкретный репозиторий, а тяжёлое чтение - мимо него, тонким запросом напрямую к БД. Но это уже деталь поверх того, в чём мы сошлись.

Информация

В рейтинге
225-й
Откуда
Москва, Москва и Московская обл., Россия
Дата рождения
Зарегистрирован
Активность

Специализация

Фулстек разработчик
Ведущий
От 5 500 $
Проектирование архитектуры приложений
Управление требованиями к ПО
Высоконагруженные системы
Управление разработкой
Системная интеграция
Управление людьми
Бизнес аналитика
.NET
Angular
Построение команды