Как стать автором
Обновить

Вам не нужна Чистая архитектура. Скорее всего

Уровень сложностиСредний
Время на прочтение22 мин
Количество просмотров12K
Всего голосов 36: ↑34 и ↓2+40
Комментарии102

Комментарии 102

Согласен, тоже не понимаю зачем плодить тонны абстракций, которые никогда не пригодятся, плодить на каждый класс интерфейс, с верой что на пол пути в рабочей системе, вдруг, БД придется менять... Мне кажется что интерфейс нужно вводить или когда изначально есть несколько реализаций, или ввести его в момент когда появится новая реализация, благо современные IDE рефакторинг очень упрощают.

Так в том то и дело, что реализаций БД уже есть великое множество. В случае key-value db как-то вроде бы и не грех нарисовать свой слой абстракции поверх. Потом можно будет продакшн потестить на разных конкретиках.

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

Ну, и БД и другие инфраструктурные зависимости не так уж редко имеют свойство менять лицензию или импортозамещаться :)

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

Какая-то не очень удачная аналогия с мостом в плане соотношения затрат времени и ресурсов :)

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

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

Приваренный фаркоп - это уже реализация интерфейса IФаркоп! И в вашем примере если надо что-то буксировать Вам посреди ночи придется заменить реализацию авто без фаркопа на реализацию с фаркопом 😁

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

Приваренный фаркоп - это уже реализация интерфейса IФаркоп! И в вашем примере если надо что-то буксировать Вам посреди ночи придется заменить реализацию авто без фаркопа на реализацию с фаркопом

Интересное у вас восприятие :)

Фаркоп это когда к авто можно прицепить хоть прицеп, хоть трейлер через контракт IПрицеп.

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

Тестировать можно и моками

Можно и руками все тестировать :) Только зачем? Только для того чтобы не создавать интерфейс?

Если что-то удобнее тестировать и экспозировать с интерфейсами - вот в этих местах, в этот момент их и создавать

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

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

Вот если точно известно что у вас есть\будет пара прицепов - тогда, да, логично фаркоп. Если не планируется - его отсутствие. Если планируется грузоперевозки то лучше реализовать сразу авто с грузовым отсеком, типа газели или грузовика.

Если у вас нет прицепа, и не планируется - это лишний вес, лишняя деталь, итд.

Лишний вес 10-20 кг для машины весом в несколько тонн? Нет, ну серьезно? :)

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

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

Т.е. или есть информация что точно пригодится

То есть, если вернуться к примеру выше, с вашим подходом (создавать интерфейс только когда есть больше одной реализации), вы бы установили на автомобиль фаркоп только когда точно станет известно, что будет как минимум 2 разных прицепа. А до этого момента при необходимости что-то перевезти, вы бы приваривали прицеп прямо к автомобилю жесткой сваркой. И когда потом происходили бы ситуации, что прицеп не нужен, а только мешает (юнит-тесты), вы приносили бы болгарку и начинали отпиливать мешающие части (делать моки).

Вот такая аналогия была бы полной.

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

Как-то так

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

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

Поддерживаю. Работал на одном таком проекте, где на каждый класс был свой интерфейс. Всё это выглядит как сова натянутая на глобус. Делайте проще.

В целом согласен, но рассуждать об архитектуре на примере сниппетов на 10 строчек - это смешно. К Мартину в общем-то та же претензия.

И теперь наша «бизнес‑логика» стала вполне себе «подвижной» — её без проблем можно будет переиспользовать в другом контексте и не тащить при этом за собой интерфейсы и не приседать с адаптерами, затачивающими стандартную библиотеку под эти интерфейсы. Всё.

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

А тестировать-то как? Раньше я передавал другую имплементацию на моках в качестве dependency и получал красивые, ёмкие и внятные тесты. А теперь?

Ответ есть в посте

Ясно. Бизнес-логика всегда сводится к складыванию джейсонов в базу, а высоконкурентные и высоконагруженные приложения тестировать не нужно.

Понял.

Я удивлён, что коммент выше заминусили в свете того, что все классики TDD предостерегают от повсеместного использования моков.

Оставлю здесь мудрость древних для будущих поколений:

My personal practices…​ I’m mock almost nothing. If I can’t figure out how to test efficiently with the real stuff I find another way of creating a feedback loop for myself…​ I just don’t go very far down the mock path. I look at a code where you have mocks returning mocks returning mocks and…​ my experience is if I have…​ If I use TDD I can refactor stuff and then I heard these stories people say well I use TDD and now I can’t refactor anything and I feel like I couldn’t understand that and then I started looking at their tests…​ Well if you have mocks returning mocks returning mocks your test is completely coupled to the implementation…​ not on the interface but the exact implementation of some object …​ three streets away …​ of course you can’t change anything without breaking a test. So that for me is too high a price to pay that’s not not a trade-off I’m willing to make.

---

Мои личные практики…​ Я почти ничего не мокаю. Если я не могу понять, как эффективно тестировать c реальными зависимостями, я нахожу другой способ создать для себя цикл обратной связи…​ Я просто не хожу далеко по пути мокирования. Я смотрю на код, в котором у вас есть моки, возвращающие моки, возвращающие моки, и…​ мой опыт показывает, что если у меня есть…​ Если я использую TDD, я могу рефакторить кода, а потом я слышу эти истории, когда люди говорили, что я использую TDD, а теперь я ничего не могу отрефакторить, и я не мог этого понять, пока не начал начал смотреть на их тесты…​ Ну, если у вас моки возвращают моки возвращающие моки, то ваш тест полностью связан с реализацией…​ не с интерфейсом, а с точной реализацией какого-либо объекта…​ в трех кварталах отсюда…​ конечно, вы ничего не сможете изменить, не сломав тест. Так что для меня это слишком высокая цена, на которую я не готов пойти ни в коем случае.

— Кент Бек, основатель движения TDD, автор JUnit
TW Hangouts | Is TDD dead?

Personally I’ve always been a old fashioned classic TDDer and thus far I don’t see any reason to change. I don’t see any compelling benefits for mockist TDD, and am concerned about the consequences of coupling tests to implementation.

This has particularly struck me when I’ve observed a mockist programmer. I really like the fact that while writing the test you focus on the result of the behavior, not how it’s done. A mockist is constantly thinking about how the SUT is going to be implemented in order to write the expectations. This feels really unnatural to me.

---

Лично я всегда был сторонником старомодного классического подхода и до сих пор не вижу причин меняться. Я не вижу никаких убедительных преимуществ для mockist TDD и обеспокоен последствиями привязки тестов к реализации.

Это особенно поразило меня, когда я наблюдал за программистом-мокистом. Мне очень нравится, что при написании теста вы сосредотачиваетесь на результате поведения, а не на том, как оно достигается. Мокист постоянно думает о том, как будет реализован SUT, чтобы описать ожидания. не это кажется действительно неестественным.

— Мартин Фаулер, автор таких книг как "Рефакторинг" и "Шаблоны корпоративных приложений"
Mocks Aren’t Stubs

In short, however, I recommend that you mock sparingly. Find a way to test – design a way to test – your code so that it doesn’t require a mock. Reserve mocking for architecturally significant boundaries; and then be ruthless about it. Those are the important boundaries of your system and they need to be managed, not just for tests, but for everything.

And write your own mocks. Try to depend on mocking tools as little as possible. Or, if you decide to use a mocking tool, use it with a very light touch.

If you follow these heuristics you’ll find that your test suites run faster, are less fragile, have higher coverage, and that the designs of your applications are better.

---

Вкратце, я рекомендую вам использовать моки с осторожностью. Найдите способ тестирования – спроектируйте способ тестирования вашего кода, чтобы он не требовал моков. Оставьте моки для архитектурно значимых границ, и там безжалостно мокайте всё (дословный перевод: "и затем будьте безжалостны к нему"). Это важные границы вашей системы, и ими нужно управлять не только для тестов, но и для всего остального.

И пишите свои собственные моки. Старайтесь как можно меньше зависеть от инструментов мокирования. Или, если вы решите использовать инструмент для создания макета, используйте его минимально (дословный перевод: "очень лёгкими касаниями").

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

— Роберт Мартин, автор SOLID и Чистой архитектуры
When To Mock

Еще один argumentum ad vericundiam, причем авторитеты — мягко говоря — крайне спорные.

От того, что два унылых теоретика не умеют в моки, а автор TDD безбожно передергивает и просто не понимает, что такое мок и зачем он нужен — моки хуже не становятся.

Добавлю ещё, что эти тезисы подтверждаются фактами из моей практики

В моём предыдущем проекте с тестами без моков за 500 человеко-часов, которые я рассматривал в ретроспективе было 0.5 бага на задачу или 0.05 бага/человеко-час (подробности).

В текущем проекте за примерно человеко/год разработки нашли 2 бага (не соответствие поведения софта требованиям) и 0 регрессий. Проект ещё не завершён, поэтому ретру не проводил и точных цифр пока нет.

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

А рефакторинг (20 затронутых файлов файлов, 482 добавленных строки, 172 удалённых строки) не содержал ни одной строки изменений в тестах.
Это красноречивая иллюстрация того, что хорошие тесты проверяют поведение системы, а не её реализацию и, как следствие, не меняются при рефакторинге - изменении структуры кода, без изменения его поведения.

хорошие тесты проверяют поведение системы, а не её реализацию и, как следствие, не меняются при рефакторинге — изменении структуры кода, без изменения его поведения

А что, с этим хоть кто-то спорил? Это как бы первая заповедь хороших тестов.

Ладно тестировать, а переиспользовать то как?

Этот кусок я в тексте видел; тоже — никак, потому что вам это не нужно. Как и тесты.

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

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

Как заварить чай, если чайник полон? Шаг 0, опустошить чайник...

Так и переиспользуем-с

На позапрошлом дотнексте сходил на доклад "Фрактальная архитектура", чуть умом не тронулся. Астронавты-архитекторы ведут мир в ад. Просто удивительно, как раньше на Луну с программами на ассемблере летали.

Ответ на ваши опасения относительно Функциональной (а Симан в дополнение к Фрактальной топит и за Функциональную) архитектуру так же есть в посте:

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

Мне кажется про это автор упомянул в исключених. Я согласен с вами что пример возможен, но и с автором что если он возможен - вы будете знать об этом заранее и оправдать этим использование clean.

В 9 из 10 команд где я работал - был либо 1 продукт, либо 2 настолько разных продукта, что перерисовать кода все равно в них не имело смысла, там просто не может изниоткуда возникнуть внезапно второй продукт с кривыми придётся шерить кодовую базу.

Несколько реальных примеров:

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

  2. Мы пол года делали C2C стартап. Учредители поругались с инвесторами. В итоге за 2 недели пересобрали из него B2B стартап.

  3. Был у нас коллаборативный дашборд и никак не связанный с ним картографический сервис. Пара дней и уже можно вместе нарисовать прямо по карте.

Но да, 9 из 10 команд так не умеют, поэтому им даже не ставят подобные задачи.

Это же надо в абстрактное мышление, такое не всем доступно :)

Спасибо, интересная статья.
Не хотел бы влезать в детали (у нас разные области), но хорош даже сам формат обсуждения - без фанатизма разбирать аргументы сторон и прикладывать их к своему опыту и примерам. Искать области применимости, как и ограничения.

Самая важная мысль тут - DIP не бесплатен.

Ее хорошо осознать бы многим.
В более широком смысле многие архитектурные приемы не бесплатны.

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

Да вообще писать программы на высокоуровневых языках дело такое - не бесплатное. Мы жертвуем скоростью работы кода в пользу скорости разработки программы. И с ООП в общем-то тоже самое, в том числе с DI - мы немного жертвуем скоростью работы кода в пользу удобства разработки, тестирования и т.д.

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

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

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

Так собственно поэтому и придумали со временем много дополнительных парадигм программирования, таких как KISS и YAGNI, а не ограничились одним только волшебным SOLID )

Мало что принесло бизнесу столько вреда, как YAGNI. Да и KISS слишком многие понимают как «фу, декоратор — это слишком сложно, будем прямо из сущности джейсон строить».

Спорно. Эти парадигмы скорее о том, что если не планируется расширение функциональности, то и не нужно на это закладываться, тратить на это лишние ресурсы. У меня уже неоднократно было, что продукты, в которых вложены немалые ресурсы, вообще выводились из эксплуатации через какое-то время. Был ли смысл упираться и делать их идеальными, расширяемыми и т.д.? Заранее не угадать. Но всегда нужно иметь в виду разные варианты развития бизнес-сценариев.

если не планируется расширение функциональности, то и не нужно на это закладываться, тратить на это лишние ресурсы

Вот я и говорю, что принесло кучу вреда. Чтобы заложиться на расширение функциональности — никакие лишние ресурсы тратить не нужно. Достаточно писать более-менее адекватный код (а не идеальный). Расширяемость обеспечивается очень просто, я бы сказал — тривиально. Просто не надо ее реализовывать сразу.

Простой, как березовое полено, принцип dependency injection вместо приколоченных гвоздями кросс-зависимостей — решает примерно 99% проблем расширяемости совершенно бесплатно. Псевдокод:

# неправильно
function getTemperature(city) {
  return WeatherService.getTemp(city)
}

# правильно
function getTemperature(service = WeatherService, city) {
  return service.getTemp(city)
}

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

Проще WeatherService передавать в конструктор класса, а не в параметр функции.

Проще, если в вашем языке есть классы.

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

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

Я имел в виду принцип, а не конкретную реализацию.

Простой, как березовое полено, принцип dependency injection вместо приколоченных гвоздями кросс-зависимостей — решает примерно 99% проблем расширяемости совершенно бесплатно.

DI отнюдь не бесплатен. По крайней мере - тот, что в ASP.NET Core: он берет свою плату тем, что маскирует зависимость - то есть, при чтении кода (и даже при компиляции) он не дает просто взять и узнать, каким именно классом реализован внедряемый через него сервис.

У меня есть хобби - читать исходники ASP.NET Core. DI там много, и реализуется он через контейнер сервисов: при конфигурировании приложения реализации сервисов добавляются в него, а затем пеоедаются классам (обычно, через параметры конструкторов классов, но кое-где - и через параметры методов). Так вот, этому моему хобби DI вполне успешно мешает: приходится разыскивать, где именно интересующий сервис добавляется в контейнер сервисов. А с учетом привычки разработчиков ASP.NET Core добавлять сервисы крупными блоками в отдельных методах расширения (типичное название такого метода - AddЧтотоТам ), и учитывая, что таких методов может быть много, разыскать добавление сервиса не всегда тривиально.

В вашем варинте DI - протаскивание через параметры вместо использования контейнера - эта проблема стоит не так остро. Но зато, как написал выше @nin-jin , вместо нее есть другая: протаскивать зависимости через длинные цепочки вызовов. Особенно это занятие становится интересным и даже творческим, когда нужный вызов метода делается как обратный вызов из внешнего кода (фреймворка или библиотеки).

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

Вы не верите в длинные цепочки вызовов или в обращение к зависимости лишь на самом её конце?

Почему же не верю? — Верю. Я и не такое говнище повидал в своей жизни. Особенно в недоязыках.

Завязывайте уже какашками метаться и начинайте взрослеть, если хотите, чтобы ваши слова имели хоть какой-то вес.

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

В ASP.NET Core такое количество интерфейсов обусловлено расширяемостью. Вы буквально можете вклинить свой код куда угодно. Это особенности разработки фреймворка.

Вы разрабатываете фреймворк? Или Вы используете dependency inversion? Или у вас в данный момент есть несколько реализаций которые необходимо подкидывать в рантайме? Если нет, зачем вам интерфейс - прокидывайте конкретный класс. А можете наклепать интерфейсов "на будущее" и потом распутывать граф зависимостей. Может в итоге это окупит себя, когда например вам нужно будет обмазать все приложение логами и аналитикой используя декораторы. А может и нет. Универсального решения нет, только опыт.

Все так, но неплохо было бы регулярно повторять, что ни KISS, ни YAGNI, ни SOLID - не абсолюты.
Что KISS придуман не чтобы максимально простой говнокод писать из возможных, а чтобы в архитектуре не заблудиться. Он не означает, что файлы не нужно декомпозировать.
А YAGNI не означает, что это не понадобится в любом случае. Наоборот если ты уже 100 раз делал типичный сервер и знаешь, что узлы типа балансера, логов, сериализации и репозитория типичны, и даже если не используются, их стоит завести. Это, кстати, будет тебе довольно дешево

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

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

Многослойная архитектура как раз для того и делается, чтобы уйти от жёсткости и хрупкости. И "архитектурный налог" платится за то, что разработчики смогли уйти от жёсткости и хрупкости.

Тут нужно отметить, что не всегда эта плата идет в зачет.
Иногда это просто культ карго.
Слои есть, но что толку, если декомпозиция отвратительная, а их прошивают различные костыли (в полном соответствии с направлением зависимостей)
Но да, определенно для интеграции в одном проекте 30 разработчиков нужно платить совершенно другую цену, нежели при работе 4-х

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

 которую использую в качестве дефолтной последние 3 года и пока что доволен.

Блин. Ну если Вы используете это уже три года, то где настоящие боевые примеры? Зачем в очередной раз сравнивать hello worldы? Мы их не пишем на работе. Мы там решаем проблемы. Не интересно мне почему чистая архитектура плохая, дайте мне удобный инструмент, но вы вместо того чтобы его показать, просто рассказываете что он хороший и лучше чистой архитектуры.

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

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

Давайте, вот вопросы от меня.

    public long fu(long id) {
        var a = aRepo.findById(id).orElseThrow();
        if(a.type() == PRIME) {
          throw new FuException();
        }      
        var b = bRepo.findById(a.bId()).orElseThrow();
        if(b.score() > 4) {
          throw new FuException();
        }
        var c = cService.compute(a, b);
        if(c.dest().equals("direct")){
          systemXClient.send(c);
        }
        var g = (a.price() + b.fine()) / c.cf();
        var f = mClient(a.id);
        var h = g * f;
        if (hClient(h).getStatus() == PROBLEM){
          throw new FuException();
        }
        var counterItem = hRepo.findCounterItem(h.id()).orElseThrow();
        h.setStatus(READY);
        counterItem.setStatus(READY);
        return counterItem.counterId();
    }

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

Я бы переписал этот код как-то так (я не особо знаком с явой):

    public long fu( long id ) {
      
        var a = _.aRepo.getById( id );
        a.type().forbid( AType.PRIME );
          
        var b = a.getB();
        _.enforce.greater( b.score(), _.bLimit );
      
        var c = _.cService.compute( a, b );
        if( c.dest().equals( CDest.DIRECT ) ) _.systemXClient.send( c );

        var g = ( a.price() + b.fine() ) / c.cf();
        var f = _.mClient( a.id );
        var h = g * f;
        _.hClient( h ).getStatus().forbid( HStatus.PROBLEM );
          
        var counterItem = _.hRepo.getCounterItem( h.id() );
        // h.setStatus(READY); ???
        counterItem.setStatus( CStatus.READY );
        return counterItem.counterId();
      
    }

По сути ничего не поменялось. Оно и не удивительно, мало обработки, много оркестрации. Из действительно полезного имеет смысл вынести функцию расчета g и f во что-то, что имеет смысл в контексте бизнеса.

Далее вкусовщина:

  • Вынести получение и валидацию a & b в отдельную функцию

  • Вынести обновление counterItem в отдельную функцию

  • (С заменой флоу на событийно ориентированную) Отправка в systemXClient выглядит как сайд эффект, стоит рассмотреть возможность реализации через интеграционный ивент cComputedEvent

  • (С заменой флоу на событийно ориентированную) Обновление каунтера выглядит как сайд эффект, возможно стоит рассмотреть возможность реализации через интеграционный ивент hCompletedEvent

  • (С заменой флоу) Далее уже продолжать логику с момента counterReadyEvent.

  • В целом стоит подумать о согласованности, устойчивости и идемпотентности. Предположим при получении каунтера упала сеть. Насколько устойчива ваша система к этому рассогласованию. Одно дело, когда система находится в рассогласованном состоянии 10милисикунд, пока вы получаете каунтер, а другое часы, пока восстанавливается сеть. Какие процессы это аффектит. А можете ли вы безопасно перезапустить функцию fu, чтобы согласовать систему или придется мануально согласовывать. Если с этим всё ок, то код нормальный.

Можно улучшить по шагам

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

Упор на правильное управление зависимостями. Упор на явное выделение побочных эффектов (I/O, запросы к БД и API).

1) Выделяем объекты моделей явно, например:

// чистые доменные модели и DTO:
record ItemA(long id, ItemType type, long bId, float price) {}
record ItemB(long id, int score, float fine) {}
record ComputationResult(String dest, float cf) {}
record BusinessLogicInput(ItemA a, ItemB b, ComputationResult c, float factoredValue, StatusCheck hStatus) {}
record BusinessLogicOutput(long counterItemId, BusinessLogicStatus status) {}

enum BusinessLogicStatus { READY, PROBLEM, INVALID_TYPE, EXCEEDED_SCORE }
enum ItemType { PRIME, NORMAL }
enum StatusCheck { PROBLEM, OK }

2) "Чистая" бизнес-логика становится отдельным сервисом/функцией:

// чистый доменный слой (никаких репозиториев, никаких внешних систем!)
class FuBusinessLogic {
    public static BusinessLogicOutput process(BusinessLogicInput input, CounterItem counterItem) {
        if (input.a().type() == ItemType.PRIME)
            return new BusinessLogicOutput(0, BusinessLogicStatus.INVALID_TYPE);

        if (input.b().score() > 4)
            return new BusinessLogicOutput(0, BusinessLogicStatus.EXCEEDED_SCORE);

        if(input.c().dest().equals("direct")){
            // Вероятно, сигнал что нужно отправить во внешний сервис (потом сделает оболочка)
            // Здесь просто ставим готовность, решение об отправке - оболочка
        }

        if (input.hStatus() == StatusCheck.PROBLEM)
            return new BusinessLogicOutput(0, BusinessLogicStatus.PROBLEM);

        counterItem.setStatus(Status.READY);
        return new BusinessLogicOutput(counterItem.counterId(), BusinessLogicStatus.READY);
    }

    // Отдельно высчитываются вычисления (чистая логика)
    public static float calculateG(ItemA a, ItemB b, ComputationResult c) {
        return (a.price() + b.fine()) / c.cf();
    }

    public static float calculateH(float g, float f) {
        return g * f;
    }
}

3) "Императивная оболочка" выполняет грязную работу, чётко показывая процесс внешних интеграций и использования чистой бизнес-логики:

class FuService {

    public long fu(long id) {
        // оболочка явно делает общение с внешними источниками:
        var a = aRepo.findById(id).orElseThrow(NotFoundException::new);
        var b = bRepo.findById(a.bId()).orElseThrow(NotFoundException::new);
        var c = cService.compute(a, b);

        var g = FuBusinessLogic.calculateG(a, b, c);
        var f = mClient(a.id());
        var hValue = FuBusinessLogic.calculateH(g, f);
        var hStatus = hClient(hValue).getStatus();
        var counterItem = hRepo.findCounterItem(hValue.id()).orElseThrow(NotFoundException::new);

        // Формируем явный вход для чистой логики
        var input = new BusinessLogicInput(a, b, c, hValue, hStatus);

        // вызываем чистую логику, которая решает, что делать дальше
        var result = FuBusinessLogic.process(input, counterItem);

        if(result.status() != BusinessLogicStatus.READY){
            throw new FuException("Ошибка: " + result.status());
        }

        if(c.dest().equals("direct")){
            // императивная оболочка сама отправляет во внешний сервис (это её ответственность)
            systemXClient.send(c);
        }

        return result.counterItemId();
    }
}

Почему это решение превосходит исходный подход:

Очень лёгкое тестирование бизнес-логики: Чистая логика теперь отдельно, можно покрыть unit-тестами (JUnit 5, Mockito и т.д.).

Чёткое разделение ответственности: внешние процессы интеграций и IO в явной оболочке, чистый домен отдельно.

Легче расширять: ясно видно, где добавляются внешние вызовы, где сама логика.

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

Изоляция от внешних зависимостей: легче заменять сервисы и БД без изменения бизнес ядра.

Принцип Single Responsibility: каждый класс и каждый метод имеет понятную единственную ответственность.

P.S. без конкретной бизнес логики это все кажется как пустое раздувание кода втрое, но это не так(С)

P.P.S. А, ну да, джава же так чисто не умеет

Вот rust

// core_logic.rs
use crate::data::*;

pub fn calculate_g(a: &ItemA, b: &ItemB, c: &ComputationResult) -> f32 {
    (a.price + b.fine) / c.cf
}

pub fn calculate_h(g: f32, f: f32) -> f32 {
    g * f
}

pub fn process_logic(
    a: &ItemA,
    b: &ItemB,
    c: &ComputationResult,
    h_status: StatusCheck,
) -> Result<Status, BusinessLogicError> {
    match a.item_type {
        ItemType::Prime => Err(BusinessLogicError::InvalidType),
        ItemType::Normal if b.score > 4 => Err(BusinessLogicError::ExceededScore),
        ItemType::Normal if matches!(h_status, StatusCheck::Problem) => Err(BusinessLogicError::ProblemStatus),
        _ => Ok(Status::Ready),
    }
}

(⁎˃ᆺ˂)

Повышение читабельности и поддержки

Ну это я даже комментировать не буду.

У меня другой вопрос. Зачем Вы изменили бизнес-логику моей функции, попросил написать в вашем стиле а не натянуть бизнес-логику на Ваш стиль.

        var a = aRepo.findById(id).orElseThrow();
        if(a.type() == PRIME) {
          throw new FuException();
        } 

Здесь идет выход из функции сразу как только условие не соблюдено.

        // оболочка явно делает общение с внешними источниками:
        var a = aRepo.findById(id).orElseThrow(NotFoundException::new);
        var b = bRepo.findById(a.bId()).orElseThrow(NotFoundException::new);
        var c = cService.compute(a, b);

        var g = FuBusinessLogic.calculateG(a, b, c);
        var f = mClient(a.id());
        var hValue = FuBusinessLogic.calculateH(g, f);
        var hStatus = hClient(hValue).getStatus();
        var counterItem = hRepo.findCounterItem(hValue.id()).orElseThrow(NotFoundException::new);

        // Формируем явный вход для чистой логики
        var input = new BusinessLogicInput(a, b, c, hValue, hStatus);

        // вызываем чистую логику, которая решает, что делать дальше
        var result = FuBusinessLogic.process(input, counterItem);

У нас перед первой же проверкой на которой все должно остановиться Вы скачиваете половину базы данных и дергаете несколько сервисов хотя эти данные уже не нужны так как a не валиден.

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

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

Технически (в нормальных языках :troll:) это можно сделать так:

val a by lazy { aRepo.findByIdOrNull(id) ?: throw NotFoundException() }
// ...

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

PS>

Ну а если у вас Прям Нормальный Язык для ФА (Haskell) - оно всё автоматом будет лениво тянуться

то где настоящие боевые примеры?

Здесь, здесь, здесь и здесь

дайте мне удобный инструмент

Вот он

Приведите пожалуйста

Чуть позже

Приведите пожалуйста пример как...

Чуть позже

На самом деле это довольно сложно в имеющихся условиях.

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

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

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

В-третьих, в вашем примере практически нет бизнес-логики.

Но тем не менее, давайте я попробую.

Что бы я точно сделал:

  1. Собрал вычисление h в отдельный метод

  2. Сделал бы данные неизменяемыми, а сохранение обновлений явным

Возможно ещё обернул бы логические выражения проверок в методы с вменяемыми именами, особенно для b и c:

    public long fu(long id) {
        var a = aRepo.findById(id).orElseThrow();
        if(a.type() == PRIME) {
            throw new FuException();
        }
        var b = bRepo.findById(a.bId()).orElseThrow();
        if(b.isFour()) {
            throw new FuException();
        }
        var c = cService.compute(a, b);
        if(c.isDirect()){
            systemXClient.send(c);
        }
        var h = h(a, b,c);
        if (hClient(h).getStatus() == PROBLEM){
            throw new FuException();
        }
        var counterItem = hRepo.findCounterItem(h.id()).orElseThrow();
        var readyH = h.withStatus(READY);
        var readyCounter = counterItem.withStatus(READY);
        hRepo.save(readyH);
        hRepo.save(readyCounter);
        return counterItem.counterId();
    }

    public H h(A a, B b, C c) {
        var g = (a.price() + b.fine()) / c.cf();
        var f = mClient(a.id);
        return g * f;
    }

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

Публичных примеров прям кода прям с бизнес-логикой у меня ещё нет. Но есть:

  1. Пример кода оркестрации и графа вызовов операции для операции со сложной бизнес-логикой

  2. Пример построения сложной модели представления

    1. Тривиальная оркестрация

    2. Развесистая логика на чистых функциях

  3. Пример построения docx-дока

    1. Тривиальная оркестрация

    2. Относительно развесистая логика построения в виде чистой функции

      1. Тут код - прям наскоряк написанный говнокод - это был прототип фичи, которая оказалось не востребованной, поэтому рефакторить я его не стал. Но это чистый говнокод - если ему в fetchImage подсунуть Map::get - он для одних и тех же аргументов всегда будет возвращать один и тот же результат.

Глянул пример 2. Там на каждый чих происходит пересоздание всего мира модели страницы. Теперь понятно почему приложения на Яве такие тормозные. Небось и ререндер происходит всего экрана, а не только изменившейся его части.

Завязывайте уже какашками метаться и начинайте взрослеть, если хотите, чтобы ваши слова имели хоть какой-то вес.

@nin-jin, комментарий к этому посту

:)

Там на каждый чих происходит пересоздание всего мира модели страницы.

Судя по этому сообщению, вы, путаете клиентский и серверный рендеринг:) Это такая технология древних, когда браузер отправляет HTTP-запросы, получает HTML и, действительно, ререндерит всю страницу. Вообще без JS-а.

Советую ознакомится с HTMX и идеями за этой либой - позволяет делать "good enough" UI за очень дёшево.

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

Ах, да, не заметил, тут же чистое ядро зачем-то гвоздями прибито к Spring Framework, да ещё и к конкретной вёрстке из therapist/appointments/schedule.html.

Не правильно.

идеи не годятся

Идеи тащтельного проектирования АПИ и разделения ио и логики - универсальные и годятся веде.

весь код придётся выбросить в трубу

Код серверного рендеринга - естественно придётся выкинуть в трубу при разработке десктопного клиента.

Код бизнес-логики от вида клиента никак не зависит и будет работать и дальше.

Если вы захотите запустить весь сервис на дескотопе в одном процессе - выкинуть придётся код контроллеров (связывающий HTTP и бизнес-логику).

Если вы при этом захотите отказаться и от реляционки (хотя бы в виде SQLlite) в пользу файликов - так же придётся написать занового код репозиториев (связывающий бизнес-логику с постоянным хранилищем)

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

Да ну как сказать. React (да и Compose на андроиде) именно так и делает, насколько я знаю, и не чё "пипл юзает".

Да, полагаю, под капотом они хачат реальную модель "на месте", но сами UI компоненты и их состояние (с Redux-ом по крайней мере) с точки зраения разработчика - чистые функции и неизменяемые данные. Опять же насколько знаю, сам ни на React, ни на Compose не писал ничего.

Но вообще я возьму самоотвод - UI в целом и его рендеринг в частности - не моя специализация.

Я бегло посмотрел Вашу статью и в целом понимаю что вы хотите сделать.
Но незадача в том что относительно адекватных альтернативных решений задач наподобие того примера что я скинул я не знаю.
Суть проблемы. У вас есть единое бизнес правило. Вы хотите вынести из него IO и оставить только computation часть. Это я понял. Проблема в том что в большинстве случаев computation и IO логика перемешана и что еще хуже находиться в зависимости друг от друга.Вы не можете просто так взять и вынести куски IO наверх так как от них зависят computation части. Из того что я увидел в Ваших примерах, Вы пытаетесь хитрить и выполнять IO операции перед чистой функцией, но это не верно - как в моем примеру возможно после первого чтения нужно закончить операцию или что еще веселее, от результата одного IO после computation будет рeзультат другого IO.

a = read()
b
if a.x == 1
write(a.x)
b = a.b
else
b = read(a.b)

Здесь каждая операция зависит от другой. Код сцеплен бизнес логикой и ничего вы с этим не сделаете. Где-то у вас получиться жадно прочитать кучу данных чтобы при первой ошибке выкинуть все на помойку а где-то просто бизнес логика не позволит это сделать. А это значит что?

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

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

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

Да я тоже думал про такое. Но мне кажется это уже переусложнение + всегда есть риск что кто-то что-то наверху забудет довызывать

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

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

А вот штука, которой можно достичь практически всегда - это сендвич - io -> logic -> io -> logic -> io.

Но большой вопрос стоит ли оно того.

Плюс по моим канонам, сам io на внутри себя и на своём уровне абстракции может содержать логику. Вот тут у меня описан пример такого случая.

Вот ещё пара ссылок по теме, как упоровшись по ФП выжимать возможный максимум разделения ио и логики:

https://blog.ploeh.dk/2017/02/02/dependency-rejection/

https://blog.ploeh.dk/2019/12/02/refactoring-registration-flow-to-functional-architecture/

Как я писал - в вашем конкретном примере я бы ничего не стал глобально менять.

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

Разница в сложности задачи.

В вашем примере я не вижу сложной бизнес-логики, которую стоит выделять.

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

Ещё так попробую проиллюстрировать свою точку зрения.

Разделением ИО и логики я борюсь с этим:

Легенда:

  • Синие блоки - оркестрация, блоки которые взывают более одного типа других блоков и имеют когнитивную сложность <= 4

  • Красные блоки - ввод, блоки которые делают ввод и имеют когнитивную сложность <= 4

  • Зелёные блоки - бизнес-логика, блоки, которые не вызывают ввод или вывод и имеют когнитивную сложность <= 15

  • Жёлтые блоки - вывод, блоки, которые делают вывод и имеют когнитивную сложность <= 4

  • Оранжевые блоки - месиво, блоки которые и делают ио (их сложно тестить и сложно понимать из-за необходимости учитывать перформанс, обработку ошибок и порядок выполнения) и имеют когнитивную сложность > 4 (их сложно читать и хочется тестить)

Или вот ещё не раскрашенный пример:

И в идеале я стремлюсь (и очень часто у меня получается) к такому:

Но вы правы, что не всегда всё так просто и красиво. Однако достичь вот такого - вполне реально и в "волосатых" случаях:

Эта картинка как раз ближе к IOSP/IODA, чем к ФА. Ну и да, оранжевый блок я подчищу в понедельник - этот кусок только-только дописали просрав дедлайн из-за недооценки "волосатости" задачи:)

Я пожалуй даже код левого блока оркистрации скину:)

private fun updateTable(updateTableRq: UpdateTableRq): Table? =
        transactionTemplate.execute {
            if (updateTableRq.correctionData.isNullOrEmpty()) {
                return@execute null
            }

            val correctedRows = updateTableRq.correctedRows()
            val rowsExternalIds = correctedRows.map { it.externalId }.toSet()
            val table = tablesService.findTableByExternalIds(rowsExternalIds)

            if (table == null) {
                return@execute null
            }

            if (correctedRows.size != table.rows.size) {
                throw TableRowNotFoundException()
            }

            val settings =  tablesSettingsRepo.findByDocumentId(table.outgoingDocument.id)
                                      ?: throw ResourceNotFoundException()

            val referenceExternalIds = correctedRows.collectReferenceFieldsValues(settings.fields)
            val referenceIds = referencesService.findReferenceMapByExternalIds(referenceExternalIds)

            val systemName = table.rows.first().externalId.systemName
            val parsedCorrections = correctedRows.associateBy(UpdatedRowRq::externalId) {
                it.parseFieldValues(systemName, settings.apiFieldsById, referenceIds)
            }

            tablesService.updateTable(table) {
                val rowCorrections = parsedCorrections[it.externalId.externalId]
                tableRowsFactory.updateRow(settings, it, rowCorrections)
            }
        }

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

Разделением ИО и логики я борюсь с этим

Знаете в чем главная проблема? Даже не в самой архитектуре а в ее практичности. Можно построить сколько угодно сложную систему на принципах DDD, на монадах и эффектах. Но с практической точки зрения нужно чрезмерно много усилий чтобы это все поддерживать, кто-то должен этому учить, кто-то рисовать диаграммы а кто-то долго сидеть и долго думать а кто-то стоять с плетью и следить за неукоснительным соблюдением ее правил. Как только этого не будет - все развалиться. Реальность такова что к вам приду несколько типичных разработчиков после курсов а заказчик будет требовать сроки а на этом все закончиться.

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

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

Суть проблемы. У вас есть единое бизнес правило. Вы хотите вынести из него IO и оставить только computation часть. Это я понял. Проблема в том что в большинстве случаев computation и IO логика перемешана и что еще хуже находиться в зависимости друг от друга. Вы не можете просто так взять и вынести куски IO наверх так как от них зависят computation части.

Почему никто здесь не рассматривает подход, который описан в книге Фаулера 2004 года. Верхний уровень логики (application logic) занимается оркестрацией. Он обращается как к доменной логике, которая не связана с вводом-выводом, так и к другой группе функционала, которая связана только с вводом-выводом. Если необходима транзакция, то транзакцией обёртывается весь используемый элемент application logic.

Верхний уровень логики (application logic) занимается оркестрацией. Он обращается как к доменной логике, которая не связана с вводом-выводом, так и к другой группе функционала, которая связана только с вводом-выводом.

Добавьте туда модель данных в виде неизменямых классов и это ровно то, что я называю "Промышленной функциональной архитектурой".

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

Чем тогда "Промышленная функциональная архитектура" отличается от многослойной? Logic layer = application logic + domain logic. Вышележащий слой - presentation layer. Нижележащий слой - persistence layer.

ПФА - это частный случай того, что вы называете многослойной архитектуры (как и чистая, и просто функциональная), который накладывает доп. ограничение в виде неизменямых данных.

Но! В моём окружающем мире люди называют многослойной архитектуру, в которой domain logic зависит от persistence layer (который может выглядеть интерфейсами Spring Data репозиториев). И от такой вариации многослойной архитектуры, ПФА отличается ещё и запретом на обращение к persistence layer из domain logic.

На самом деле, видимо, тут же кроется и ответ на ваш первый комментарий: если делать многослойную архитектуру так, как её делают вокруг меня - она ни от чего не спасает и проекты по ней быстро превращается в большой ком грязи. Тот же Project Daniel - там были даже отдельные Maven-модули application-services и domain-services. Но это не спасло проект от превращения в большой ком грязи.

А ваша многослойная архитектура с правилом "доменная логика, которая не связана с вводом-выводом" как таки и позволяет "уйти от жёсткости и хрупкости".

Почему никто здесь не рассматривает подход, который описан в книге Фаулера 2004 года. Верхний уровень логики (application logic) занимается оркестрацией. Он обращается как к доменной логике, которая не связана с вводом-выводом, так и к другой группе функционала, которая связана только с вводом-выводом. 

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

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

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

  2. Генерация доменных событий, которые необходимо сохранять в соответствующей бд. Этот вариант можно обыграть по-разному: вносить события в бд как из доменной логики, так и из application logic.

Если воспринимать в серьез идею "а хрен с ними с интерфейсами, давайте просто вынесем всю абстрактную логику в чистые функции и сделаем все остальное фасадом", тогда можно переделать примерно так (это плюс-минус C#):

public long fu(long id) {
      var result = OrganizeSomeStrangeActivity(
         () => aRepo.findById(id).orElseThrow(),
         bId => bRepo.findById(bId).orElseThrow(),
         (a,b) = > cService.compute(a, b),
         ... чего-то про mClient и hClient, чего я не понял....

      )
      if (result.cToDirectSend!=null)
        systemXClient.send(result.cToDirectSend);
      return result.counterId;
    }

public static (C cToDirectSend, long counterId) OrganizeSomeStrangeActivity(
  Func<A> getA, 
  Func<long, B> getB, 
  Func<A,B,C> calculateC,
  Func<long, ???> mClient, //  не понял даже какого они типа и что это
  Func<???,???> hClient
) {
        var a = getA();
        if(a.type() == PRIME) {
          throw new FuException();
        }      
        var b = getB(a.bId());
        if(b.score() > 4) {
          throw new FuException();
        }
        var c = calculateC(a,b);
        var g = (a.price() + b.fine()) / c.cf();
        var f = mClient(a.id);
        var h = g * f;
        if (hClient(h).getStatus() == PROBLEM){
          throw new FuException();
        }
        var counterItem = getCounterByHId(h.id());
        h.setStatus(READY);
        counterItem.setStatus(READY);        
        return (c.dest().equals("direct")? c: null, counterItem.counterId())
}

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

Но так то конечно надо значть что за продукт и изначально строить код согласно принятой архитектуте.

тогда можно переделать примерно так (это плюс-минус C#):

Да можно так сделать, но какой смысл?
С функцией теперь неудобно работать, много параметров, при каждом вызове нужно передавать кучу сервисов и репозиториев, если добавляется сервис то меняется сигнатура. Каждый раз оборачивать в лямбды.
Плюс к этому функция становится небезопасной ибо она зависит от того какой сервис ей передадут выше. Дебажить сложнее. И как бы профитов как будто нет.

Ну, не так все плохо.

Параметров да, много, но это как бы фасад, он и не может быть простым.

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

В каком смысле она небезопасна не сумел понять.

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

Минус - уродливые сложные фасады, обеспечивающие бизнес-логике комфортное сущестование и отличии от простого и удобного решения с DI контейнерами.

Жила была функция versionService.createVersion() которая принимала id документа и список изменений в нем. Внутр сложного сервиса VersionService использовалось еще 8 сервисов репозиториев и клиентов. Но нам это не важно, потому что все это скрыто. Где бы в коде мы не вызывали createVersion() - она будет всегда работать одинаково, принимать id и список изменений и создавать версии. Нельзя вызывать ее не правильно. Если нам нужно будет изменить бизнес правила сигнатура не измениться.

Жила была другая функция createVersionV2() она помимо id и списка изменений вынуждена принимать еще 6 функций которые ей нужны, но проблема в том что функции которые используются внутри нее принимают от 2 до 8 функций каждая, в итоге бедной функции чтобы работать самой и пере-прокинуть во внутренние вызовы, нужно принять еще 25 функций-параметров. Когда мы ее вызываем то вынуждены заботиться о том где найти то что ей нужно, нужно обернуть 25 сервисов/репозиториев/клиентов, каждый раз в 25 замыканий. При этом важно не ошибиться и не передать в замыкания компоненты с похожей сигнатурой. Теперь функция не обещает что отработает нормально в не зависимости от того где вызывается, вызывающему придется попотеть. Наконец, если где-то в глубине логики, нужны будут дополнительные данные из БД то придется переписывать сигнатуры всех функций до самого верха.

Не знаю как Вам, а мне классика больше по душе.

Так разве "fu" в примере @amazingname не принимает int id? Внешний фасад остаётся createVersion(id).

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

Жила была функция versionService.createVersion() которая принимала id документа и список изменений в нем. Внутр сложного сервиса VersionService использовалось еще 8 сервисов репозиториев и клиентов.

У меня был похожий кейз. Функция, которая принимает id документа об аварии и формирует план, обследуя кучу разной информации. В основе функции очень сложный оптимизационный алгоритм, похожий на метод ветвей и границ с кучей улучшений и обходных маневром. Тут начинаем с того, что написать такую штуку в терминах домена нереально и очень плохая идея. Написал его абстрактно, как оптимизацию чего-то с возможностями получения информации откуда-то. Дальше обернул его в фасад, который готовит необходимые входные функции, используя другие входные функции, которые уже описаны в терминах домена. И уже на уровне контроллера получилась функция просто с Id на входе. 25 функций на входах-выходах внутри конечно тоже не было, было поменьше, многие вещи объединяются, если о них изначально думать абстрактно. Вызывающему фасаду конечно надо попотеть, потому что задча у него не из легких - собрать из домена абстрактную математическую модель. Вызывать функцию в голом виде везде и отовсюду конечно нет никакого смысла. Она один раз обернута в фасад и его нужно использовать всегда.

Профит здесь очевидный: если позволить алгоритму непосредтвенно лазить по сервисам и извлекать зависимости из воздуха, шансов разобраться как он работает или написать для него тесты не было бы никаких. А так мы имеем понятный "математический" класс, понятный фасад который связывает входные абстрактные структуры с доменными объектами и понятный фасад для фасада, который знает где взять все зависимости и как вытянуть все нужные данные.

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

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

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

Не знаю как Вам, а мне классика больше по душе.

Да мне в принципе тоже. Но не сказать чтобы альтернатива не имела смысла.

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

Так что если пишите один, то ЧА вам не нужна.

Ещё она помогает отделить один домен от другого, чтобы не держать в голове сразу оба. Но для этого есть и более дешёвые способы.

Труъ Чистая архитектура по анкл Бобу и Функциональная архитектура по Влашину — это одно и то же

Нет.

Разные цели и акценты. Хотя пересечение есть.

Ещё раз

Читая архитектура.

Цель: уменьшение рисков необходимости вместе с одним блоком кода менять и соседние. Средство: разные способы снижения зависимостей с конкретных классов до простых интерфейсов (но они тоже могут измениться!).

Функциональное ядро, императивная оболочка (по Влашину):

А здесь фокус уже иной. Он гораздо ближе к семантике вычислений:

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

Средство: Четкое разделение программного кода на две зоны:

Функциональное ядро: чистые функции, неизменяемые данные, только четко заданные вычисления.

Императивная оболочка: код с эффектами ввода-вывода, интеграции, управление состоянием и пр.

Акцент: Разделение чистой логики и эффектов (побочных действий).

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

Только java не лучший язык для функиональщины.

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

Интересно, а когда бывает по-другому. Если сидеть и пилить себе 10 лет продутк в тишине с приятелем, то вообще любой вариант архитектуры подойдет, абы был хоть какой-то понятный порядок.

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

Не смотря на то, что во много я согласен с автором, некоторые выводы мне кажутся странным. Например,

В своих иллюстрациях анкл Боб лукавит — почему‑то в случае плохого дизайна у него в API механизмов фигурируют слова «Keyboard» и «Printer», а в случае хорошего дизайна — они чудесным образом исчезают.

Он не лукавит. В этом суть понятия "абстракция". Поинт абстрагирования не в том чтобы написать abstract перед классом, а в том чтобы скрыть неважные детали. Там где вы предлагаете проектировать API - вы буквально предлагаете заняться абстрагированием.

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

При этом механические отличия всё ещё остаются. Если вы "спроектировали API", но не положили интерфейс туда где он используется, в следующей версии нет никаких проблем добавить туда публичных методов, завязанных на детали, а потом начать юзать. Выделение интерфейса как раз помогает отделить API от всего остального. Расположение интерфейса рядом с БЛ - помогает следить что мы не используем какие-то детали вроде библиотеки кассандры в этом API.

Да, у этого есть цена, тут нет вопросов.

Ну то есть вы имплементите, по сути, тот же клин, но нормально, без традиционных ошибок, допускаемых теми кто его плохо делает, а виноват дядя Боб? :)

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории