Владимир Синявский@vsinyavsky
AI-native Software Engineer
Информация
- В рейтинге
- 225-й
- Откуда
- Москва, Москва и Московская обл., Россия
- Дата рождения
- Зарегистрирован
- Активность
Специализация
Фулстек разработчик
Ведущий
От 5 500 $
Проектирование архитектуры приложений
Управление требованиями к ПО
Высоконагруженные системы
Управление разработкой
Системная интеграция
Управление людьми
Бизнес аналитика
.NET
Angular
Построение команды
затем я и пишу, чтобы кто-то вслепую не наступил на то, о чём даже не знал, что туда можно наступить ))
мне нравится жанр "кейсы с граблями", сам же их перечитывать буду когда в следующий раз столкнусь
не скрываю - для причесывания больших текстов я юзаю ллм, чисто как инструмент
черновик, каждый кейс в нём, структура, рассуждения, выводы, грабли в которые я наступал, примеры кода, комменты под постами, и голова - мои собственные не сгенерированные )
ллм только сглаживает формулировки в длинных кусках - стиль подкручу, спасибо за честный фидбэк
если направленно искать, на хабре можно найти что угодно на любой вкус
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 не должен наследлвать репозиторий - тут согласен, это разные штуки. HotelSearchQuery - это отдельный класс для чтение из БД, а не наследник репозитория. В финале я предлагаю такую концепцию:
IBookingRepository - запись делаем через репозиторий с методами по смыслу для конкретно этого агрегата, он не содержит методы для тех действий, которые с ним совершить нельзя
HotelSearchQuery - читаем через read-query прямо из провайдера, а не через IRepository<T>, это намеренно
потому что $facet через контракт репозитория невозможно вызвать, с этого примера статья и начинается. Как только читающий query начнёт использовать репозиторий, на чтении вернётся та проблема, ради которой всё и затевалось.
Generic IRepository<T> торчащий наружу методы не допустимые для конкретного агрегата - это то, что я оспариваю. Ну а если юзать в качестве приватной базы под конкретным репозиторием - вопросов нет.
Так меня и не обманули, в посте я написал в точности это: репозиторий ценен разделением кода и направлением зависимостей, а "экономия на смене БД" - это ложное обещание
Про сигнатуру по кругу не пойду, выше уже отвечал
Так да, в том и суть: "написать новую реализацию" = "переписать все запросы под новый стор", это и есть дорогая часть, а паттерн репозиторий продавали как то, что её экономит! а в nosql даже сигнатура одинаковой не остаётся
Проекты и кейсы на них, каркас статьи, рассуждения, тезисы и выводы, грабли что я видел и наступал сам, решения и ошибки, что я сделал - это всё мой опыт за годы работы в ИТ.
Что касается нейронки - длинные ответы накидываю с ассистентом и правлю, да. Но кейсы, рассуждения и выводы мои, про facet на поиске отелей и мой проект нейронка ничего не знает. Юзаю как инструмент, но голова своя 🙂
это радует, разбор от коллег это одна из целей для чего вообще я начал писать ))
я много лет всё откладывал и прокрастинировал, хотя материалов и кейсов накопилось много, есть о чем рассказать, вот пытаюсь писать так, чтобы было интересно коллегам и читать, и вовлекаться в обсуждение, надеюсь это так )
Про SQL→SQL полностью согласен: переносимость между реляционными базами даёт сам EF, репозиторий тут ничего не добавляет. Вы это на трёх СУБД и показали - правки в основном в миграциях, а остальное EF тащит сам.
Про тесты хочу развить мысль. В посте я "ругал" именно EF InMemory и подобные ему: это не реляционный провайдер, нет констрейнтов, своя трансляция, своё поведение на GroupBy. SQLite in-memory - совсем другое, это настоящий SQL-движок с реальной семантикой. Поэтому ваш расклад честный: 99% на sqlite , потому что там настоящий SQL, и 1% на testcontainers. И вот этот 1% как раз должен ловить места, где sqlite и postgres расходятся - collation, отдельные типы, конкурентность. Тот самый разъезд сортировки по collation, на котором я в посте обжёгся, живёт в этом 1%.
Про мысль "сменить источник = написать новую реализацию и подставить" - по-моему, тут про 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": база умеет всегда, а нам мешает только обобщённый контракт над ней.
Так что в главном мы с вами заодно. Единственное уточню: запись я веду через конкретный репозиторий, а тяжёлое чтение - мимо него, тонким запросом напрямую к БД. Но это уже деталь поверх того, в чём мы сошлись.