Дисклеймер: все нижеописанное относится к использованию ООП в бизнес-приложениях и я бы хотел вынести за скобки применение ООП для описания типов вроде int, string и т.д.
Концепцию ООП часто применяют для отражения объектов реального мира в коде программ. Так объект реального мира "Пользователь" может быть описан в коде как класс "Пользователь", у которого есть поля: имя и адрес электронной почты, а действия, которые можно совершить над объектами реального мира, превращаются в методы класса: назначить администратором, удалить учетную и т.д.
public sealed class User
{
public string Email { get; set; }
public string Name { get; set; }
public void Delete()
{
...
}
public void SetAsAdministrator()
{
...
}
}
В простых учебных примерах такой подход позволяет познакомить начинающих программистов с основными инструментами ООП. Но когда дело доходит до более сложных примеров, кол-во методов в классе растет, а с ними и количество строк кода — код становится сложно читаемым. Но, главное, связь между объектами реального мира, когда изменение одного объекта влечет за собой изменение другого (удаление учетной записи пользователя, приводит к удалению учетной записи из всех списков где она была отмечена), сложно отразить в коде одного класса. Для меня апофеозом такого подхода стал проект eShopOnContainers, когда для решения, казалось бы, простой задачи, которая была решена уже тысячи раз, приходится писать огромное кол-во строк кода. Связь того, как изменение состояния одного объекта влияет на другой объект, прослеживается не явно, через очередь событий и несколько классов. Кол-во зависимостей, требующихся для инстанцирования одного класса, растет, что приводит к сложности использования такого класса как в приложении, так и в тестах.
Вдобавок к этому, в современных системах практически отпала потребность хранить состояние объектов в приложении, все состояние хранят специализированные системы: базы данных или внешние сервисы (которые в итоге хранят его в БД). Задача многих приложений сводится к отображению состояния сущностей в разных проекциях и предоставлению методов изменения состояния с учетом правил предметной области этих приложений. В общем виде это можно представить как методы получения данных для отображения части состояния объекта и методы для изменения состояния. Описание и хранение самого состояния в приложении не требуется.
В классах-проекциях (Data Transfer Object), которые передают данные между слоями приложения, не требуется иметь методов изменения состояния. Тогда возникает вопрос, где же хранить методы, позволяющие получить состояние или изменять его? Мы в Retail Rocket пришли к решению сделать по одному классу на каждую такую задачу. Те классы, которые относятся строго к одному объекту, можно сгруппировать в папке с именем этого объекта, а те, что, например, меняют состояние более одного класса, положить в папку с именем Services.
Зачем же тогда нужен ООП, если все приложение — это набор функций? Для локализации границ зависимостей функции. ООП позволяет элегантно описать зависимости, которые требуются "функции".
Вот пример "функции", которая меняет состояние системы
internal sealed class RemoveVerificationRequestHandler
: IRemoveVerificationRequestHandler
{
private readonly IMongoCollection<VerificationRequest> verificationRequestCollection;
public RemoveVerificationHandler(
IMongoCollection<VerificationRequest> verificationRequestCollection)
{
this.verificationRequestCollection = verificationRequestCollection;
}
public void Handle(
VerificationRequestId verificationRequestId)
{
...
}
}
Для работы этой функции требуется 1 внешняя зависимость в виде интерфейса к конкретной коллекции в базе данных, ее мы и принимаем через конструктор и сохраняем в private поле.
Вот пример использования такой функции в MVC контроллере
public class VerificationListController
: Controller
{
private readonly IRemoveVerificationRequestHandler removeVerificationRequestHandler;
public VerificationListController(
IRemoveVerificationRequestHandler removeVerificationRequestHandler)
{
this.removeVerificationRequestHandler = removeVerificationRequestHandler;
}
public ActionResult RemoveVerificationRequest(
VerificationRequestId verificationRequestId)
{
this
.removeVerificationRequestHandler
.Handle(verificationRequestId);
return this
.RedirectToAction(
actionName: nameof(this.List));
}
public ActionResult List()
{
return this
.View("List");
}
}
Данный подход меняет привычное использование концепции ООП как способ описания объектов реального мира, на способ описания функции с ее аргументами, возвращаемым значением и зависимостями (сайд эффектами).
Комментарии (91)
Stefanio
00.00.0000 00:00Недокрутили мысль, к сожалению( Изобрели велосипед и рассказали о каком-то рафинированном CQRS
chizh_andrey Автор
00.00.0000 00:00-1Добрый день, расскажите как можно было бы докрутить. Наш подход вырос именно из CQRS. Поделив приложение(монолит) на изолированные сервисы, встал вопрос о том, как организовывать код внутри сервисов. Мы решили сохранить гранулярность от CQRS, когда каждая функция это отдельный класс, но при этом нет необходимости в разделение на command и query.
SadOcean
00.00.0000 00:00+3Я много раз видел превращение полновесных объектов в анемичные (без поведения), в том числе с классами-командами и хочу сказать, что этот метод имеет громадные недостатки - размытие данных о поведении объекта, фрагментация, плохой контроль целостности объекта (когда разные сущности могут напрямую влиять на внутреннее состояние, они могут рассогласования его и нет места, которое бы за этим следило.
Безусловно ООП не отвечает на многие вопросы сам по себе и автоматически, поэтому есть множество дополнительных вариантов дизайна, правил и рекомендаций.
Например, объект пользователя не может отвечать за свое удаление (и не должен иметь метода delete), потому что удаление - операция не над состоянием одного пользователя (за которое отвечает объект), а состояние их списка.
Это должен быть метод условного менеджера пользователей или класса-обертки над хранилищем.
Аналогично взаимодействие многих сущностей - проблема в том, что операцию должен проводить тот, кто знает обо всех, ближайший информационный эксперт. Пользователь является экспертом своих данных и запчастей (например класса почты внутри) и не может отвечать за администрируемые страницы (даже если имеет статус админа)
Страницы тоже не могут знать про пользователя.
В итоге операция, затрагивающая пользователя и страницы, оказывается подвешенной, она будет плоха и в пользователе и в странице, а хорошего класса для нее нет. В итоге этот метод спускается в специальных "менеджеров пользователестраниц" или в гигантские общие классы.
В этом случае вынос этого в класс неплохой выход, хоть и создаёт бойлерплейт(обычно достаточно функции)
Для этого даже есть паттерн DCU - представление процесса как объекта.
chizh_andrey Автор
00.00.0000 00:00Про Delete не буду спорить, скорее всего, этот метод, в реальном проекте затрагивал бы состояние не только объекта Пользователь. В примере стоило бы использовать что-то более специфичное для объекта.
Верно ли я понял вас, что влияние на внутреннее состояние объекта вы предлагает упаковать в один класс, чтобы всегда иметь одно место в коде ответственное за изменение этого состояния?
SadOcean
00.00.0000 00:00+1Если кратко - да. ООП без фанатизма.
Фанатичное ООП порождает ужасы, как и фанатичное не ООП.
Как минимум для основных наборов сущностей можно создать хорошую организацию - основное правило тут - объект должен следить за своим состоянием, а не содержать все возможные методы, где он как то участвует.
Так как для многих объектов практично организовывать в деревья - то это правило неплохо работает и для запчастей объектов.
Естественно на каком то моменте счастье заканчивается - нужны связи многих ко многим, нужно поддерживать какие то технические детали и особенности, сериализации, маппинги.
Тут уже все равно придется думать над практичным подходом для вашей системы, важно лишь то, что нужно именно подумать.
Всякие особенности проекта накладывают свои ограничения, делающие оправданным разные типы решений.
Например для десктопных приложений разделение на слои важны, а вот команды возможно излишество.
Если же вам нужно организовать сервер и большая часть операций - функции над данными в БД - то да, это скорее большой список несвязанных команд.
Я так уверенно это рассказываю, но, разумеется, у меня нет полного понимания и уверенности, особенно в смысле обсуждения на уровне книжных архитектур и терминов.
Я сделал довольно много и с прагматичной точки зрения вроде неплох.
С другой стороны - материалы которые Я читал и обдумывал, не всегда оказывались применимы или субьективно хороши, в то время как антипаттерны - практичны и надежны.
Особенно часто люди обжигаются на реализации крутых архитектур в смысле того, что дорого за них платят, но не получают преимуществ. Только платят.gandjustas
00.00.0000 00:00Если "антипаттерны" показывают характеристики лучше, чем идиоматичный код, то это не антипаттерны. В этом случае ваши идиомы являются антипаттернами.
SadOcean
00.00.0000 00:00Тут важно знать граничные условия.
Например все ругают синглтоны (и с ними есть проблемы), но для очень многих решений они подходят отлично. Ключевое тут - чтобы они не имели явного стейта и управления.chizh_andrey Автор
00.00.0000 00:00Расскажите пожалуйста, когда синглтон это отличное решение.
SadOcean
00.00.0000 00:00Когда у него нет состояния, а его функции делают нерациональной абстрагирование для тестов
gandjustas
00.00.0000 00:00Синглтон без состояния это же просто набор статических функций, не?
SadOcean
00.00.0000 00:00Имеется в виду без состояния с точки зрения приложения.
Внутри могут инициализироваться дескрипторы, подгружаться ресурсы, нечто такое.
SadOcean
00.00.0000 00:00Например адаптеры для интеропа между языками или специфические библиотеки для устройств
gandjustas
00.00.0000 00:00Давайте поподробнее про "одно место, которое будет следить за инвариантами".
Сначала простой вариант. нам нужны инварианты в рамках одного класса.
Известные мне ORM поддерживают возможность перехватить событие записи объектов в базу и туда можно навесить свою валидацию. В C# уже готовый интерфейс есть для этого https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.ivalidatableobject который еще и в UI поддерживается.
Более того, можно эту валидацию в виде check constraints в базе. Тогда инварианты нарушить будет невозможно.
Сложный случай: нам нужны инварианты в рамках нескольких классов. Например "не более трех позиций в одном заказе". В рамках какого класса разместить эту логику? Будет ли это "одно место" ? Надо ли поднимать позиции заказа из базы, чтобы проверить инвариант, если вы только статус заказа меняете? Надо ли выкидывать ошибку если вы из базы подняли заказ с четырьмя позициями?
Как сделать это "одно место", если "не более трех позиций в заказе" это не "инвариант", а правило, которое меняется. Например разрешить больше трех позиций ВИП-клиентам или во время распродаж?
В итоге у вас получится движок правил (стратегий), которые вы сможете применять (или не применять) к классам Order во время работы с ними. Очевидно нет смысла применять набор правил когда вы меняете статус заказа или получаете данные для отображения.
SadOcean
00.00.0000 00:00Ваш пример отличный.
Как раз хорошо показывает и ограничения ООП (например на стыках в БД) и прагматичный подход.
Я не знаю, как будет лучше)
Вычисления еще при доступе к базе - это как раз пример не ООП, но нормального и практичного подхода.
В идеальном мире абсолютных вычислительных мощностей можно было бы поднять все объекты в красивую доменную область, там делать с ними всякие чистые архитектуры, а потом распаршивать обратно. Полная фигня то есть.
По поводу вашего примера - Я бы сделал так:
- объекты следят только за простыми инвариантами - в заказе должен быть телефон и сумма, заказ не должен быть пустым и статус должен соответствовать другим аттрибутам (например заказ может быть без адреса на ранних стадиях, но после приема в работу адрес должен быть)
Они стараются не допускать перевода себя в недопустимый стейт.
- Правила для многих объектов удобнее формировать не от объектов, а от процессов, их описывающих.
Соответственно получится что-то похожее на описанное в статье - один класс на один процесс, в котором объекты заказа, клиента и его счета - параметры.
Конечно полное абсолютное поведение системы будет чуть размазанным, но правила будут сосредоточены на правилах.
Альтернатива - какой нибудь мегакласс с доступом всех ко всем, тоже так себе.
- Если не получается собрать объекты в рациональные сущности, ну, значит договариваться с действующими лицами на нерациональные, синтетические сущности, которые хотя бы будут понятны действующим лицам.
Например условный МенеджерПокупкиИПравилАкций.
Если с точки зрения понятных абстракций объект так себе - ну, он может быть прост и полезен с другой стороны, например, быть плоским списком, который легко настраивать и расширять менеджерам клиента.
Я не то чтобы ярый апологет ООП, Я скорее за практичность.
Там где можно - используем, там где нет - адаптируем.
Но множество архитектур, уходящих подальше от ООП показались мне еще более ядерными, неудобными и склонными к ошибкам.
Люди описывающие преимущества чистых функциональных языков не смогли донести до меня, как Я могу этим сделать продукт и не испытывать боли.
Так же часто заметно натягивание совы на глобус - если условные наборы обработчиков и команд хорошо ложатся на сервера (потому что там запрос-ответ между базой и клиентом пользователя), то для приложений с полным стейтом (Я больше специализируюсь на клиентских играх) это может стать адом.
gybson_63
00.00.0000 00:00Осталось пару шагов и придумаете 1С
chizh_andrey Автор
00.00.0000 00:00Я не знаком с тем как 1С устроено, в чем вы видите сходство?
gybson_63
00.00.0000 00:00Каждый объект хранится в БД при этом поддерживается несколько СУБД. Но из всех прелестей ООП остается только инкапсуляция и околополиморфизм =). Т.е. нельзя изменить существующие абстракции никак, потому что нет наследования, как такового.
И управляется это все скриптовым языком.
Наверное можно найти аналогию с Питоном, который можно использовать как скрипт управления набором готовых неизменяемых классов.
Ну такой постООП. Мне кажется на текущий момент уже невозможно написать свой уникальный класс и даже набор абстракций. Берется готовый набор (фреймворк) и связывается процедурными языками, скриптами, разным.
aborouhin
00.00.0000 00:00Концептуальные вопросы проектирования - это хорошо. А вот приколоченная гвоздями внутри бизнес-логики зависимость от конкретной БД и повторяющиеся строковые литералы в коде из примеров - не очень...
chizh_andrey Автор
00.00.0000 00:00Рад, что концептуально мы с вами согласны. О каких строковых литералах идет речь, имя вьюшки и имя метода куда редиректнуть?
aborouhin
00.00.0000 00:00Глаз за имя вьюшки зацепился. Об опечатке в котором нас компилятор не предупредит, если что, да и рефакторинг будет не очень изящный.
chizh_andrey Автор
00.00.0000 00:00Как лучше сделать имя вьюшки я не знаю, если знаете, то подскажите. Редирект поправил, так компилятор поможет.
avf48
00.00.0000 00:00А почему не воспользоваться стандартом(ми), там и способы описания и их взаимодействие явно прописаны.
картинки
А.3 Проблемы системной инженерии и пространство решений
В данном контексте системная инженерия несет ответственность за общую деятельность по разработке продуктов, проекта/конструкции, которые можно испытывать, изготавливать, технически поддерживать, контролировать, распределять и выводить из эксплуатации. Кроме того, необходимо принимать во внимание подготовку персонала к работе, техническую поддержку, распространение продукции (включая установку и т.д.) и ее вывод из эксплуатации. Основные задачи системного проектирования заключаются в удовлетворении ожиданий клиентов и стратегии предприятия, а социальные, правовые и геополитические ограничения требует структурированного процесса изучения различных вариантов в системных альтернативах для гарантии того, что разрабатывается действительно экономически эффективное, практичное проектное решение. На рисунке А.2 схематически показано пространство проблем, которое необходимо изучить и хорошо понять, чтобы приступить к разработке продукт-решения.
gandjustas
00.00.0000 00:00+1Для начала отделим мух от котлет.
ООП - это средства языка по созданию объектов, обладающих свойствами инкапсуляции и полиморфизма, а также средства наследования интерфейса и реализации для уменьшения дублирования кода.
Моделировать предметную область, то есть объекты физического мира с которыми оперирует программа, с помощью системы типов ОО-языка называется Domain Driven Design (ака DDD). Вернее DDD описывает в целом подход к анализу и проектированию, а моделирование предметной области с помощью типов называется DDD pattern language.
Хотя и до появления термина DDD многократно в книгах встречались попытки моделировать объекты реального мира с помощью ООП (у Буча например), но именно Эванс с Фаулером ввели в обращение понятие DDD.
Так вот:
DDD pattern language не работает на любой нетривиальной программе программе.
Можете ссылаться.
Причины банальные.
Во-первых, при более двух классов доменной модели неизбежно возникает вопроса где должны располагаться методы, использующие три и более разнотипных объекты. Например у нас есть Заказ, Покупатель и Магазин, где должен располагаться метод, реализующий логику получения Покупателем Заказа в Магазине?
Во-вторых, DDD-классы очень быстро превращаются в полу-god-object, содержащие в себе все методы-бизнес логики. Например Заказ может быть Сформирован, Проверен, Отгружен, Доставлен, Получен покупателем. В итоге в DDD-класс стекается куча вызовов из разных мест, это описание очень походит на антипаттерн god-object.
Адепты DDD все время стремятся написать Земля.Копайся() вместо моделирования экскаватора.
Справедливости ради в DDD pattern language есть средства борьбы со сложностью создаваемой DDD. Domain Services, Application Services и прочие сервисы. Суть их в том, что они выносят логику работы из DDD-классов. Все эти сервисы очень даже объектно-ориентированы, используют инкапсуляцию и полиморфизм, иногда даже наследование. Их строят с применением ОО-паттренов: стратегии, фабрики, фасады, chain-of-responsibility иногда даже рекурсивная композиция и интерпретаторы.
До нормального дизайна остается всего один шаг - убрать вызовы к DDD-классам и сделать так, чтобы контроллеры\команды вызывали Application Services, те вызывали Domain Services, они в свою очередь Infrastructure Services и оперировали с данными во внешнем хранилище. Если оперирование с данными проще сделать с помощью ORM, который отражает данные на классы, то так и надо делать.
chizh_andrey Автор
00.00.0000 00:00Спасибо за развернутый комментарий, все по делу, совершенно правы, что разделяете ООП и DDD. Я готов по пунктам ответить. Тизер: мне кажется обе проблемы DDD, что вы описали можно решить.
Давайте начнем с первого ограничения: "Во-первых, при более двух классов доменной модели неизбежно возникает вопроса где должны располагаться методы".
Мы долго мучались с этим вопросом, но оказалось, что в книге по DDD есть точный ответ на это вопрос -- метод надо назвать доменным сервисом и он не относится ни к одному из агрегатов. В заметке выше я предлагаю такие "функции" положить в отдельную папку под именем Services.
По второму вопросу: "Во-вторых, DDD-классы очень быстро превращаются в полу-god-object, содержащие в себе все методы-бизнес логики.".
Вы правы, если методы одного агрегата упаковывать в один класс, то именно это и происходит и это ужасно и чревато кучей проблем, одна из, которых это миллион зависимостей у таких god-object-ов. Частично это решится за счет перового пункта, когда эти классы перестанут менять состояния друг друга, но основная идея из статьи в том чтобы перестать складывать все методы агрегата в один класс, а положить каждый метод в отдельный класс(в одной папке). Оба этих пункта решают проблема god-object-а.
Если придерживаться такого подхода, то не приходится городить сложные схемы работы с состоянием и получается выразить DDD проект в коде, так чтобы код был читабельный и поддерживаемый. Я даже скажу больше, это статья именно про решение проблемы good-object при реализации DDD проекта на ООП языках.
gandjustas
00.00.0000 00:00Решение проблемы - отказаться от DDD, строить системы "сверху вниз", сжечь книги буча и эванса, бить себя по лбу при желании написать
Земля.Копайся()
chizh_andrey Автор
00.00.0000 00:00Думаю что проблема не в книге Эванса, а в том что "мы"(программисты) пишем "Земля.Копайся()" в книге он не настаивает(не призывает) на к такому подходу, а явно говорит, что его книга не про тактику реализации на конкретной парадигме, а про подход проектирования.
Мне было бы интересно, если бы вы сказали решает ли подход, описанный в коментарии выше, проблемы которые вы упомянули.
gandjustas
00.00.0000 00:00Именно призывает и настаивает. Как и Буч в своей книге ООАД (на самом деле с него все началось). Это их парадигма и подход к проектированию. Перечитайте.
Отделение данных от алгоритмов обработки этих данных конечно решает проблемы DDD. Потому что это и есть отказ от DDD. Мы просто начинаем применять ООП в другом месте - в моделировании алгоритмов, а не в моделировании данных.
Я предлагаю даже не начинать DDD если это нетривиальный пример.
chizh_andrey Автор
00.00.0000 00:00+1Я могу прислать скан страницы из книги, если вы не верите мне на слово. Нам удается в большом проекте успешно применять DDD, на большом кол-ве сущностей.
gandjustas
00.00.0000 00:00Никто не говорил что нельзя. Просто недостатков от DDD больше, чем преимуществ.
Небольшая метафора:
Почесать ухо можно и пяткой ноги. Можно годиться этим, организовать конференцию на тему того как чесать ухо пяткой, рассмотреть все особенности чесания ушей пяткой, потом написать книгу с советами дяди боба о том как чесать ухо пяткой.
Но рукой все равно удобнее.
chizh_andrey Автор
00.00.0000 00:00Я вот на этот тезис из книги опираюсь.
gandjustas
00.00.0000 00:00А вся остальная книга про то как надо делать в java.
chizh_andrey Автор
00.00.0000 00:00Вы правы, отсюда огромная путаница между DDD и ООП, как будто одно должно быть выражено в другом. Я делаю скидку автору, книга была выпущенна в 2007 года, уже почти 20 лет прошло, а писалась наверное еще раньше. В те года "клиенты" были толстые, БД медленные(из за дисков), о stateless подходе мало кто говорил, а парадигма ООП казалась ответом на все вопросы, поэтому автор так много уделил ей внимание.
Но сейчас, мы можем взять лучшее из книги - это методы проектирования и используя новые языки, функциональные принципы, stateless подход и микросервисы наконец то реализовать DDD как оно может работать!gandjustas
00.00.0000 00:00+1Эванс книгу выпустил в 2003, а фаулер упоминал Domain Model в POEAA в 2001. Но история началась гораздо раньше, с Гради Буча.
Он выпустил свои книги в по ООП в 1990-1995 году, где центральной идеей было выразить связи объектов реального мира в виде классов в ОО-языке. По его мнению это могло бы упаковать сложность внутрь объектов, представив программиста простой интерфейс для создания программ.
Вполне возможно для некоторых классов программ это так и есть. Например для симуляций или АСУТП - там у каждого компонента будет свое поведение.
Фаулер в POEAA описал паттрен domain model, где подход Буча применялся для корпоративных приложений. Описал он его поверхностно, не особо углубляясь в недостатки. Далее они с эвансом написали книгу про DDD, где первая глава про анализ, а остальные про DDD pattern language.
В POEAA кстати было и про стейтлесс, и про веб, и query builder, и современные БД. Так что вряд ли получится сказать, что вознесение DDD было обусловлено внешними факторами.
Буч, кстати, переобулся. В последней редакции OOAD не пишет что
Чайник.Закипеть()
- отличное решение. Он рассматривает реалистичные примеры приложений и иерархии классов их реализующие. И гораздо чаще на страницах книги можно встретитьЭкскаватор.Копать(земля)
, чемЗемля.Копайся()
.chizh_andrey Автор
00.00.0000 00:00Что если можно сделать по другому. Что если можно сделать функцию-объект-сервис "ВыкопатьЗемлюмыЭкскаватором(ЭксковаторИД, ЗемляИД)" и это функция гарантированно изменит состояние всех агрегатов валидно.
Я бы даже продолжил мысль сразу дальше, что если есть веб-серсив, у которого есть http endpoint который принимает ЭксковаторИД и ЗемляИД и гарантирует что действие будет выполнено правильно.
Зачем нам вообще нужно это ООП для этой задачи?gandjustas
00.00.0000 00:00Экскаватор это не объект данных, а сервис, у которого нет наблюдаемого состояния. Так что нет семантической разницы между
Копать(Эксковатор, Земля)
иЭксковатор.Копать(Земля
), но второй вариант лучше тестируется и имеет выше discoverability.ООП для данной задачи в том, что Экскаватор это объект со своей структурой, декомпозицией. Земля это объект предметной области с Identity. Это означает что нельзя передать в метод невалидный ЗемляИД и, соответственно, не надо внутри метода Копать устраивать дополнительные проверки.
gybson_63
00.00.0000 00:00И мы же позволяем его вызывать только классам произведенным от абстрактного экскаватора. Потому, что копают экскаваторы одинаково, а копается земля везде по-разному.
GraDea
00.00.0000 00:00ООП предложило совместить данные и поведение. Одним из посылов было соблюдение инвариантов класса. В вашем случае знание инвариантов надо размазывать по всем функциям и не забыть поменять когда бизнес меняется. Но это нарушает принцип DRY и cohesion выходит не очень сильной.
Размещать инварианты чуть ли не в персистанс слое (как предлагалось здесь в комментах)? Ну это на любителя , примерно как строить систему на триггерах и хранимках - можно забыть о тестабилити
chizh_andrey Автор
00.00.0000 00:00Подскажите, где вам кажется нарушается принцип DRY?
GraDea
00.00.0000 00:00Надо проверку инвариантов повторять в каждой функции, как я понял. Хотя если это просто вынос метода в отдельный класс, то немногое в этом отношении меняется, но тогда я не понимаю профита от такого выноса.
chizh_andrey Автор
00.00.0000 00:00Мы не проверяем инвариантность после вызова каждого метода, у нас метод гарантирует что он не нарушит инвариант, а фиксируем мы это в тестах, тестируя каждый метод изменения состояния.
Подскажите, а как у вас проверяется инвариант.
GraDea
00.00.0000 00:00Если не брать полуэкспериментальное проектирование по контракту, то также в коде и тестами.
GraDea
00.00.0000 00:00Вы как-то проверяете, что модель изменяется только классами из этой же папочки?
chizh_andrey Автор
00.00.0000 00:00Мы эту папочку с функциями-классами упаковываем в сервис, и только этот сервис знает пароль от БД где лежит состояние агрегатов этого домена. Считаем что все методы этого сервиса(api) обязаны менять состояние агрегата валидно и должны быть затестированы - ~100% покрытие тестами.
Пока только такие способы нашил.GraDea
00.00.0000 00:00Круто что вы строите агрегат со столь жесткими границами. В таком случае особых проблем не вижу, но и преимуществ такой организации кода тоже не нахожу)
chizh_andrey Автор
00.00.0000 00:00"Строить систему на триггерах и хранимках" -- мне казалось, подход, который я предлагаю, прямо противоположный. Все методы изменения состояния явно описаны в коде, независимы друг от друга, да еще и отделены от слоя доступа к данным(data layer). Требуется явный и прямой вызов "функции" изменения состояния, который не удасться скрыть.
chizh_andrey Автор
00.00.0000 00:00Мне кажется, что в тезисе "Одним из посылов[ООП(я добавил)] было соблюдение инвариантов класса." и кроется наше с вами различие взглядов, мне кажется, это ошибочный тезис, тем более если речь идет об сущностях из DDD. ООП, похоже, было про алгебраические типы, вроде int, double, которые между собой независимы и состояние у них крайне ограниченно, если это вообще моно назвать состоянием.
Попытка описать инвариант агрегата в рамках одного класса приводит к очень громоздким конструкциям.
ApeCoder
00.00.0000 00:00+1https://medium.com/javascript-scene/the-forgotten-history-of-oop-88d71b9b2d9f
ООП было про гуй и имитационное моделирование
Хотя:
Alan Kay also saw objects as algebraic structures, which make certain mathematically provable guarantees about their behaviors:
“My math background made me realize that each object could have several algebras associated with it, and there could be families of these, and that these would be very very useful.”
~ Alan Kay
mozg3000tm
00.00.0000 00:00ООП, похоже, было про алгебраические типы, вроде int, double,
я удивлен, что вы как и многие говорят про ОПП, и при этом не в курсе базы.
Класс - это пользовательский тип данных, т.е. расширение языка позволяющие программисту создать свои типы данных.
Тип данных — класс данных, характеризуемый членами класса и операциями, которые могут быть к ним применены (ISO/IEC/IEEE 24765-2010). Тип данных — категоризация абстрактного множества возможных значений, характеристик и набор операций для некоторого атрибута (IEEE Std 1320.2-1998).
Соответственно, ООП лишь сопоставило, членов класса состоянию объекта, а операции над атрибутами поведению объекта.
Все что говорите вы и другие, это фактически антииООП, это превращение классов в контейнеры для функций.
chizh_andrey Автор
00.00.0000 00:00В заметке я как раз и пытаюсь сказать, что, похоже, пора пересмотреть то как мы применяем ООП. Обратите внимание на года стандартов, что вы привели в качестве аргументов, одному из них больше 25 лет, а второму 13.
Если выражаться вашими словами, то я действительно говорю об антииООП.
gandjustas
00.00.0000 00:00Остаётся только вопрос что есть инвариант и является ли правило "заказ должен содержать не более трёх позиций" инвариантом класса заказа для приложения интернет-магазина.
GraDea
00.00.0000 00:00Это инвариант для агрегата заказ, если нет особо других вводных.
gandjustas
00.00.0000 00:00То есть если вы подняли из базы заказ с 4 позициями должна вылететь ошибка?
GraDea
00.00.0000 00:00По-хорошему, его там не должно быть сохранено. Если логика поменялась в процессе, то надо предусмотреть что делать с легаси-заказами.
Можно запретить часть методов, то есть поднимать можно, а процессить нельзя.gandjustas
00.00.0000 00:00По-хорошему, его там не должно быть сохранено. Если логика поменялась в процессе, то надо предусмотреть что делать с легаси-заказами.
Это не ответ на вопрос. В базе может произойти что угодно.
Можно запретить часть методов, то есть поднимать можно, а процессить нельзя.
Опустим вопрос о том, как это реализовать технически, чтобы я не мог в программе руками создать невалидный заказ.
Интереснее другой вопрос: как написать код, чтобы не забыть вызвать логику валидации? Кто помешает добавить метод бизнес-логики, где нарушается инвариант и не вызывает проверку?
gandjustas
00.00.0000 00:00А является ли инвариантом какого-то класса правило "не более трёх позиций в заказе, если клиент не ВИП"?
GraDea
00.00.0000 00:00Скорее всего да, но к сожалению, не любой инвариант можно запихать в доменную модель.
Классический пример - требование уникальности никнейма (если это не локальные шашки на смартфоне). В таком случае вы делаете выбор между производительностью, полнотой модели и работой внутри одного процесса приложения (то что назвали purity).gandjustas
00.00.0000 00:00Вот мы и добрались до ключевого аспекта. Далеко не все "инварианты" можно выразить в системе типов. Особенно если они меняются с изменением требований.
Учитывая ваш коммент выше оказывается что даже простые "инварианты" фактически являются не инвариантами, а пред- и пост-условиями операций.
Если мы эти пред и пост условия отделим от объектов данных, то окажется, что мы сможем легко создавать условия, которые оперируют множеством сущностей. Например "заказ может содержать не более трёх позиций, если клиент не ВИП, и если позиции не содержат товары по акции".
Более того, эти пред и пост условия можно выразить в виде запросов к бд (с помощью ef) и даже не поднимать объекты в память
GraDea
00.00.0000 00:00Можно фрагментирован бизнес-логику как угодно. Но зачем? Тестабилити снижается, maintainability снижается, cohesion снижается. Одно дело когда из-за компромисса мы это делаем, но не понимаю зачем это делать нормой.
gandjustas
00.00.0000 00:00Снижается по сравнению с чем?
Вернемся к простому случаю. Бизнес-правило - в одном заказе не больше трех позиций.
Так как на уровне БД мы не контролируем количество позиций, то поднять из базы можем любое количество. Поэтому проверять это правило при загрузке мы не можем.
Кроме того: мы используем ORM, аля EF, который использует свойства объекта или конструктор для создания. Это значит что мы не можем проверять правила в конструкторе.
У нас остается один вариант: проверять правила при выполнении действия.
Итак у нас получается цепочка вызовов:
Контроллер -> Объект.Действие -> Проверка
. Естественно каждая стрелка может заключать еще 100500 вызовов, это не имеет значенияЯ предлагаю заменять это на:
Контроллер -> Правила.Проверка(объект).
Что это дает:
Меньше косвенность вызовов и меньше приседаний с инъекцией зависимостей в Объект.
Проверка может сколь угодно сложной - принимать любое количество параметров, загружать данные из базы, вызывать веб-сервисы.
Проверка может оперировать на состоянием объекта в памяти, а сделать запрос в базу.
Первый вариант может только выбросить исключение при непрохождении проверки, а второй вариант может возвращать true или false и использоваться для деактивации элементов интерфейса ДО выполнения действия пользователем.
Тестируемость обоих вариантов одинаковая. У второго даже выше, так как для создания Объекта ему надо передать кучу зависимостей, которые нужны не только для проверки. Формальные метрики при преобразовании первого варианта во второй не меняются (проверял). В целом если отказаться от инъекции сервисов в Domain Object можно значительно сократить объемы кода.
Недостатки у второго варианта только надуманные. Можно сказать что легко проверку не вызвать в контроллере. Но её также легко не вызвать и в объекте, особенно если действий много. Можно сказать что действие будет вызываться в нескольких контроллерах, тогда будет дублирование кода. Но это тоже неправда, так как никто не мешает проверку и действие вынести в одни метод Domain Service или как он там называется.
GraDea
00.00.0000 00:00А объект.Действие() где? В сервисах?
Имеет ли валидатор и сервисы доступ к внутреннему устройству объекта? Правильно ли я понимаю, что вы топите за анемичную модель?
gandjustas
00.00.0000 00:00А объект.Действие() где? В сервисах?
Да, но это не имеет значения. Действие с Объектом подчиняется той же логике что и правила. Чем сложнее действие, тем хороших способов поместить его в объект.
Имеет ли валидатор и сервисы доступ к внутреннему устройству объекта?
К внутреннему - нет. Использует тот же публичный интерфейс, который используется например для вывода списка объектов на экран. В варианте
Объект.Действие()
при достаточно сложном действии оно будет перенесено в сервис и будет иметь такой же доступ к объекту, как иПравило
, написанное внеОбъекта
.Правильно ли я понимаю, что вы топите за анемичную модель?
Я топлю за то, чтобы писать меньше, а работало лучше.
gandjustas
00.00.0000 00:00Сможете привести реалистичный рабочий пример?
Потому что я вижу сразу кучу проблем со скрытым состоянием объекта, которые не оправдываются вообще ничем.
Его нельзя сохранить в базу с помощью EF. Чтобы можно было сохранить нужно как минимум read-only свойство
У вас получается логика основанная на исключениях. Если единственный способ узнать что действие выполнить нельзя - вызвать
Объект.Действие()
, то вы никак не сможете скрыть\деактивировать элементы управления в UI. Даже try-pattern не поможет.Вы не сможете логику действия вынести в отдельный класс.
В целом если вытащить наружу для чтения состояние объекта, то это никак не повредит его инкапсуляции, но поможет сделать остальной код лучше.
GraDea
00.00.0000 00:00Для персистанс-слоя можно использовать дто.
Любая раскрытая информация вредит инкапсуляции, потому что рано или поздно люди начнут обращаться к внутрянке мойЗаказ.элементы[0]…
И приватные поля - отличный способ запретить такое использование. Можно, конечно, придумать конвенции и всем рассказать что можно а что нет, но рано или поздно начнет протекать.
gandjustas
00.00.0000 00:00DTO делать там, где можно без него, это антипаттерн. Прямое увеличение объема, связности и снижение mantainability index. Вы на ровном месте сделали код хуже, а преимущества крайне сомнительны.
рано или поздно люди начнут обращаться к внутрянке мойЗаказ.элементы[0]
А чего в этом плохого? И кто помешает также обращаться к dto?
Но это опять половина беды. Вводя dto между domain и persistence вы больше не можете использовать механизм запросов, предоставляемых orm.
То есть мало того, что код стал хуже, он ещё стал значительно медленнее.
Сори, но такой подход нежизнеспособен настолько что нет смысла дальше обсуждать. Ну или вы просто пример кода приведете, который работает и не абсурден.
guryanov
00.00.0000 00:00В последнем абзаце согласен с автором.
Я считаю вообще деление на слои выглядит логичным но крайне неудобно в написании и поддержке. Вместо деления на слои надо максимально делить на операции а внутри каждой операции можно делать столько слоев-функций, сколько необходимо.
Потому что не бывает задач "переписать все функции для работы с БД" или "доработать все обработчики HTTP" зачем их тогда группировать?
Напротив, 95% всех задач - это либо сделать/доработать новую операцию (добавить операцию по смене статуса заказа, добавить новый фильтр в список заказов). Либо найти баг/оптимизировать (тормозит выдача списка заказов, 404 при попытке смене заказа). И вот для решения таких задач хочется чтобы ВЕСЬ код, который реализует эту операцию находился в одной папке и кроме кода, относящегося к этой операции в этой папке больше ничего не было.
Да, если такой подход реализовать в лоб, будет куча копипасты, но как всегда, можно чуть подумать и сделать общие функции, например.
Такая аналогия: вы увлекаетесь разными видами спорта и есть 2 подхода к хранению снаряжения:Шкаф для шлемов, шкаф для курток, шкаф для обуви, комната/гараж для основных снарядов
Делить по видам спорта: чулан для велосипеда а рядом сумка с велотуфлями, велошлемом и велоформой. Гараж для мотоцикла + ящик для мотоформы. Шкаф для коньков и хоккейной формы.
Несмотря на то, что при первом подходе на вид будет порядок второй подход будет требовать гораздо меньших затрат времени - открываешь шкаф для хоккея и надеваешь все что там есть. Не надо открывать 5 разных шкафов в двух комнатах.
gybson_63
00.00.0000 00:00Призвать увеличивать связанность кода для облегчения поддержки ... Ну такое.
Поэтому у вас и бывают задачи "перезаписать все функции", вы все не так проектируете.
В данном примере, для уменьшения связанности нужен интерфейс по сериализации и десериализации данных объекта, которые будут передавать в БД, микросервисы, разное.
В каждом слое объект выглядит не так, как в других. Это касается отображения, поведения, хранения и у каждого слоя несколько вариантов и сам он делится на еще несколько.
А так, отправили объект в очередь, оттуда он разлетелся в БД, сайт, прочим партнерам. И вот это уже можно не просто поддерживать, но и развивать и масштабировать.guryanov
00.00.0000 00:00Я же наоборот сказал, что не бывает задач переписать все функции для работы с БД, поэтому и объединять их (в один слой) не нужно.
Объединять нужно то, что используется вместе а разделять - то что вместе не используется и не связано. хендлер1-бизнес логика1-запрос в базу1 для одной конкретной апишки всегда используется вместе, поэтому их надо объединить. А хендлер2-бизнес логика2-запрос в базу2 другой апишки не связан с хендлер1-бизнес логика1-запрос в базу1 поэтому их надо отделить на самом верхнем уровне.
gybson_63
00.00.0000 00:00Да, невнимательно прочитал.
Но Вы настаиваете на тоталитарной и беспощадной связанности кода, а это путь прямиком в Ад.
В моем понимание все БЛ должны иметь общий интерфейс совместимый с общим интерфейсом БД и тогда с этим можно работать.
ApeCoder
00.00.0000 00:00Похоже на https://jimmybogard.com/vertical-slice-architecture/
С зависимостями интересная штука. В контроллерах уже можно передавать зависимости параметрами - возможно, что-то такое можно придумать и просто для вызова методовchizh_andrey Автор
00.00.0000 00:00Кстати! Невозможность передавать в MVC контроллер зависимости явно - это боль. Навязанный DI, какая-то жуткая ошибка. Я вот тут поразмышлял об этом https://habr.com/ru/company/retailrocket/blog/512222/
gybson_63
00.00.0000 00:00Победили "скрипты", которые замечательно живут на неизменяемых готовых библиотеках классов. Сам процесс создания класса, должен заставить человека задуматься, что он творит и во что лезет. Это сотни-тысячи часов проектирования, реализации и тестирования, которые до него уже тысячи раз были потрачены.
И я очень надеюсь, что ChatGPT и т.п, наконец избавят человечество от этого обезьяньего труда, делать что-то только потому, что не можешь найти готовую реализацию.Ну перестали же повсеместно писать свои ДОС и браузеры.
irony_iron
00.00.0000 00:00+1помню лет 15 назад были ссоры между процедурщиками и оопшниками, ООП победил и теперь из-за мейнстримности подход позволяет не включать мозги в момент планирования, что конечно удобно, но приводит к существованию классов с 1 экземпляром или классом от которого никто и никогда не наследуется, я уж не заикаюсь про настоящие динамические объекты, в общем и целом, добро пожаловать назад в будущее
chizh_andrey Автор
00.00.0000 00:00Наверное уже пораньше, 15 лет назад, был очередной виток споров между функциональщиками и инперативщиками. Процедурщики и ООПки, наверное, спорили 30 лет назад, в 80х ) когда C++ вынужден был делать zero cost of abstraction.
А что плохого что у класса 1 экземпляр, его задача это сокрытие сложности, если надо скрыть ее один раз, ну и хорошо.
Наследование вообще спорная вещь, все меньше вижу ему применение(речь не про интерфейсы как контракты). Тем более, когда мы все ближе к миру с discriminated union и exhaustive pattern matching, где у наследования еще меньше причин.
Что такое настоящий динамический объект?irony_iron
00.00.0000 00:00под динамический я имею ввиду создаваемые на время экземпляры с полиморфными методами, одно время это применялось при написании вирусов, умерло из-за сложности трассировки
irony_iron
00.00.0000 00:00когда в классе 1 экземпляр все хорошо если экземпляров программы не миллионы по всему миру, иначе в конечном итоге за читабельность кода заплатит экология)
chizh_andrey Автор
00.00.0000 00:00Вы считаете что инстанцирование классов как-то заметно сказывается на потребление ресурсов?
irony_iron
00.00.0000 00:00если бы сим-карты были сделаны с поддержкой ООП, то нам бы пришлось переплавить всю луну на телефоны
pladar
00.00.0000 00:00-1Сэкономлю много времени. Можно открыть для себя MediatR и уже посоревноваться в специальной Олимпиаде по теме его обоснованного использования. Совершенно то же самое, только пользоваться проще. Там тебе и OpenClose из коробки. Дальше CQRS, EventSourcing etc...
Никакой новизны в материале нет, сочувствую.
chizh_andrey Автор
00.00.0000 00:00Действительно есть сходство с MediatR и CQRS, но, на мой взгляд, есть и ряд значимых отличия.
Заметка, скорее для тех, кто делает классы бизнес-сущности с состояниями и поведением. Надеюсь для них она будет полезной.
Oceanshiver
А о чем статья-то?
chizh_andrey Автор
Возможно недостаточно акцентировал. Пытался выразить две мысли:
Отражение объектов реального мира в классах с поведением, похоже что не лучшая идея
Предложил способ получше.
ruomserg
Мне кажется, что при объяснении принципов ООП преподаватели (иногда по объективным причинам, иногда по незнанию) забывают что мы никогда не создаем (и не можем создать) отражение объективной реальности. Вместо этого, мы в ООП описываем модель (!) этой реальности, существующую только в мыслях проектировщика/проектной команды. И, поскольку модель реальности всегда упрощена, то она отражает только те свойства которые были существенны с точки зрения наблюдателя.
Безусловно, удобно на примере показывать что собака лает, а кошка мяукает — потому что для нашего сознания это привычный и удобный ход вещей. Однако, в рамках некоторой модели можно предположить существование универсального лаятеля, который лает за любую собаку и мяукателя который мяукает за кошек. И это тоже будет валидно до тех пор, пока наша модель удобна для какого-то специфического применения.
В энтерпрайзе уже давно считается хорошим тоном не смешивать объекты-данные и объекты-сервисы. Но надо помнить что это просто такая модель окружающего мира, принятая там по совершенно утилитарным причинам. По поводу одного и того же явления, можно построить множество моделей — какие-то из них более удобны в одной ситуации, другие — в другой…
chizh_andrey Автор
С первым тезисом согласен. Меня смущает единый объект с данными описывающий модель собаки и сервис-объект, который описывает все поведение собаки. Я предлагаю каждый "метод" описывающий поведение собаки положить в отдельный класс, но в одной папке, и если этот метод возвращает какие-то сведения об объекте, то пускай вернет свой DTO(только с необходимыми полями). Такой подход позволяет иметь в каждом классе-функции только свои внешние зависимости, а каждая DTO имеет только свои поля и в том виде, в котором они нужны, из за чего лишние данные не протекают там где они не нужны.
ruomserg
Это потому что собака живая, и с нашей точки зрения, она должна быть самодостаточна в своем теле. В энтерпрайзе объекты зачастую не живые (счет, партия, клиент-как-сущность, и т.д.). И как «здрасьте» можно иметь некий сервис, который реализует life-cycle определенной сущности. То есть если вы хотите собаку, то идите в DogsService и попросите себе экземпляр. А по завершении жизненного цикла туда же сдайте. :-) И вообще, трогать собаку вы не имеете права, потому что для decopuling вам бы хорошо вообще не иметь понятия как она устроена. Поэтому если вы с ней что-то хотите сделать, то лучше попросите специализированный сервис, чтобы они в рамках своего public interface произвели необходимые манипуляции. :-)
chizh_andrey Автор
Статьей я хотел намекнуть, что у DogsService просматриваются проблемы с SRP т.к. сервис имеет все зависимости, которые требуются для поддержания инварианта по всем методам изменения состояния собаки. Везде где разработчики взаимодействуют с "собакой", даже по самым простым методам вроде получения года рождения, будет необходимо знать о всей сложности такого сервиса -- о всех зависимостях. Явно это проблема заметна в тестах, где для проверки простого кейса, вроде вычисления возраста собаки, придется инициализировать объект-сервис непропорциональной кейсу сложности.
Ниже в комментариях так же описали еще две проблемы таких сервисов как DogService:
1) со временем сервисы склонным превращаться в god-object
2) если есть задача в одной транзакции и собаку в будку прописать и кол-во жильцов в будке увеличить, то в каком сервисе(сервис собаки или будки) должен лежать такой метод прописки собаки?
В статье я предлагаю распилить сервис на отдельные функции, сложить их в папку с именем DogsService и там где требуется использовать конкретную "функцию-класс". А те функции, которые меняют состояние двух агрегатов вынести отдельно в папку "Services".
ruomserg
Дальше оно уже очень сильно depends. В layered-подходе, у вас есть сервис будки, который может засунуть в будку любую сущность с подходящим интерфейсом, есть сервис собак который выдает собак. А где-то наверху над ними живет еще один сервис, которому вы можете показать собаку и и будку — а он проверит какие-то свои бизнес правила, и либо дернет нижележащие сервисы чтобы они изменили собаку и будку для установления связи «живет в» между ними, либо откажется это делать. Причем в сервисе верхнего уровня могут быть зашиты (или сконфигурированы, или даже подгружены в момент выполнения) некие правила по которым это можно или нельзя делать. В гексагональной архитектуре — это будет решено по-другому, и т.д. Вопрос в модели, и в цели ради которой она построена. Для меня в общем, не очевидно, почему распил сервиса на отдельные функциональные интерфейсы (каждый в своем файле) является положительным моментом… Или, говоря по-другому, я не вижу ничего такого, что мы бы могли сделать после этого действия, но чего не могли бы сделать будь эти методы собраны в одном юните? Ссылку на метод-то никто не отменял… В джаве так и вообще ссылка на метод образует FunctionalInterface — все как вы хотите…
chizh_andrey Автор
Ссылка на метод, в случае если сущность и методы изменения состояния разделены, концептуально, почти идентичное решение с тем, что я предлагаю, у него даже есть плюс в том, что все методы в сервисе созданы в рамках вызова одного конструктора -- гарантия что все методы имеют одну ссылку на БД или что-то подобное.
Плюсы которые я могу выделить в моем подходе:
1) Для тестирование отдельного метода, не требуется создавать сложный объект со всеми зависимостями;
2) Небольшие удобные файлы с "функциями", которые могут быть отредактированы отдельно друг от друга. Это кажется не значимо, но на деле, очень удобно.