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

January 18, 2025

Введение

Сейчас среди Java/Kotlin команд распространено применение Чистой (ака Гексагональной, ака Луковой - Clean, Hexagonal, Onion) архитектуры для разработки бакэндов прикладных приложений (да и Android-приложений тоже). Однако это семейство архитектур в контексте прикладной разработки зачастую не даёт никаких преимуществ, а только привносит лишние церемонии и тем самым замедляет её.

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

Но перед тем как перейти к Чистой архитектуре, сначала надо разобрать принцип инверсии зависимостей (Dependency Inversion Principle, DIP).

Принцип инверсии зависимостей

Определение

Принцип инверсии зависимостей — это один из принципов SOLID, самого известного набора принципов объектно-ориентированного дизайна, разработанного Робертом Мартином, так же известным как дядюшка или анкл Боб. Формулировка принципа звучит так:

  1. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS.
  2. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS.

  1. МОДУЛИ ВЫСОКОГО УРОВНЯ НЕ ДОЛЖНЫ ЗАВИСЕТЬ ОТ МОДУЛЕЙ НИЗКОГО УРОВНЯ. И ТЕ И ДРУГИЕ ДОЛЖНЫ ЗАВИСЕТЬ ОТ АБСТРАКЦИЙ.
  2. АБСТРАКЦИИ НЕ ДОЛЖНЫ ЗАВИСЕТЬ ОТ ДЕТАЛЕЙ. ДЕТАЛИ ДОЛЖНЫ ЗАВИСЕТЬ ОТ АБСТРАКЦИЙ.
Robert Martin, The Dependency Inversion Principle

Признаки кода с «плохим дизайном»

В своей оригинальной статье о принципе инверсии зависимостей анкл Боб предлагает три признака кода с «плохим дизайном»:

A piece of software that fulfills its requirements and yet exhibits any or all of the following three traits has a bad design.

  1. It is hard to change because every change affects too many other parts of the system. (Rigidity)
  2. When you make a change, unexpected parts of the system break. (Fragility)
  3. It is hard to reuse in another application because it cannot be disentangled from the current application. (Immobility)

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

  1. Её трудно изменить, потому что каждое изменение затрагивает слишком много других частей системы. (Жёсткость)
  2. Когда вы вносите изменения, ломаются неожиданные части системы. (Хрупкость)
  3. Её трудно повторно использовать в другом приложении, потому что её невозможно отделить от текущего приложения. (Неподвижность)
Robert Martin, The Dependency Inversion Principle

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

Как часто вам хотелось переиспользовать бизнес-логику в другом контексте (другом приложении)? Притом не какую-то универсальную, вроде аутентификации, загрузки файлов или отправки письма для сброса пароля, а какой-нибудь хардкор — тот же набивший уже оскомину перевод денег между счетами в банке? Да ещё и, забегая вперёд, сменив в новом контексте надёжный™ Oracle на хипстерскую™ MongoDB или Cassandra?

В моей двадцатилетней практике такого не было ни разу. Аутентификацию, хранение файлов и отправку емейлов — да, регулярно переиспользую. И то методом copy-and-paste (и ни разу об этом не пожалел).

А вот код доменно-специфичной бизнес-логики — ни единого раза.

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

Меж тем анкл Боб опирается именно на переиспользование бизнес-логики в разных приложениях для того, чтобы обосновать DIP.

Пример кода с «плохим дизайном»

Для того, чтобы обосновать применение DIP, Мартин берёт такой код в качестве примера «плохого дизайна»:

2025 01 19 11 00 52
void Copy() {
    int c;
    while ((c = ReadKeyboard()) != EOF)
        WritePrinter(c);
}

И пинает его за то, что «бизнес-логика» (копирование символов) «неподвижна».

С тем, что эта «бизнес-логика неподвижна» - не поспоришь. Но подвижность, как мы договорились выше, для нас особой ценностью не является.

А что не так с «жёсткостью» и «хрупкостью» этого кода? Да на самом деле всё так и, чтобы притянуть за уши проблемы с ними, анкл Боб говорит:

For example, consider a new program that copies keyboard characters to a disk file. Certainly we would like to reuse the “Copy” module since it encapsulates the high level policy that we need. i.e. it knows how to copy characters from a source to a sink. Unfortunately, the “Copy” module is dependent upon the “Write Printer” module, and so cannot be reused in the new context.


Например, рассмотрим новую программу, которая копирует символы клавиатуры в файл на диске. Конечно, мы хотели бы повторно использовать модуль "Copy", поскольку он инкапсулирует необходимую нам политику высокого уровня. То есть он знает, как копировать символы из источника в приемник. К сожалению, модуль "Copy" зависит от модуля "WritePrinter" и поэтому не может быть повторно использован в новом контексте.

Robert Martin, The Dependency Inversion Principle

А для того чтобы всё-таки использовать модуль "Copy" в новой программе, он предлагает такой код:

enum OutputDevice {printer, disk};

void Copy(outputDevice dev) {
    int c;
    while ((c = ReadKeyboard()) != EOF)
        if (dev == printer)
            WritePrinter(c);
        else
            WriteDisk(c);
}

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

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

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

И он не просто «не нужен», в контексте прикладной разработки DIP даже чуть-чуть вреден.

DIP не бесплатен

Далее, в качестве «Хорошего дизайна» анкл Боб предлагает следующий:

2025 01 19 11 28 53
class Reader {

    public:
        virtual int Read() = 0;
};

class Writer {

    public:
        virtual void Write(char) = 0;
};

void Copy(Reader& r, Writer& w) {
    int c;
    while((c=r.Read()) != EOF)
        w.Write(c);
}

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

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

Да, это всё копейки, но сто копеек — уже рупь. И платим мы его ни за что и можем не платить, не потеряв ничего. Или потеряв?

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

Проектирование API вместо кодирования интерфейсов

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

Но никто же не мешает хорошему разработчику сделать так:

sd good
void Copy() {
    int c;
    while ((c = Read()) != EOF)
        Write(c);
}

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

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

ood bad
class KeyboardReader {

    public:
        virtual int Read() = 0;
};

class PrinterWriter {

    public:
        virtual void Write(char) = 0;
};

void Copy(KeyboardReader& r, PrinterWriter& w) {
    int c;
    while((c=r.Read()) != EOF)
        w.Write(c);
}

И тогда для перевода модуля "Copy" на работу с диском, его придётся "потрогать" хотя бы для того, чтобы ошибку в имени интерфейса исправить. А то и весь интерфейс придётся переделывать.

Притом не обязательно настолько грубо лажать. Достаточно лишь, например, в контракте репозитория "между строк пообещать", что он возвращает сущности, для которых автомагически выполняется ленивая загрузка и сохранение изменений (dirty checking). Или выставить в интерфейс механизма тип из библиотеки, используемой в её текущей реализации — условный Pageable из Spring.

И в этом случае DIP и интерфейс (абстрактный класс) между бизнес-логикой и репозиторием не спасут вас. Если вы захотите переехать с Oracle на Cassandra — вам придётся потрогать всю бизнес-логику, чтобы она научилась обходится без ленивой загрузки и дёти-чекинга. Вариант, что вы в контексте прикладной разработки сделаете полноценный ORM (OWCM - Object-wide-column-mapper?) поверх Cassandra, я исключаю как нереалистичный.

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

Статическая работа с несколькими механизмами

Опытный читатель спросит: «А если я не могу одномоментно переключиться на новый механизм, и мне надо, чтобы в рантайме в зависимости от определённых условий использовался то старый, то новый?».

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

Для большей конкретики, давайте рассмотрим такой код хранилища файлов:

class FilesStorage(private val jdbcTemplate: JdbcTemplate) {

    fun saveFile(id: UUID, content: InputStream) {
        jdbcTemplate.execute("INSERT INTO files ...")
    }

    fun getFile(id: UUID): InputStream? {
        return jdbcTemplate.query("SELECT content FROM files ...")
                   .getBlob("content")
    }

}

И представим, что наш прототип стрельнул, нагрузка выросла, и мы решили перейти на хранение файлов в Minio.

Но даже в этом случае нам не нужен DIP и промежуточный интерфейс.

Для начала мы реализуем новый класс MinioFilesStorage, затем код из FilesStorage перенесём в новый класс JdbcFilesStorage, а сам FilesStorage перепишем так:

class FilesStorage(
    private val jdbcFilesStorage: JdbcFilesStorage,
    private val minioFilesStorage: MinioFilesStorage,
) {

    fun saveFile(id: UUID, content: InputStream) {
        minioFilesStorage.saveFile(id, content)
    }

    fun getFile(id: UUID): InputStream? {
        return minioFilesStorage.getFile(id)
                  ?: jdbcFilesStorage.getFile(id)
    }
}

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

Выходит, работать с несколькими механизмами одновременно мы можем и без лишних интерфейсов и DIP-а.

Конфигурируемая работа с нескольким механизмами

И тут на сцену должен выйти самый опытный читатель и сказать: «А вот я как-то делал коробочный продукт, который деплоился силами и на инфраструктуре клиентов, и к нам как-то пришёл жирный заказчик с чемоданом денег и сказал, что хочет использовать Cassandra вместо Postgres для хранения данных. И как тут выкрутится без DIP?».

Никак. Вот тут он вам наконец нужен.

Но, если API слоя персистанса спроектирован качественно (через него не текут детали реализации), то, в случае появления необходимости работы с несколькими хранилищами данных, внедрить DIP можно постфактум одним движением руки — рефакторингом "Extract Interface" в IDEA.

Да, этого движения можно было бы избежать, если сразу завести интерфейс, но:

  • Вы бы платили всё это время копеечку за интерфейс;
  • Клиент мог и не прийти;
  • Это движение — капля в море, на фоне трудозатрат на повторную реализацию слоя персистанса.

И более того, как я писал в Проектирование API вместо кодирования интерфейсов DIP и интерфейсы сами по себе не спасли бы в этом случае.

Например, если API слоя персистанса — это интерфейсы Spring Data JPA репозиториев, и вся бизнес-логика полагается на ленивую загрузку и автоматическое сохранение изменений, то от жирного клиента и чемодана денег придётся отказаться. Потому что даже чемодана денег не хватит на то, чтобы переписать всё приложение.

И тут мы, наконец, приходим к Чистой архитектуре.

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

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

В одноимённой книге анкл Боб определяет Чистую архитектуру иллюстрацией:

2025 01 19 16 03 04

И «Правилом зависимостей»:

The overriding rule that makes this architecture work is the Dependency Rule: Source code dependencies must point only inward, toward higher-level policies.


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

Robert Martin, Clean Architecture

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

Чистая архитектура вводит в ваш проект «архитектурный налог» — как минимум вам придётся тратить время на создание, актуализацию, компиляцию и пересылку кода интерфейсов.

А, если вы решите реализовать Чистую архитектуру честно, и хотя бы в слоях сущностей и юзкейсов исключите зависимости от фреймворков, то «архитектурный налог» будет ещё выше. В этом случае помимо дополнительных интерфейсов, вам придётся ещё и написать модель фактически четыре раза: один раз в слое персистанса, один раз в слое бизнес-логики и по разу для маппинга туда и обратно (хотя условный Mapstruct сильно сократит код маппинга). А ещё надо будет завести собственные аналоги @Transactional, @EventListener, @Cachable и т. д. с их обработчиками, пускай и просто делегирующими вызовы в Spring.

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

Также есть гуманитарный случай, когда Чистая архитектура может быть полезна: если у вас большая команда, с большой текучкой, непостоянным уровнем квалификации и перегруженным лидом. В этом случае честная реализация Чистой архитектуры с выделением слоёв сущностей и юзкейсов в отдельный(е) Gradle/Maven-модуль может помочь повысить качество дизайна API (но и это не гарантирует качественный дизайн).

Например, возвращаясь к примеру с поддержкой Cassandra, если бы бизнес-логика была в отдельном модуле, у которого в зависимостях нет ни JPA, ни Spring Data, то для того, чтобы предоставить контракт с ленивой загрузкой и автоматическим сохранением изменений — их пришлось бы реализовать самостоятельно. И вряд ли команда бы это сделала, соответственно в контракт они бы не утекли, добавить поддержку Cassandra было бы возможно и удалось бы заполучить жирного клиента и чемодан денег. Профит.

Таким образом, мы приходим к тому, что Чистая архитектура полезна только в двух случаях:

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

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

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

Но сделав их, я офигел от "архитектурного налога". И перешёл на один из вариантов Функциональной архитектуры.

Функциональная архитектура

Определение

В отличие от Чистой, у Функциональной архитектуры нет единого авторитетного источника. Но в целом, можно охарактеризовать Функциональную архитектуру следующей иллюстрацией:

2025 01 19 17 08 02

И несколькими правилами:

  1. Все зависимости должны быть направлены в сторону чистого ядра (Functional Core) — аналог правила зависимостей;
  2. Чистое ядро должно быть чистым в функциональном смысле - состоять из неизменяемых структур данных и чистых функций (функций, не выполняющих ввод-вывод, изменение данных и обращений к глобальному окружению);
  3. Максимум кода должен находиться в чистом ядре.

И если приглядеться (или прищуриться) — можно разглядеть много общего между Функциональной и Чистой архитектурами.

Чистое ядро может быть аналогом сущностей (Entities) в терминах Чистой архитектуры.

А императивная оболочка, в которую попадает весь ввод-вывод — это аналог адаптеров интерфейсов (Interface Adapters, но далее я буду использовать более распространённый термин — гейтвей) в терминах чистой архитектуры.

С аналогом юзкейсов же (у которого нет общепринятого названия, моя версия — оркестрация, можно ещё встретить варианты: workflow, операция алгебры) всё уже не так очевидно.

Есть хардкорные реализации Функциональной архитектуры на IO-монадах, в которых функции оркестрации являются чистыми. Концептуально он довольно сложен, поэтому в этом посте я его описывать не буду, а если интересно, то можно почитать в Functional and Reactive Domain Modeling, From Objects to Functions или Functional Design and Architecture, например. И, соответственно, я не представляю, как такой вариант затащить в свою практику коммерческой разработки — людей, которые могут или хотя бы хотят писать в таком стиле, не найдёшь днём с огнём.

Есть более приземлённый вариант реализации Функциональной архитектуры «по Влашину» без IO-монад, описанный в его книге Domain Modeling Made Functional и посте A primer on functional architecture.

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

И в таком исполнении Функциональная архитектура является строгим подмножеством Чистой архитектуры*, в котором на слои юзкейсов и сущностей наложено дополнительное ограничение в виде чистоты в функциональном смысле. А если учесть, что анкл Боб больше 10 лет программирует на Clojure и пишет посты с одами ФП и книги про функциональный дизайн, то вообще можно предположить, что Труъ Чистая архитектура по анкл Бобу и Функциональная архитектура по Влашину — это одно и то же.

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

Промышленная функциональная архитектура

В отличие от Чистой архитектуры (по крайней мере в её общепринятой интерпретации), отказ от архитектурного налога в Функциональной архитектуре (в виде переноса оркестрации/юзкейсов в императивную оболочку и работы с гейтвеями напрямую) не возвращает вас к слоёнке и большему кому грязи. Оставшиеся дополнительные ограничения Функциональной архитектуры, помогать сохранить порядок в коде:

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

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

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

Если вы пока что не знакомы с идеями функционального программирования, и вам этот раздел показался тарабарщиной — прошу, не пугайтесь, все эти идеи прекрасно ложатся на почти привычный Java/Kotlin/Spring-код. У меня есть доклад на Joker '24 и пост с его расшифровкой, где я разбираю три примера рефакторинга реального коммерческого кода к Промышленной функциональной архитектуре, оставаясь, по большому счёту, в привычных большинству разработчиков рамках.

Здесь же я проиллюстрирую Промышленную функциональную архитектуру на анкл Бобовском примере с копированием байт.

Пример

И если переписать анкл Бобовский пример по Промышленной функциональной архитектуре, то…​ Ничего не поменяется.

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

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

В этом случае "плохой дизайн" выглядел бы так:

void Copy() {
    int key = 3;
    int c;
    while ((c = Read()) != EOF) {
        // Так как реализация шифра Цезаря тут исключительно в иллюстрационных целях,
        // я сгенерировал её с помощью ИИ и толком не перепроверял, поэтому не ручаюсь за корректность
        int charCode = c - 97;
        charCode = (charCode + key) % 26;
        Write(charCode + 97);
    }
}

И для того, чтобы привести этот пример к Промышленной функциональной архитектуре — нам надо вытащить «бизнес-логику» (алгоритм шифрования) в отдельную функцию:

void Copy() {
    int key = 3;
    int c;
    while ((c = Read()) != EOF) {
        Write(encrypt(key, c));
    }
}

int encrypt(int key, int c) {
    // Реализацию шифра цезаря сгенерял чат-ботом
    int charCode = c - 97;
    charCode = (charCode + key) % 26;
    return charCode + 97;
}

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

Заключение

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

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

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