Луковичная архитектура

Подход одинаково работает в Go, Java, C#, TypeScript, Kotlin, Rust, PHP — это не про язык, а про направление зависимостей.

Идея на пальцах

Код укладывается слоями, как лук. Внутренние слои ничего не знают о внешних, внешние зависят от внутренних.

Главный эффект разделения — то, что изменения становятся предсказуемыми в масштабе. Несколько типовых ситуаций:

  1. Нужно поменять формат ответа одной ручки API — правим один handler. Остальные ручки, команды, обработчики очередей и cron-задачи, использующие ту же бизнес-логику, не задеты.
  2. Нужно поменять бизнес-правило — идём в соответствующий сервис, правим его метод. Эффект автоматически распространяется наружу: новое поведение видят все слои выше — API-handler’ы, CLI-команды, consumers очередей, cron.
  3. Прижала нагрузка — добавили в слой источников ещё одно хранилище (no-SQL рядом с реляционной БД, либо более производительную для этого кейса СУБД) и научили репо читать и писать в оба: горячие данные идут через быстрое, основной слепок остаётся в основной БД. Вся система разом получила буст, без правки тонн кода в сервисах и handler’ах.

Слои такие, снаружи внутрь:

  • Слой 1. Точка входа: handlers, контроллеры, consumers, cron, CLI.
  • Слой 2. Сервисный слой — бизнес-логика.
  • Слой 3. Репозиторный слой — нормализация и оркестрация источников.
  • Слой 4. Источники данных: разные СУБД, брокеры сообщений (на отправку), файловая система, внешние API.
  • Ядро (нулевой слой). Домен — ни от чего не зависит.

Правило одно: зависимости идут только внутрь. Реализуется через инверсию: интерфейс (порт) объявляется в том слое, который им пользуется; реализация лежит в слое ниже.

Слой 1. Точка входа

Задача: принять «сырой» внешний сигнал и превратить его в удобоваримый запрос для бизнес-логики. Маппинг ответа обратно в формат транспорта.

Что тут лежит:

  • HTTP-controllers, gRPC-handlers, GraphQL-resolvers, consumers очередей, cron-jobs, CLI-команды;
  • парсинг входных данных (protobuf, JSON, форма, аргументы CLI → доменная структура);
  • валидация формата (поле обязательное, число в диапазоне, формат email);
  • аутентификация и базовая авторизация — «имеет ли вызывающий вообще право обращаться к ручке»;
  • маппинг ответа сервисного слоя в формат транспорта;
  • маппинг доменных ошибок в коды транспорта (UserNotFound → 404, InsufficientFunds → 422 или gRPC FAILED_PRECONDITION).

Чего тут нет:

  • бизнес-правил («если у пользователя такой тариф — пропустить» — это сервис);
  • походов в БД или к внешним API;
  • знания о том, как устроено хранилище.

Это driving-адаптеры — они «двигают» приложение снаружи внутрь.

Аналогия: ресепшн в отеле. Принимает гостя, проверяет паспорт, заполняет карточку, выдаёт ключ. Но сам номера не убирает и завтрак не готовит.

Слой 2. Сервисный слой — бизнес-логика

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

Что тут лежит:

  • сценарии («оформить заказ», «начислить кэшбэк», «провести платёж»);
  • бизнес-правила: что разрешено, что запрещено, в каком порядке выполнять шаги;
  • оркестрация по доменным понятиям: вызвать репозиторий A, потом репозиторий B, склеить результат, применить правило;
  • работа с доменными объектами, а не с DTO транспорта и не с записями таблиц;
  • интерфейсы (порты) к репозиториям и внешним зависимостям объявляются здесь же — это и есть инверсия зависимостей. Сервис объявляет, что ему нужен «способ получить пользователя по id»; чем эта возможность реализуется — его не интересует.

Чего тут нет:

  • знаний о HTTP/gRPC/Kafka и форматах транспорта;
  • знаний о SQL, Redis, HTTP-клиенте конкретного API;
  • маппингов из/в формат хранилища или транспорта;
  • прямых вызовов логгера в каждой строчке (сквозные дела — через middleware/декораторы, см. ниже).

Сервис получает на вход доменные объекты, отдаёт доменные объекты или доменные ошибки. Если завтра поменяли БД, добавили кэш, переехали с REST на gRPC — сервисный слой не трогаем.

Аналогия: шеф-повар. Знает рецепт, последовательность шагов, что с чем сочетается, что не сочетается ни с чем. Не знает, у какого поставщика закуплена морковь и в какой холодильник её положили.

Слой 3. Репозиторный слой — нормализация и оркестрация источников

Задача: скрыть от сервиса детали «где лежат данные и в каком виде». Сервис говорит «дай мне пользователя по id» — репозиторий разбирается как.

Что тут лежит:

  • реализации портов, объявленных в сервисном слое;
  • маппинг «доменная сущность ↔ модель источника» (запись из таблицы → доменный объект; ответ внешнего API → доменный объект). Именно здесь живут Eloquent/ActiveRecord-модели, если стек на них построен — как «модели источника», не как доменные сущности;
  • решение, куда идти: в кэш, в БД, во внешний API, и в каком порядке (сначала кэш, потом БД, результат положить в кэш);
  • агрегация из нескольких источников (несколько страниц API в один список; данные из двух таблиц в один объект);
  • ретраи, пагинация, throttling запросов к нижнему слою;
  • транзакционные границы (если пишем в несколько таблиц одной операцией — это здесь);
  • оборачивание инфраструктурных ошибок в доменные (таймаут SQL → RepositoryUnavailable, 404 от внешнего API → NotFound).

Чего тут нет:

  • бизнес-правил уровня «применять ли этого поставщика в этом сценарии», «начислять ли скидку» — это сервис;
  • самих SQL-запросов, HTTP-вызовов, команд Redis — это уровнем ниже.

Бонус, который часто недооценивают: декораторы поверх репо применяются ко всей системе сразу. Хочется кэш — оборачиваем репо в CachingRepo, регистрируем в composition root вместо «голого». Сервис, handler, и весь остальной код продолжают работать как ни в чём не бывало, потому что говорят с тем же портом. Так же подключаются ретраи, метрики попаданий, circuit breaker. Бизнес-код этой обвязки не видит — это всё инфраструктурный слой.

Прагматичное расширение канона

В строгом онионе/DDD «один репозиторий — один агрегат — одно хранилище», а оркестрация нескольких источников ради одного ответа — это application service.

На практике часто полезно сознательно сделать шире: репозиторий — это порт к данным; он может ходить в несколько источников ради ответа на один доменный вопрос, но не принимает бизнес-решений. «Посмотри сначала в кэш, потом сходи в API, результат положи обратно в кэш» — это plumbing, а не бизнес. Тащить такое в сервис — значит замусоривать его инфраструктурой.

Граница простая: репо имеет право выбирать «откуда взять данные», но не имеет права выбирать «брать ли вообще» по бизнес-критериям. Как только в репо появляется ветка вида «если у пользователя такой тариф — не запрашивать», логика утекла не туда.

Аналогия: завхоз на кухне. Шеф попросил морковь — завхоз сам решает: есть ли в холодильнике, не пора ли заказать у поставщика, в каком порядке списать со склада. Шефу отдаёт чищеную морковь, а не сырую с грядки. Но он не решает, что сегодня готовят: «у нас закончилась морковь — приготовим вместо рагу пельмени» — это не его уровень.

Слой 4. Источники данных

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

Что считается источником:

  • разные СУБД (реляционные, документные, time-series, key-value);
  • брокеры сообщений (на отправку);
  • файловая система (локальная, S3-совместимая);
  • внешние API (REST, gRPC, GraphQL — каждый отдельный поставщик);
  • кэши, поисковые индексы, очереди задач.

Что тут лежит:

  • HTTP/gRPC клиенты конкретных внешних сервисов;
  • SQL-запросы к конкретным таблицам;
  • команды Redis, операции с файловой системой, обращения к очередям;
  • настройка коннектов, пулов, таймаутов, авторизации (OAuth-токены, HMAC-подписи, mTLS);
  • сериализация/десериализация именно в формат источника (JSON конкретного API, строка таблицы, бинарный формат брокера).

Чего тут нет:

  • объединения данных из разных источников;
  • решений «куда лучше пойти»;
  • бизнес-правил в любом виде.

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

Это driven-адаптеры — их «двигает» сервис изнутри наружу через репо.

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

Ядро. Домен (нулевой слой)

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

Домен — это не «папка с DTO». Это словарь и грамматика бизнеса, выраженные в коде. Все остальные слои говорят про мир в его терминах.

Из чего состоит домен

  • Сущности (entities) — то, у чего есть идентичность во времени. У каждого пользователя свой ID, который не меняется, даже если он сменил имя и почту. Два экземпляра с разным ID — разные сущности, даже если все остальные поля совпадают.
  • Значения (value objects) — то, что равно по содержимому, без идентичности. Money(amount, currency), Quantity(value, unit), EmailAddress, DateRange. Два Money(100, "USD") — это один и тот же «сто долларов». Value-объекты обычно неизменяемые: «добавить 10 долларов» возвращает новый Money, а не мутирует старый.
  • Доменные ошибки — типизированные ситуации предметной области: UserNotFound, InsufficientFunds, OrderAlreadyShipped, InvalidEmailFormat. Это отдельные типы или sentinel-значения, а не магические строки и не HTTP-коды.
  • Доменные сервисы — функции для логики, которая не принадлежит одной сущности. «Применить скидку к корзине по правилам акции», «выбрать оптимальный вариант доставки» — это правило, а не свойство одной сущности. Доменный сервис чист: на вход доменные объекты, на выход доменные объекты, никаких I/O.
  • Инварианты — правила, которые сущность никогда не должна нарушать в течение своей жизни. Количество строго положительное. Валюта непустая. Заказ в статусе «отправлен» нельзя отредактировать. Инварианты гарантируются в конструкторах и в методах изменения состояния: создать или довести до невалидного состояния доменный объект нельзя в принципе.

Главное свойство — изоляция

Доменный модуль не импортирует ничего извне. Ни HTTP-фреймворк, ни SQL-драйвер, ни ORM, ни логгер, ни конфиг, ни клиент шины сообщений. Из стандартной библиотеки допустимо только то, что моделирует предметную область: коллекции, базовые типы, работа со временем как структурой данных, числа. Всё, что работает с сетью, файловой системой или процессом — мимо домена.

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

Чего в домене нет

  • Сериализационных аннотаций на полях. Никаких JSON-тегов, ORM-маппингов, protobuf-аннотаций, разметки валидаторов поверх доменных структур. Доменный объект не знает, что его кто-то когда-то сериализует — это знание адаптера. Соблазн «одна структура и для домена, и для JSON, и для строки таблицы» — та же ловушка, что и Active Record: на короткой дистанции экономит маппинг, на длинной — пришивает домен к транспорту и хранилищу.
  • «Умных» моделей со встроенным I/O — Active Record во всех формах. user.save(), order.publish(), invoice.sendByEmail() — это смесь данных и транспорта. Eloquent в Laravel, ActiveRecord в Rails, модели Django с менеджерами — удобный паттерн на коротких сроках, но это не доменные сущности, а «модели источника данных». В онионе они живут в репозиторном слое и оттуда маппятся в чистые доменные классы. Когда видишь у класса метод save() или find() — это сразу подсказка, что объект знает про БД, а значит, не доменный.
  • Сеттеров, которые ломают инварианты. Если количество всегда положительное — нет публичного «установить значение», который позволяет поставить ноль. Изменение состояния — через методы предметной области (order.cancel(), cart.addItem(...)), которые сами следят за инвариантами и могут отказать.
  • Прямых обращений к времени и случайности. Системное время и генерация UUID/ID — это внешний мир. Прямой вызов внутри доменного метода делает любую логику «срок истёк», «сгенерировать новый идентификатор» зависимой от системного времени и непредсказуемого значения. Если время или ID нужны как часть бизнес-логики — таймпровайдер и генератор ID передаются как зависимость в доменный сервис, либо значение генерируется на сервисном слое и приходит в конструктор сущности.
  • Знаний о транзакциях, кэше, ретраях, идемпотентности транспорта. Это инфраструктурные понятия. Бизнес-понятие «эта запись устарела» — доменное. Реализация «как мы это узнаём (TTL в кэше или поле updated_at)» — инфраструктурная.

Доменные ошибки чуть подробнее

Это не общий error со строкой и не HTTP-коды. Это конкретные типы или sentinel-значения, по которым внешние слои сопоставляют ситуацию со своим языком:

  • handler знает: «доменная ошибка UserNotFound» → HTTP 404 или gRPC NOT_FOUND;
  • сервис не парсит сообщения от драйвера БД;
  • репозиторный слой никогда не отдаёт наверх таймаут SQL-драйвера или пустой ответ Redis — он оборачивает их в доменное.

В большинстве языков для этого хватает sentinel-значений (как в Go) или иерархии исключений (как в Java/C#/PHP/Python). Главное — у внешнего слоя должен быть способ отличить «доменная ситуация» от «инфраструктурный сбой» без анализа текста сообщения.

Чего сознательно не делаем (если осторожны с DDD)

  • Не строим полноценный DDD с агрегатами и aggregate roots, пока он реально не нужен. На большинстве масштабов достаточно «сущности + value-объекты + доменные сервисы». Если появится случай, где границы согласованности нетривиальные (несколько сущностей надо менять атомарно по бизнес-правилу), — введём агрегат точечно для этого случая, а не из общих соображений.
  • Не вводим domain events заранее. Заманчиво, но добавляет шину и подписчиков в инфраструктуре, и часто превращает явные вызовы в магические «откуда-то прилетело». Когда появляется конкретный кейс, где это снимает сложность (например, асинхронная обработка после фиксации транзакции), — вводим точечно.

Это разумные отклонения от канона; их полезно фиксировать в команде явно, чтобы не получился спор «но в книжке написано иначе».

Лакмус-тест

  • Доменный модуль собирается отдельно, без подтягивания HTTP-фреймворка, SQL-драйверов, клиентов очередей. Если в дереве зависимостей домена транзитивно появился драйвер БД — где-то протечка, ищи импорт.
  • Юнит-тест доменного правила пишется без поднятия БД, контейнеров и сети. Только in-memory объекты и фейки. Если для теста доменной логики понадобился HTTP-сервер или поднятый брокер — правило живёт не там.

Аналогия

Домен — это меню и правила сочетания продуктов. «Куриный бульон плюс пельмени — пельмени в бульоне», «свинина с молоком не сочетается», «горячее подаётся не холоднее 60°». Меню не знает, есть ли сейчас свинина в холодильнике, у кого её закупили, кто её привёз и какая стоит плита. Когда меняем поставщиков, плиту, посуду, кассу — меню не меняется. Когда добавляем новое блюдо — меняется только меню, а посуда и плита остаются.

Сборка всего вместе. Composition root

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

Конкретное воплощение зависит от стека:

  • В Go — функция main соответствующего бинаря, обычно cmd/<service>/main.go. Сборка руками, без магии.
  • В .NET — Program.cs плюс IServiceCollection-конфигурация.
  • В Spring (Java/Kotlin) — @Configuration-классы, явные @Bean-методы. Автосканирование @Component поверх всего проекта быстро превращается в антипаттерн: «всё знает про всё».
  • В Laravel (PHP) — bootstrap/app.php плюс ServiceProvider’ы, где интерфейсы биндятся к реализациям. Контейнер Laravel допустим как composition root, но не как сервис-локатор, который дёргают App::make(...) из контроллеров и моделей.
  • В Node.js / TypeScript — index.ts / main.ts, либо через DI-контейнер (tsyringe, inversify), но опять же только как composition root.

Composition root — единственное место, где допустимо «знать всех сразу». Если зависимости начали склеиваться где-то ещё — через инициализацию на уровне модуля, глобальные переменные, синглтоны, статические локаторы, Container::get(...) из бизнес-кода — архитектура поползёт. Любой код в любом слое сможет дотянуться до чего угодно, и проверить дисциплину автоматически уже не получится.

DI-контейнер сам по себе не зло. Зло — это когда его API доступен из любого места кода. Контейнер должен быть инструментом для composition root и ничем больше.

Сквозные вещи. Логи, метрики, трассировка, конфиг

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

  • Контекст вызова пробрасываем сквозь все слои. В нём — идентификатор запроса, span трассировки, дедлайн, отмена. В Go это context.Context, в .NET — CancellationToken плюс scoped DI, в Java/Spring — RequestContext/MDC, в Node — AsyncLocalStorage, в Laravel — Request и контекст логгера. Имя разное, идея одна.
  • Сквозные дела подключаем через middleware/interceptor/декораторы на границах слоёв. Серверный middleware логирует входящий вызов и латентность. Декоратор поверх репо считает метрики попаданий в кэш. Сам бизнес-код остаётся читаемым, в нём нет шума.
  • Конфигурация — обычная зависимость, передаётся параметрами из composition root. Per-feature настройки (таймауты, ретраи, лимиты) загружаются один раз и передаются внутрь конструкторами. Глобального Config::get(...) не делать — это та же глобальная зависимость, что и синглтон БД, только замаскированная.

Ошибки — отдельный язык каждого слоя

  • Доменные ошибки живут в домене и поднимаются вверх как есть.
  • Инфраструктурные ошибки (таймаут, отказ соединения, 5xx от внешнего API, пустой ответ из кэша) оборачиваются в репо в доменные. Сервис никогда не видит низкоуровневую ошибку.
  • Handler знает, что конкретная доменная ошибка маппится в конкретный код транспорта, и не парсит сообщения от драйвера.

Без этой дисциплины ошибки расползаются: handler начинает знать про SQL, сервис — про HTTP-статусы внешнего API. При первой же смене хранилища или поставщика всё это нужно переписывать.

Правила, которые держат архитектуру

  1. Зависимости только внутрь. Слой 1 знает про слой 2; слой 2 — про порт, реализация которого живёт в слое 3; слой 3 — про слой 4. Сервис не имеет права импортировать модуль handlers; репо не имеет права импортировать сервис.
  2. Порты внутри, адаптеры снаружи. Интерфейс репозитория объявлен в сервисном слое — там, где его потребляют. Реализация — в репо. Это и есть инверсия зависимостей: без неё «онион» только по структуре папок, а по сути — обычный многослойник, где сервис прибит к инфраструктуре гвоздями.
  3. Доменные объекты живут в центре. Они не зависят ни от транспорта, ни от хранилища.
  4. Каждый слой говорит на своём языке. Транспорт — protobuf/JSON. Сервис — домен. Репо — модели источников. Транспортный/DB-слой — строки таблиц и HTTP-ответы. Перевод — строго на границе.
  5. Composition root — единственное место сборки. Никаких глобальных синглтонов, статических локаторов, инициализации «по факту импорта».
  6. Сквозные вещи — через контекст и middleware, а не через прямые вызовы инфраструктуры в бизнес-коде.

Применение в существующем проекте: новый код и постепенный рефакторинг

Книжная картинка предполагает, что у вас зелёное поле и можно сразу разложить четыре папки правильно. В реальности чаще другое: есть монолит на 5–10 лет с Eloquent-моделями, репозиториями, которые тянут логику, контроллерами, в которых половина бизнес-правил, и сервисами, которые знают про HTTP-запрос. Переписать всё за один заход нельзя, а обещать «постепенно станет лучше» без правил — значит, не станет лучше никогда.

Правила применения на легаси:

  • Не требовать чистоты всего проекта одним заходом. Если правишь модуль или фичу — приводи в порядок этот кусок. Соседний модуль остаётся как есть, пока его не трогают по делу.
  • Граница «новый код / легаси» — явная. Отдельный namespace или каталог (app/Domain/, app/NewBilling/, internal/v2/). Видя путь, любой разработчик понимает, по каким правилам код написан и какие комментарии на ревью уместны.
  • Внутри нового куска — без компромиссов. Никаких «здесь временно пусть будет Eloquent в сервисе, потом поправим». Потом не поправите. Если в новом куске не получается соблюсти правила — значит, граница нарезана неудачно, отщипываем меньше.
  • На границе нового и старого — anti-corruption layer. Маленький слой адаптеров, которые принимают легаси-объекты (Eloquent-модели, массивы из старого кода, объекты сторонних либ) и отдают доменные сущности нового куска. Внутрь нового куска легаси не пускается ни в каком виде.
  • Anti-corruption layer — временный. Он удаляется вместе с легаси, которое мостит. Если переход растягивается на годы и адаптеры обрастают логикой — это уже не мост, а собственный слой системы, и от него тяжелее избавиться, чем от исходного легаси.
  • Compatibility shims не плодим. Тонкие обёртки, которые делают старый API совместимым с новым «на всякий случай», живут вечно. Лучше за один заход переключить всех потребителей, чем оставить и старый, и новый параллельно.
  • Тесты как страховка миграции. Перед тем как разбирать сложный кусок легаси, обвешиваем его характеристическими тестами (черный ящик: подали вход → получили выход), чтобы рефакторинг не сломал поведение незаметно. После — новые модули покрываем юнит-тестами по правилу «без БД и сети».

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

Тестируемость как лакмус

Если непонятно, в правильном ли слое лежит код, проверь так:

  • Юнит-тест сервиса должен писаться без БД, без сети, без транспорта. Подсовываем фейковую реализацию репо — всё работает.
  • Тест репо может требовать БД или HTTP-мока, но не тянет сервисный код.
  • Тест handler’а — на маппинг и валидацию, с замоканным сервисом.

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

Чего избегать

  • «Умных» моделей БД с бизнес-методами (Active Record как доменная сущность). user.save(), order.charge(), Eloquent в сервисах, ActiveRecord в моделях. Это монолит обратно. Модели хранилища знают только про хранилище.
  • DTO транспорта в сервисе. Ни protobuf-сгенерированных структур, ни JSON-моделей в бизнес-логике. Бизнес-логика работает с доменом, маппинг — на границе.
  • «Универсальных» типов между слоями (any, object, map[string]any, Map<String, Object>, array без структуры в PHP). Лучше явный доменный тип, даже если он «такой же по полям». Универсальный тип — это отложенная ошибка, которая обнаружится в проде.
  • Глобальных конфига и логгера, которых зовут из любого слоя.
  • Сервис-локатора под видом DI-контейнера. Container::get(...) или App::make(...) из бизнес-кода — это глобальное состояние, замаскированное декларациями. DI-контейнер должен жить в composition root, и только.
  • Долгоживущих compatibility shims на границе нового и старого кода. Либо переходный слой удаляется вместе с легаси, либо превращается в собственное легаси.
  • Циклических зависимостей пакетов. Компилятор современных языков их обычно не пускает, но если хочется обойти переименованием пакета — это сигнал, что слои перепутаны, а не повод изобретать workaround.

Что это даёт практически

  • Можно переписать gRPC на REST, не трогая сервис.
  • Можно поменять Postgres на ClickHouse, не трогая сервис.
  • Можно добавить новый источник данных или новый внешний API — это новый файл только в слое транспорта плюс регистрация в репо.
  • Тесты сервиса не требуют поднятой БД и сети — подсовываем фейковые репо.
  • Логика бизнес-сценариев лежит в одном месте, а не размазана по адаптерам и контроллерам.
  • В легаси-проекте каждый отрефакторенный кусок становится островом стабильности: его проще тестировать, проще переносить в отдельный сервис при декомпозиции, проще менять без страха зацепить что-то ещё.

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