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

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

Уровень сложностиСредний
Время на прочтение22 мин
Количество просмотров21K

Введение

Сейчас среди 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.

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

Например, примерно в то же время Пейдж‑Джонс в What every programmer should know about object‑oriented design писал:

Object orientation enhances reusability because it promotes reuse of code at the class level rather than at the level of individual subroutine. By developing and tending a library of classes for your applications in your shop, you are, in effect, creating a new, very‑high‑level language tailored specifically to your needs. It appears empirically that the object class is a sophisticated‑enough organism to be abel to migrate from application to application across your company as a self‑contained unit of software.

---

Объектная ориентированность улучшает переиспользуемость, поскольку способствует повторному использованию кода на уровне класса, а не на уровне отдельной подпрограммы. Разрабатывая и поддерживая библиотеку классов для ваших приложений в вашей компании, вы, по сути, создаёте новый язык очень высокого уровня, специально адаптированный к вашим потребностям. Практика показывает, что класс объектов является достаточно сложным организмом, чтобы его можно было переносить из приложения в приложение вашей компании как автономную программную единицу. — Meilir Page‑Jones, What every programmer should know about object‑oriented design

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

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

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

Примечание: на мой взгляд, этот пример не очень удачный — не думаю, что кто‑то в здравом уме стал бы городить разделяемую библиотеку с одним while‑ом.

Но, видимо, этот while символизирует сложные вычисления и бизнес‑правила «уровня предприятия», которые хотелось бы переиспользовать в нескольких приложениях, автоматизирующих работу этого предприятия.

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

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

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

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 не нужен, ч.т.д.

Примечание: тут вы можете спросить: а как же тесты и тестовые дубли?

Во‑первых — никак. В своих книге и блоге Владимир Хориков подробно объясняет, почему не надо повсюду использовать тестовые дубли (моки, в первую очередь), и как писать код так, чтобы львиная доля бизнес‑логики не зависела от инфраструктуры и, соответственно, могла быть протестирована без тестовых дублей.

А оставшаяся часть кода покрывается интеграционными тестами, которые с PostgreSQL на RAM‑диске работают в несколько раз быстрее тестов с моками. В моих проектах отношение тестов без моков к тестам с моками примерно 100 к 1, а фейков ни разу не было больше двух.

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

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

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

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

2025 01 19 11 28 53
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 я подразумеваю любой набор сигнатур, предназначенный для вызова клиентским кодом — функции C и Kotlin, публичные методы класса, методы интерфейса. А под интерфейсом — именно сущность в исходном коде с именем и набором сигнатур методов без реализации.

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

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

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

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

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

ood bad
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 механизмов, а не кодировать дополнительный уровень косвенности в виде интерфейса или абстрактного класса.

Примечание:

Сказать «надо качественно проектировать API» — легко. Но что конкретно для этого надо делать? К сожалению, я не знаю простого рецепта дизайна качественного API, который бы уместился в подраздел поста на Хабре. Зато знаю несколько хороших источников по этой теме:

* Книга Practical API Design
* Книга Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable.NET Libraries
* Книга API
* Книга Грокаем функциональное мышление (Grokking simplicity) (Главы 8 и 9 про «многоуровневое проектирование» — Stratified design).
* Доклад Stratified Design: A Lisp Tradition от автора Grokking Simplicity
* Доклад Design in Practice от автора Clojure (тут прям хардкор, вряд ли у вас получится повторить это в прикладной разработке, где, обычно, надо прыгать, а не думать)
* Доклад‑продолжение предыдущего Design in Practice in Practice от коллеги Хикки и одного из основных маинтейнеров Clojure

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

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

Да, такое бывает. Например, когда у вас есть какой‑то огромный набор данных, дополняемый 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)
  }
  
}

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

Примечание: в значительной степени этот пример является комбинацией двух реальных историй из моей практики. В одной я делал перевод системы хранения данных с хранения данных в Jackrabbit на хранение в Minio с фоновой миграцией десятков гигабайт данных. В другой у меня студент‑дипломник менял хранение файлов в Postgres на хранение в Minio.

Так что ответственно заявляю: если не налажать в API класса хранилища — это действительно так просто.

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

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

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

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

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

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

  • Вы бы платили всё это время копеечку за интерфейс;

  • Клиент мог и не прийти;

  • Это движение — капля в море, на фоне трудозатрат на повторную реализацию слоя персистанса.

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

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

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

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

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

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

2025 01 19 16 03 04
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.

Пруф

We do not expect changes in this layer to affect the entities. We also do not expect this layer to be affected by changes to externalities such as the database, the UI, or of the common frameworks. The use cases layer is isolated from such concerns.

---

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

— Robert Martin, Clean Architecture

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

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

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

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

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

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


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

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

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

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

Определение

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

2025 01 19 17 08 02
2025 01 19 17 08 02

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

  1. Все зависимости должны быть направлены в сторону чистого ядра (Functional Core) — аналог правила зависимостей;

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

  3. Максимум кода должен находиться в чистом ядре.

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

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

Примечание: на всякий случай подчеркну, что в Чистой архитектуре Entities — это не JPA‑сущности и не модель данных — это в первую очередь функции.

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

An Entity is an object within our computer system that embodies a small set of critical business rules operating on Critical Business Data. The Entity object either contains the Critical Business Data or has very easy access to that data. The interface of the Entity consists of the functions that implement the Critical Business Rules that operate on that data. Сущность — это объект в нашей компьютерной системе, который воплощает небольшой набор важных бизнес‑правил, работающих с критически важными бизнес‑данными. Объект Entity либо содержит критически важные бизнес‑данные, либо имеет очень простой доступ к этим данным. Интерфейс объекта состоит из функций, реализующих критически важные бизнес‑правила, которые работают с этими данными. — Robert Martin, Clean Architecture

А императивная оболочка, в которую попадает весь ввод‑вывод — это аналог адаптеров интерфейсов (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 и пишет посты с одами ФП и книги про функциональный дизайн, то вообще можно предположить, что Труъ Чистая архитектура по анкл Бобу и Функциональная архитектура по Влашину — это одно и то же.

* особенно если вспомнить, что

The circles in Figure 22.1 are intended to be schematic: You may find that you need more than just these four. There”s no rule that says you must always have just these four. However, the Dependency Rule always applies. Source code dependencies always point inward.

---

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

— Robert Martin, Clean Architecture

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

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

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

  • Неизменяемая модель данных исключает появление циклов в зависимостях между агрегатами/сущностями;

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

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

Такой вариант Функциональной архитектуры я называю промышленным, потому что он, на мой взгляд, лучше ложится на реалии промышленной разработки, где используют преимущественно императивные по природе языки программирования, а большинство разработчиков не слышали и не хотят слышать про теорию категорий и каррирование (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;
}

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

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

Примечание: при подготовке этого кода я немного поигрался с чат‑ботами и получил, на мой взгляд, любопытные результаты:

* По промпту «Добавь в этот код шифрование шифром Цезаря» GigaChat и Алиса.Про, сразу вынесли шифрование в отдельную функцию;

* А вот ChatGpt (бесплатный, не Pro) — написал «плохой дизайн», а вынес шифрование только после запроса «Как улучшить этот код?». Промпты на английском не помогли;

* Ни один из ботов, ни сходу, ни после «Как улучшить этот код» не предложили сделать инверсию зависимостей.

Заключение

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

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

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

Теги:
Хабы:
Всего голосов 49: ↑45 и ↓4+52
Комментарии123

Публикации

Ближайшие события