Pull to refresh

Comments 108

Пишу на PHP. Использую что-то подобное cqrs, что упростило код, сделало его симпатичнее, проще в поддержании. Я не знаю, как на шарпе все реализовано, но у меня один маленький тоненький command bus и ещё один такой же query bus куда я кидаю команды и запросы. Они просто пробрасывают запрос/команду в нужный обработчик. Так я смогу в будущем переключить выполнение команд с основного потока в очередь. Единственное, что мне до сей поры очень плохо ясно, это куда во всей этой схеме пихать валидацию? Мне предлагали сделать что-то типо middleware, где я бы перехватывал команду и валидировал данные в ней. В итоге пришел к самому простому: команда с валидацией реализует интерфейс ValidatableInterface с методом validate и command bus выбрасывал бы исключение при ошибке. А что, если мне нужно проверить одни и те же данные в разных местах? Писать валидаторы для каждого свойства? Может быть есть какой-то толковый материал на эту тему? Можно англоязычный. Да и вообще не тему cqrs, но без es.

Автору спасибо за статью. Собираю по крупицам информацию. Не задумывался, почему может быть плохо использовать query внутри command. Тут был поднят вопрос без ответа на него: что делать с автосгенерированными БД ID? Я полагаю, тут истинно верного решения нет?
Единственное, что мне до сей поры очень плохо ясно, это куда во всей этой схеме пихать валидацию?

Посмотрите еще раз абзац с декораторами. Там пример, как работать с валидацией. Middleware тоже подойдет со следующими ограничениями:

  1. некоторые сущности могут быть вычитана по два раза
  2. транзакционностью придется управлять за границей CQRS-стека

Тут был поднят вопрос без ответа на него: что делать с автосгенерированными БД ID? Я полагаю, тут истинно верного решения нет?

Если у вас синхронный CQRS без шины — возвращайте Id из команды / хендлера и не мучайтесь. Если с шиной, то у вас либо должен быть механизм доставки доменного события «заказ создан» до пользователя, например web sockets. Либо отказ от автогенерируемых Id — создаем Guid'ы на клиенте.
Необязательно до пользователя, достаточно до клиента, а клиентом может быть и, например, контроллер http-интерфейса. То есть контроллер прежде чем отправить команду в шину команд, подписывается на ожидаемое событие.
Так я смогу в будущем переключить выполнение команд с основного потока в очередь.
Сомневаюсь. Придется переделать UI, частино придется переделать валидацию, и, скорее всего, сами обработчики (чтобы ничего не возвращали). Прозрачно «переключить» точно не получится.
Единственное, что мне до сей поры очень плохо ясно, это куда во всей этой схеме пихать валидацию?

В одном из видео от Uncle Bob он как раз отвечает на этот вопрос. Все предельно просто. Вы делаете валидацию там, где она вам на данный момент нужна. Весь фокус в том, чтобы определиться что, почему и как вы собираетесь валидировать. Это "определение" делает Ваше высказывание


А что, если мне нужно проверить одни и те же данные в разных местах?

недействительным. Может быть вы и проверяете одни и те же данные, но контекст уже будет другой.
Например:


  • Приходит запрос (request) который мы трансформируем в query/command-объект. Для инициализации самого объекта валидируем наличие и типы параметров запроса.
  • далее скомпанованный объект доходит до обработчика (command-/query-handler). Для обработки валидируем те же самые параметры, но уже в другом контексте. Идет проверка на соответствие с требованиями бизнес-логики.

ну и тд и тп

Единственное, что мне до сей поры очень плохо ясно, это куда во всей этой схеме пихать валидацию?

Валидацию нужно пихать туда, где вы будете обрабатывать ошибки и отдавать клиенту, то есть в контроллер.


Валидировать команды в command heandlers или middleware не правильно, так как вы не сможете корректно отреагировать на обнаруженные ошибки.


Да, вы можете сделать валидирующий middleware и бросать ValidationExcepton, но это неверно с точки зрения семантики, так как невалидные данные не являются исключительной ситуацией.


Рекомендую посмотреть готовые библиотеки реализующие CQRS, Middleware, Payload.

Смотря что называть валидацией. Минимум три вещи ею называют:
1. Грубая проверка пользовательского запроса: форма, обязательные параметры, их типы, глобальные статические правила
2. Локальные (без обращения к внешним сервисам) бизнес-правила
3. Глобальные бизнес-правила
Соответственно и размещать их нужно на нужных слоях и часто приходится выбирать или выворачивать кишки наружу, или бросать исключения.

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

Допустим, платежная система у нас, приходит запрос на оплату со счёта. В контроллере делать запрос на остаток по счёту?

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


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


Однако, это не отменяет необходимости валидировать данные на доменном слое. Причем валидирование как бизнес правил, так и самих данных. Об этом говорил Вернон. Я не приверженец двойной валидации, но это правильней. И тут каждый выбирает свою истину. Кто-то размазывать валидацию, кто-то концентрирует валидацию в контроллерах, а кто-то использует двойную валидацию.

Исключение само по себе не плохо. Но можно обходиться и без исключений, если их недостатки перевешивают в конкретном случае преимущества. Например, как в Go или в старом добром C.


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


Если говорить о валидации в контексте CQRS, то на уровне контроллера достаточно, по-моему, провалидировать данные запроса на предмет возможности создания команды, а потом передать в шину или хэндлер и ждать не выплюнут ли (исключения, коды возврата или ещё что-то — детали реализации) они её с ошибкой. Контроллер не должно интересовать на каком уровне или слое команда не смогла выполниться и почему.

Это тоже самое, что ValidatorMiddleware. И middleware на мой взгляд предпочтительней декораторов.
Но сути это не меняет. Нужно бросить исключение чтоб прекратить выполнение и поднять сообщение(я) об ошибке наверх в контроллер. Я не в восторге от такого подхода.

Но сути это не меняет. Нужно бросить исключение чтоб прекратить выполнение и поднять сообщение(я) об ошибке наверх в контроллер. Я не в восторге от такого подхода.

В моем примере используется возвращаемый тип Result, а не исключения. Подробно подход описан здесь.

Окей. Вы подмешиваете данных в возвращаемое значение. Тоже метод решения задачи. С таким же успехом можно использовать аргумент out (возвращаемся к разделу ICommandHandler должен всегда возвращать void?).
Можно придумать еще с 10 костылей, но зачем?

Тогда я не понимаю, чего вы хотите. Все возможные варианты не устраивают:)
Только мир конкурентен. В момент валидации деньги есть, а при проводке транзакции денег уже нет.

Есть такой подход в ddd, как ContextValidation. Думаю, его лучше юзать

А ссылочкой не поделитесь?

https://martinfowler.com/bliki/ContextualValidation.html
https://lostechies.com/jimmybogard/2009/02/15/validation-in-a-ddd-world/


Главный посыл таков — "Instead of answering the question, “is this object valid”, try and answer the question, “Can this operation be performed?”.


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

А если на начальном этапе разработки в качестве хранилища взять тот же EF Core, и оперировать его контекстом, как в командах, так и в запросах. А потом, по мере роста нагрузок и усложнения логики — проводить постепенный рефакторинг, устраняя узкие места (с использованием того же Dapper или вообще ADO).
Допустимо ли строить архитектуру таким образом?
Потому что строить чистый CQRS изначально, с отдельным представлением данных для CUD и R — довольно дорого, особенно в условиях, когда даже нет целостного представления о том какой должна быть модель (а это означает постоянный рефакторинг. Много рефакторинга).

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

тут у меня два вопроса — что в вашем понимании "чистый CQRS" и почему введение двух интерфейсов вместо одного для разделения операций чтения и записи вдруг стало дорого?


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


У Грега Янга по поводу всех этих безумств с шинами команд и query bus есть отдельная статья: CQRS, Task Based UIs, Event Sourcing agh!

«Чистый» — это с полноценно раздельной записью и чтением, когда модификация состояния — это EF + DDD с транзакциями, а чтение — Dapper/ADO+DTO.
Я спросил, на счёт того — допустимо ли использование бизнесовых сущностей и контекста БД не только для модификации, но и для чтения на начальных этапах проектирования системы, так как домен там постоянно изменяется по мере уточнения требований. А так же спросил на счёт возможности использования готовой библиотеки в качестве инфраструктурной основы.
допустимо ли использование бизнесовых сущностей

допустимо, но тогда это не CQRS так как доступ к данным организован через один и тот же объект для двух разных типов операций. (причем частенько это загрязняет сущности специфическими для UI вещами вроде геттеров, но это не так страшно в большинстве случаев)


С другой стороны — CQRS это не цель, это инструмент. Если вам так удобно — то все хорошо. Более того, если у вас скажем большая часть приложения это CRUD и есть только маленький кусочек где все не так просто — возможно разделение нужно только там.

Допустимо, но тогда это не CQRS так как доступ к данным организован через один и тот же объект для двух разных типов операций.

Мы довольно долго так думали, пока не нашли QueryableExtension'ы. Кейсы, где нужно возвращать сущности как-то после этого кончились.

Очень удобная штука. Потому я не очень понимаю почему DTO это дорого...

Ну, чисто формально разделять потоки записи и чтения можно где угодно, в том числе, например, на уровне сущности, разбив её на реализации «геттер» и «сеттер» интерфейсов, не допуская только чтобы мутирующие методы возвращали и данные.

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

А если на начальном этапе разработки в качестве хранилища взять тот же EF Core, и оперировать его контекстом, как в командах, так и в запросах. А потом, по мере роста нагрузок и усложнения логики — проводить постепенный рефакторинг, устраняя узкие места (с использованием того же Dapper или вообще ADO).

Я в абзаце «Возвращать из QueryHandler сущности или DTO» примерно это и предлагаю. Что вас смутило?:)
DTO. Потому что на начальном этапе это может быть довольно дорого в плане рефакторинга. Ваш ответ понял, стал чувствовать себя спокойнее)
Вы имеете в виду дорого писать маппинги для Select? Если да, посмотрите в сторону Mapster Queryable Extensions. Вы же в любом случае в JSON сериализуете не сущность, а какие-то агрегированные данные. Зачем тащить из базы ненужные поля, если можно выбрать только то, что нужно?
Дорого поддерживать DTO и SQL, так как схема может ощутимо так измениться в процессе рефакторинга. За этим и спросил по поводу «срезать углы» в этом моменте. А уже после, когда будет уверенность в том, что со схемой всё ок, и мы описали нашу предметную область на столько, на сколько это было возможным — уже «заплатить по счетам» и таки реализовать чтение полностью независимым от домена и EF, возможно переработав API и то, как данные возвращаются из API.
уже «заплатить по счетам»

практика показывает что никто потом не платит.


Тут важный момент — если на момент разработки у вас нет профита от разделения чтения и записи — то может быть профита и дальше не будет? Ну то есть, если все работает и так — для чего вы дальнейшие действия будете делать?


схема может ощутимо так измениться в процессе рефакторинга

Если делать рефакторинг чаще, можно уменьшать объем изменений. Актуализация же DTO в целом не настолько дорогая штука. Скучно — да, но не долго.

BTW у автомаппера также есть возможность не писать явный маппинг

И второй вопрос — что скажете на счёт MediatR в качестве инфраструктуры для CQRS. Там правда всего один интерфейс, но проблема деления на команды и запросы решается нэймингом.

MediatR реализует идею separation of concerns, только вместо декораторов использует Behaviors. Даже используется «фишка» с пустым дженерик-интерфейсом, чтобы указать возвращаемый тип:

public class Ping : IRequest<string> { }

В данном контексте я не вижу разницы: использовать SimpleInjector с декораторами или MediatR. Есть смысл проверить производительность и выбрать, что работает быстрее. SimpleInjector компилирует деревья выражений, а MediatR использует механизм фабрик.
Фабрики там служат сугубо для того, чтобы обернуть вызов IoC контейнера. Вопрос был в том — а нормально ли использовать его как основу для CQRS? API нравится, вроде даёт всё нужное. Правда интерфейс один — IRequest, но можно ведь сделать команды и запросы в разных сборках и следить за тем, чтобы обработчики запросов не изменяли состояния. Что скажете на этот счёт?

Mediatr очень хорошо подойдет. Сделали на нем уже два проекта.
Посмотрите мои ссылки выше и ниже, там статьи от создателя медиатра с хорошей теорией и практикой.

Все сделали на IRequest без сервисного слоя?

Есть несколько сервисов типа LdapServise/AuthService, но всякие бизнес и crud операции напрямую в обработчике.


Вот кстати еще вопрос — а кошерно ли использовать CommandHandler'ы внутри CommandHandler'ов?


Джимми Боггард пишет, что нет и лучше юзать композицию/реализовывать несколько commandHandler'ов в одном классе

Дописал. Все сходятся, что это неудобно. Мы пробовали компоновать QueryHandler'ы. Получается нечитаемо и многословно.

По поводу только одного интерфейса- достаточно завести свой собственный интерфейс и унаследовать от IRequest
Что-то вроде
interface IQuery: IRequestКовариацию не забудьте только, мне с телефона неудобно редактировать

Один интерфейс решает проблему с двумя декораторами, которую я решил с помощью кастинга к делегатам. Это плюс.

Субъективно кажется, что имея IRequestHandler<SomeCommand> и IRequestHandler<SomeQuery> проще запутаться и случайно добавить мутацию в IRequestHandler<SomeQuery>, чем в случае двух интерфейсов: IRequestHandler<SomeQuery>, а в IQueryHandler<SomeQuery>. Это минус.

Библиотека выглядит приятно. Если у вас нет проблем с соблюдением нейминага и код-ревью не в web-морде, а в IDE (чтобы можно было по файлам пробежаться, посмотреть, проверить наличие мутаций), решение вполне имеет право на жизнь.

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

У медиатра ваш прием с ResultHandler'ом не пройдет, поскольку вы меняете тип возвращаемого значения.
Но всякие валидации, транзакции и прочие шаблоны очень здорово залетают в pipeline behavior

Спасибо за статью!
У меня нубский вопрос — а как быть с загрузкой данных во write части? Городить ещё репозиториев для CommandHandler'ов?


Сейчас я в обработчиках использую EF контекст напрямую и при тестировании подменяю хранение на InMemoryStorage (у нас ef core) и вроде всё хорошо, но как-то не по себе.
В общем-то, пример можно посмотреть здесь у Jimmy Bogard'a

Сейчас я в обработчиках использую EF контекст напрямую и при тестировании подменяю хранение на InMemoryStorage (у нас ef core) и вроде всё хорошо, но как-то не по себе.

А что вас не устраивает в таком подходе? DbContext уже реализует репозиторий, зачем городить абстракцию поверх. Можно отвязаться от EF и инжектировать IQueryable<T> через фабрику: dbContext.Set<T>(), но тогда EF не будет кешировать обращения по Id (по-умолчанию кешируются только вызовы метода Find). Это тоже можно исправить, но работа с контекстом — это вообще тема отдельной статьи.

На практике абстракция от ORM создает больше проблем. Если можно говорить о какой-то заменяемости между EF и NHibernate, то остальные, даже если поддерживают LINQ требуют значительного переписывания кода для замены. Так что первая ложь — ORM абстрагирует вас от БД и вы сможете через 5 лет разработки переехать с Оракла на Постргес. Вторая — IQueryable абстрагирует вас от деталей реализации.

Можете поподробнее про первую ложь, пожалуйста?
Сейчас посчитал — реально пять лет писал на MSSQL со старым EF, переехал на постгрес за выходные. Потом была схожая история с переездом с MSSQL на MySQL и на AuroraDb (последнее не особо валидно, поскольку аврора это форк innodb). Возможно, дело в каких-то фундаментальных различиях оракла с постгресом?

сначала хотелось бы поблагодарить за одну из немногих более или менее вменяемых статей по CQRS и DDD. на удивление очень много пишут явный бред. спасибо!


далее к вашему примеру


Но как быть с Id, генерируемыми БД? Например, мы отправили команду «оформить заказ». Номеру заказа соответствует его Id из БД. Id нельзя получить, пока запрос INSERT не выполнен.

а никак. потому как тут явно кто-то забыл один из главных принципов DDD — Persistence Ignorance. Ваша система, разрабатываемая по принципам DDD, по умолчанию "забила" на БД. Идентификатор Entity является неотъемлимым признаком сущности. Нет идентификатора, нет Entity. Независимо от существования или отсутствия БД.
Как только возникает необходимость или зависимость от автогенерированных ID — фэйл, рукалицо и фтопку.

Я имел в виду, что доменная модель смоделирована так: сначала есть «корзина», а в момент оформления появляется «заказ». Заказ создает наша система и сообщает его Id клиенту. Вы знаете альтернативу автогенерируемым БД Id и Guid для сущностей, для которых нет естественных Id (типа ИНН, КПП, паспортных данных и т.д.)?

я не совсем понимаю, почему Вы автогенерируемые Id вместе с GUID/UUID "в кучу скидываете". как раз GUID и есть отличная замена для БД-идентификаторов.
попробуйте ответить самому себе на вопрос: На каком основании Вы в своей системе создаете зависимость от имплементации БД-идентификаторов? кто гарантирует, что со следующим обновлением БД-движка или его заменой останутся те же самые идентификаторы?
это, конечно, действительно толко в ситуации, если вы не пишите свой БД-движок

На основании, что предметная модель не предоставляет естественного Id. Guid гарантирует уникальность за счет математики (хотя и подвержен коллизиям), Id — за счет СУБД. У Эванса, на сколько я помню, про выбор Id написано четко: если есть Id в домене — берите из домена. Нет — положитесь на технические срадства.

кто гарантирует, что со следующим обновлением БД-движка или его заменой останутся те же самые идентификаторы?

Приведите реальный пример обновления СУБД, чтобы слетели первичные ключи?

Guid'ы совсем не бесплатные. Если есть возможность использовать автоинкремент в БД зачем усложнять себе жизнь?
Приведите реальный пример обновления СУБД

конечно, это самый жесткий аргумент :) зачем нам теория если она не подтверждается реальностью сиюминутно и постоянно. https://bugs.mysql.com/bug.php?id=199


бесплатного в этом мире ничего нет ;) но, вот как раз опыт и показывает, что использование автоинкремента из БД есть зло и усложнение жизни. Вы же сами приводите отличный пример, когда ваш бизнес зависит от реализации БД.


А вот насчет Эванса — согласен. Вот только там ничего насчет БД нет. "Технические средства" могут быть генератор GUID для предотвращения коллизий, например. А домен по умолчанию не БД. Ну, если вы, конечно не создаете новый phpMyAdmin

ну а чем технически некий "генератор GUID" (насколько я понимаю, их GUIDы не с неба ангелы передают) и некий "генератор автоинкремента" отличаются? оба зависят от реализации, оба имеют свои странности, оба могут быть забагованы

насчет реализации и багов — не спорю, согласен. попросили реальный пример — получите (см ссылку).


а вот отличаются они тем, зачем и для кого они. автоинкремент — идентификатор строки в таблице. а GUID как идентификатор "вообще". то есть, одно есть решение проблемы БД на уровне БД. догадайтесь с полпинка, с какой проблемой сталкиваются в первую очередь при переезде с MySQL на PostgreSQL?


То есть отличаются они как раз тем на чем держится вся идея DDD. Контекстом и целесообразностью — то бишь доменом.


все, безусловно, в праве делать что и как они хотят, но тогда не удивляйтесь, если дойдете до ситуации: заказ есть, но его нет, потому что мы не сохранили в БД то, что было или вообще то не было.


соглашусь со следующей реализацией, хотя и полный бред:
для инициализации сущности (Entity) Вы делаете запрос в БД на доступный автоинкремент. Получаете сий идентификатор. И только после этого "сохраняете" Entity.
Если же у вас есть момент, когда Entity "ждет" (те он уже есть как некий объект) свой идентификатор — ищите прокол в Вашей архитектуре. Такая ситуация по DDD не имеет места быть

Там нет той проблемы, которую вы описываете здесь, существующие идентификаторы остаются те же самые.

Автоинкремент это способ генерации уникального числового id. Integer такой же идентификатор «вообще», как и GUID.

Если Вы по поводу приведённого мною примера, то должен вас огорчить. Да, частично вы правы: пока сущности существуют ничего страшного не произойдет. Но


  1. Проблема была в завязке идентификации сущностей посредством автоинкремента из БД. На что я сказал, что при обновлении БД-движка может измениться подход самого движка к автоинкременту.
  2. По данной ссылке как раз видно, что я имел в виду. С данным "багом" БД использовала id заново. Надеюсь, Вам не надо разъяснять в чём тут проблема с точки зрения DDD?
  3. ID совершенно не то же самое что есть GUID. Дело в том, в каком контексте эти идентификаторы являются таковыми. Исходя из приведенного мною выше примера, должно стать понятным, что ID используется БД и только ей. Разрабов БД ну ни в коем случае не интересуют проблемы использования их технических решений не по назначению. Надеюсь, что и это не надо разъяснять
1. Так сам подход к автоинкременту не изменился. Как увеличивалось на 1, так и увеличивается. И id не изменятся после обновления. Проблема была в использовании ключей из одной таблицы в другой. Автоинкремент имеет смысл в той таблице, к которой он прикреплен. Видимо потому и исправили только через 14 лет. Но я согласен, что сохранение можно было сразу сделать, это более логично.

2. У генератора случайных GUID при неправильной реализации может быть такой же баг, если seed при запуске устанавливается в одно и то же число. И нет, у сущностей, хранящихся в этой таблице, не было одних и тех же id.

3. Автоинкрементный целочисленный первичный ключ используется БД точно так же, как и любой другой неинкрементный. Счетчик инкремента это отдельная сущность, в некоторых СУБД она отдельно от таблицы (SEQUENCE), а в MySQL связана с ней и снаружи недоступна.

К 1. Нет. В этом "баге" речь о том, что после затирания записи из таблицы значение id используется БД-движком при сохранении новой строки (reuse). А это есть совсем другой подход к автоинкременту как идентификатору. Что на уровне БД вполне допустимо.
К 2. Тут речь не о том, что баги возможны, а о том что вообще в каком контексте будет багом и какие последствия от них будут. См 1
К 3. И опять, речь абсолютно о другом.

1. Ну и пусть используется. Той сущности уже нет, и того id уже нет. Как будто его и не было. Вы же сами сказали «Нет идентификатора, нет Entity».
2. Так точно тот же контекст и те же баги. В одном случае вы запрашиваете генератор, он возвращает 128 бит, в другом вы тоже запрашиваете генератор, он возвращает 32 бита.
3. Так нет принципиальной разницы. И то и другое это первичный ключ, и то и другое надо генерировать. Либо приведите конкретные примеры, показывающие разницу.
1. Переипользование ключей может привести к неверным ссылкам из других сущностей, а не к битым. А битые вполне могут допускаться моделью.
Если проверки по foreign key нет, то целостность может быть нарушена и другими способами.

Битые ссылки это настолько же неправильно, как и использование указателей после освобождения памяти. Никого же не удивляет, что эту память могут отдать другим данным при следующем вызове new.

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

Битые указатели тоже можно так использовать. Только так никто не делает, потому что переиспользование оперативной памяти это нормально.

Сущность не исчезает, а перемещается, поэтому нельзя сказать, что сущности нет. Если она перемещается за пределы БД, то нужно хранить хотя бы значение автоинкремента последней внешней записи.

Об этом же случае пишут в комментариях к багу. Там тоже данные перемещаются в архивную таблицу. То есть проблема не в самом сбросе счетчика, а в том, что он должен быть общим для двух хранилищ. Просто в MySQL такого нет. Допустим, в одной таблице есть записи 1,2, а запись 3 перемещена в архивную таблицу. Счетчик в первой вполне может сбрасываться на 4.

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


Проблема именно в сбросе счётчика. Он отдаёт одинаковые значения при повторном обращении.

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

Ну так пусть отдает. Главное чтобы прежние значения нигде не использовались. Какие проблемы будут, если мы, скажем, откатом транзакций увеличим счетчик до 10, а потом сбросим на 4? Но я согласен, что сохранять счетчик решение более простое и логичное.

Контексту ничего неизвестно о какой-то общей модели, но если в требованиях говорится, что идентификатор сущности должен быть уникальным в рамках контекста и этот идентификатор входит в публичный интерфейс, то он не должен повторяться, если явно не сказано обратного. Если сказано, что он должен монотонно увеличиваться на 1, начиная с 1, то тоже недопустимо создание двух сущностей с одним идентификатором, даже если одновременно они не существуют.

Вы о чем? В этой статье речь о CQRS и DDD. Судя по Вашим комментариям, Вы ну ни разу не поняли, что такое Entity.
Хозяин, конечно, барин, но с таким подходом могу Вам и Вашим проектам только принести свои искренние соболезнования и пожелать граблей с черенком помягче. Потому как наступать Вы на них будете не раз.

Я про ваш пример с багом. Если он не относится к статье, зачем вы его привели? Если у вас была причина его привести, почему я не могу его обсуждать?


Судя по Вашим комментариям, Вы ну ни разу не поняли, что такое Entity.

Вас конечно же не затруднит привести конкретные цитаты с указанием противоречий?
Я говорю про Entity в логическом смысле, как про объект, имеющий первичный ключ и состояние. И о том, что способ генерации первичного ключа никак на нее не влияет.


Доказательств, почему "ID совершенно не то же самое что есть GUID", я так понимаю не будет.

ладно, еще раз пережуём
В статье шла речь о том, что, используя БД-генеририванный ID, сталкиваются с трудностями (ID генерируется только после сохранения записи в БД). На что я сказал, что, придерживаясь принципов DDD (persistence ignorance) такие проблемы не возникают. К тому же БД-идентификаторы не предусмотрены для использования вне контекста самой БД. Автор не согласился и запросил пример, когда обновление БД-движка может отразиться на бизнес-логике системы. Мой пример "бага" с повторным использованием ID как раз и показывает такой фэйл.


Вас конечно же не затруднит привести конкретные цитаты с указанием противоречий?

Если Вы про Ваши высказывания.


Ну и пусть используется. Той сущности уже нет, и того id уже нет

Повторное использование ID подразумевает, что у вас может появиться Entity с тем же самым идентификатором. А этого по определению не может быть.
Так как не похоже, что Вы понимаете о каком контексте идет речь в DDD, попробую объяснить на примере.
Родился Вася. При рождении он был зарегистрирован в системе в которой использовались ID генерированные БД. Васю зарегистрировали под номером 5. Деревня была маленькая. Вася пожил какое-то время и умер. Чтобы не заморачиваться его учетную запись стерли. С глаз долой, из сердца вон.
Через некоторое время родилась Маша. Её так же зарегистрировали. Угадайте какой номер получила она. Да — 5.
Маша подросла и пошла получать паспорт. Но выдали ей только похоронку.

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


Разница между ID и GUID видна хотя бы уже в самом "наименовании". GUID — globaly.
Ещё раз, используя ID из БД Вы работаете против принципов DDD в любом случае.

ладно, допустим DDD не про все эти контексты, единый язык и тд, а про технические вопросы…
ну так у вас persistence ignorance и при этом вы рассказываете истории про хранение данных о маше и васе в реляционной бд… одна история интереснее другой.
это получается на каком-нибудь редисе и при отсутствии в языке/окружении генератора уникальных айди у вас "DDD" не взлетит?

К тому же БД-идентификаторы не предусмотрены для использования вне контекста самой БД.

Вот это утверждение и вызывает вопросы. Почему вы так решили? Int такой же первичный ключ, как и GUID.


Мой пример "бага" с повторным использованием ID как раз и показывает такой фэйл.

Не показывает. После этого обновления в существующей бизнес-логике не поменяется ничего.


Повторное использование ID подразумевает, что у вас может появиться Entity с тем же самым идентификатором. А этого по определению не может быть.

Так а как определить, что это тот же самый идентификатор, если сущность исчезла и идентификатор исчез и следов уже нигде нет. Вас же не смущает, что оператор new может вернуть адрес, который ранее использовался для ссылки на объект, но был освобожден.


Маша подросла и пошла получать паспорт. Но выдали ей только похоронку.

С чего это, если о Васе никаких записей не осталось?


Да, я понимаю, что Вы сейчас найдете достаточно технических решений, чтобы предотвратить подобную ситуацию

Вы понимаете неправильно. Ее не надо предотвращать, она просто не случится в правильно построенной системе без битых ссылок. И неважно, будет в ней автоинкремент или нет.
В баге с MySQL проблема именно в битых ссылках. Ее можно решить маленькой вероятностью повторения ссылки, общим инкрементом на обе таблицы, отключением сброса в виде сохранения значения инкремента, или как-то еще. Общий/сохраняемый инкремент, кстати, дает 100% вероятность неповторения, в отличие от GUID.


Разница между ID и GUID видна хотя бы уже в самом "наименовании". GUID — globaly.

И все, слово в названии это единственный ваш аргумент? Я возьму и скопирую себе в БД какой-нибудь GUID. Все, он уже не глобальный. Или автоинкремент 128-битный сделаю. Все, он сможет адресовать больше объектов, чем GUID, в котором пропуски.
А повторение высказывания "еще раз" аргументом не является.

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

Кроме системы есть внешний мир, о котором система ничего не знает. И там вполне могут оставаться битые ссылки. И при обращении к системе за ними она должна отдавать ошибку типа NotFound минимум, а не отдавать другую сущность.

Хм, при такой постановке вопроса согласен. Сброс счетчика неприемлем.
С другой стороны, битая ссылка это почти то же что и неправильная ссылка. Допустим, мы распечатали номер заказа, но потом удалили его. Если мы обратимся по этому id, то теоретически должны получить NotFound, если id не переиспользуются. Но допустим, распечаталось плохо, мы вводим другую цифру, и получаем другую сущность. В чем разница между этими ситуациями? Почему мы одну должны контролировать, а другую нет?

Если данные ушли во внешний мир, то мы не можем удалять этот идентификатор из системы. Тогда и битых ссылок не будет. А если удаляем, то считаем, что все внешние ссылки стали неправильные.
Ещё раз, используя ID из БД Вы работаете против принципов DDD в любом случае.

В какой-то мере это справедливо только в случае, если мы не можем получить значение счётчика перед или при создании сущности. По сути это справедливо именно для автоинкремент полей в СУБД, в которых единственный способ получить значение идентификатора для невставленной записи без конфликтов, это вставить её. Хотя можно и обойти костылями типа предварительной записи фейковой сущности, либо созданием псевдопоследовательности на базе таблицы с единственным автоинкрментным полем. Но для приложения это не будет нарушением persistence ignorance, просто у нас будет сервис или метод в сервисе генерации нового идентификатора для новой сущности. Будет внутри него база, генератор UUID, генератор рандомных чисел, функция хэширования или иного вычисления, или даже пользовательский ввод — деталь реализации, скрытая за абстракцией.

Если же у вас есть момент, когда Entity "ждет" (те он уже есть как некий объект) свой идентификатор — ищите прокол в Вашей архитектуре. Такая ситуация по DDD не имеет места быть

Считаю, что Entity ждать может, например в конструкторе, не могут ждать клиенты Entity, уже получив ссылку на неё.

согласен безоговорочно.
но вот только что описанная Вами ситуация не совсем подходит как пример. в Вашем примере нет момента "ожидания". Entity "начинает существовать" с того момента как получает ИД. То есть вполне возможно породить Entity без содержимого, но не без ID.
В ситуации жесткой подвязки к БД "пустой" Entity создать получится только в том случае, если БД позволяет сохранять пустые записи.

Можно разделять собственно создание объекта, как элемента ЯП, и начало жизни сущности в этом объекте. Грубо, после entityDraft = new Entity(data), это просто технический объект, у которого есть какие-то "неопределенные" данные и поведение, не имеющие значения в предметной области, а вот после entityDraft.save(); entity = entityDraft, где в числе прочего присваивается и ид, это уже объект, представляющий сущность бизнес-модели. Да, несколько запачкивает код, да, может понадобиться разделение одной бизнес-транзакции на несколько транзакций БД, но принципов DDD такая точка зрения не нарушает.

С точки зрения DDD — Entity или есть или его нет. Целиком и полностью. Никаких танцев с бубном не подразумевается.
Грубо говоря Ваше "тех-решение" — надругательство над DDD схожее с изнасилованием.


а вот после entityDraft.save(); entity = entityDraft, где в числе прочего присваивается и ид, это уже объект, представляющий сущность бизнес-модели

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

надругательство над DDD схожее с изнасилованием.

но DDD плевать насколько у вас модель изолирована, чиста и все такое. Оно как бы подразумевается что нам по хорошему нужен persistence ignorance, или cqrs, или еще чего. Это все не важно и не требуется что бы говорить что "я юзаю DDD". Достаточно просто что бы драйвером для дизайн решений была предметная область. То есть это и выделение контекстов и все что является сложным.


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

Не стоит так все упрощать.

С точки зрения DDD — Entity или есть или его нет. Целиком и полностью.

Ну так она или есть, или нет. Просто момент "есть" начинается не с new Entity(), а с entity.save(), которую вполне можно обернуть в, например, фабричный метод, чтобы не усложнять создание сущности клиенту.


В DDD оно не то что не нужно, ему просто нельзя быть.

DDD не регламентирует технические способы создания сущностей.


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

Фабричный метод, отдельный класс фабрики или какой-нибудь билдер для создания сущностей вполне ложится на DDD, а будет там вызываться генератор UUID, "чистая" последовательность PostgreSQL или автоинкремент MySQL, путём вставки записи — деталь реализации.


Вообще DDD не требует применения ООП в общем случае, пускай и декларируется, что ООП лучше из других популярных парадигм подходит для реализации модели.

ООП лучше из других популярных парадигм подходит для реализации модели.

я не видел подобных утверждений. Мне кажется тут больше все в типы упирается.

"Парадигмы моделирования и средства программирования" в главе 3 части 1 "DDD" Эванса (Вильямс, 2011).


Можно трактовать и по другому, но для себя сделал вывод, что автор DDD рекомендует в основном ООП использовать. Ну или Пролог :)

F# мультипарадигменный язык, с объектами вполне нормально работает. Думаю, что хорошо реализованная функциональная составляющая позволит не использовать объекты в некоторых случаях, в которых на языках типа Java или C# никуда.


То, что в C#/Java прежде всего :)

То, что в C#/Java прежде всего :)

То есть то, что в C++. Но это уже дискуссия не на ту тему. Это все не очень относится к DDD.

Не очень, да. В целом DDD мало зависит от языка, пока язык позволяет реализовывать элементы DDD достаточно выразительно, без ситуации "за деревьями не видно леса".


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

Кстати, не хотите подлелиться опытом в DDD в комментариях здесь? Я как раз затронул тему «прикладного» DDD в рамках ООП-парадигмы.
Можно разделять собственно создание объекта, как элемента ЯП, и начало жизни сущности в этом объекте.

Но зачем?


а вот после entityDraft.save()

то есть мы все же не делаем persistence ignorance? Опять же, могу представить такое только в силу ограничения выбранных инструментов, например всякие эктив рекорды и прочее.


Но в целом я согласен с тем что все это к DDD никакого отношения не имеет и никак не конфликтует.

Но зачем?

А он и так обычно разделён. По крайней мере в языках, где я глубоко вникал в основные механизмы создание объектов. Конструктор уже получает готовый указатель this/self/..., объект уже существует в терминах ЯП, просто на него ещё нет внешних ссылок, а его свойства в лучшем случае проинициализированы статическими дефолтными значениями. А в том же JS конструктор без вяских выкрутасов может вернуть совсем другой объект, не тот что пришёл к нему в this.


то есть мы все же не делаем persistence ignorance?

На практике save() будет спрятана за каким-то интерфейсом типа EntityId::createIn(Entity entity), а он, возможно, за фабрикой типа Entity::create() с кодом типа entity = new self; EntityId::createIn(entity); return entity;


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

А он и так обычно разделён.

ключевое отличие — отсутствие внешних ссылок.


преодолеть ограничения выбранных или потенциальных инструментов

тогда логично.

С guid'ами проще, например, мержить две таблицы в одну либо данные с двух разных бд. Был реальный кейс объединения двух клиентских баз

Делал два ключа в таблицах, автоинкрементный как PK в рамках сущности и uuid как глобальный. Одно из требований бизнеса были человекчитаемые (точнее диктуемые :) ) ссылки. Да и требование глобальности гораздо позже появилось.
Надо заметить, что естественные id это такие же искусственные id, только для некомпьютерных баз данных, то есть для использования вне компьютера. Причем обычно они тоже имеют инкрементальную природу. Так что нет смысла их противопоставлять или считать естественные id чем-то особенным.

У меня есть мысль, что id это адрес в логической оперативной памяти. Соответственно, создание идентификатора это аналог операции new. Можно использовать большое пространство и выбирать случайную ячейку (GUID), можно выделять память последовательно (autoincrement).

Где-то в UserRepository делаем метод UserId generateNextId() и уже в реализации решаем что конкретно будем использовать, guid, serial, autoincrement и т. д. Можно cделать сигнатуру конструктора User типа (?UserId id, ?UserRepository repo) и в нём код типа this.id = id ?: repo. generateNextId()

Я надеюсь, что вы это не серьёзно имеете в виду. У вас Entity зависит от Repository — не по фэншую.

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

очень печально. а по вашему конструктор составная часть чего? то, что Вы не "храните" параметр конструктора в сущности еще не отменяет её зависимость от таковых. посмотрите ещё раз, что подразумевается под зависимостями.

Какой смысл делать это в конструкторе сущности? Это ж усложняет и тесты и вообще… Да и вы же сами прописали на уровне интерфейса что вы уже передаете готовый ID. это может быть как UUID так и следующий элемент секвенса и это уже можно спрятать в фабрику.

UserId передается сверху, потому сущность от инфраструктуры не зависит. Как был получен этот UserId — дело третье.

если Вы это как комментарий к тому, что написал я, то посмотрите еще раз. Там в конструкторе вторым параметром идет репозиторий

да, что-то проглядел. Тогда действительно как-то некрасиво.

спасибо за отличную статью!
Интересует ваше мнение на следующий вопрос. В Clean architecture Роберта Мартин одним из основных элементов является класс Interactor, идею которого он позаимствовал у Якобсона. Согласно Мартину Interactor — это класс, содержащий верхнеуровневую логику приложения и реализующий конкретный пользовательский сценарий.

На первый взгляд от хэндлеров мы можем получить те же преимущества, что и от interactor: разделение на vertical slices и приблизиться к «кричащей архитектуре», когда просматривая команды и хэндлеры будет явно понятен способ взаимодействия с приложением.

Есть ли на ваш взгляд принципиальная разница между Interactor и Handler? И как мы должны реализовать Handler, чтобы получить все преимущества идеи, заключенной в Interactor?
Я заметил, когда примерно одна идея приходит в голову нескольким умным людям, умные люди не всегда смотрят по сторонам и пытаются что-то классифицировать. Если капнуть еще глубже, все это уходит в принципы ФП, которые были до интеракторов и слайсов. Диаграмма Мартина мне кажется сложной, а Боггарда — простой. Поэтому слайсы мне нравятся больше. А какая там принципиальная разница — не знаю, лучше у них спросить.
Возможно нашел ответ на свой же вопрос. В статье Clarified CQRS Udi Dahan подчеркивает ключевую составляющую CQRS:

A core element of CQRS is rethinking the design of the user interface to enable us to capture our users’ intent such that making a customer preferred is a different unit of work for the user than indicating that the customer has moved or that they’ve gotten married.


Т.е. команды должны быть, что называется, fine-grained чтобы обеспечить масштабируемость и не потерять из-за ошибок параллелизма.

UseCase Якобсона как прародитель Interactor'a Мартина описывает как актор(человек или система) достигает цели с использованием нашего ПО. А это может включать в себя выполнение нескольких операций, что как раз может вызвать ошибки при паралелльном выполнении.
Sign up to leave a comment.

Articles

Change theme settings