⚠️ Ахтунг: тут пробегал ИИ.
📝 Примечание: есть отступления от канона — они в тексте помечены и объяснены.
Идея на пальцах
Код укладывается слоями, как лук. Внутренние слои ничего не знают о внешних, внешние зависят от внутренних.
Главный эффект разделения — то, что изменения становятся предсказуемыми в масштабе. Несколько типовых ситуаций:
Нужно поменять формат ответа одной ручки API — правим один handler. Остальные ручки, команды, обработчики очередей и cron-задачи, использующие ту же бизнес-логику, не задеты.
Нужно поменять бизнес-правило — идём в соответствующий сервис, правим его метод. Эффект автоматически распространяется наружу: новое поведение видят все слои выше — API-handler'ы, CLI-команды, consumers очередей, cron.
Прижала нагрузка — добавили в слой источников ещё одно хранилище (no-SQL рядом с реляционной БД, либо более производительную для этого кейса СУБД) и научили репо читать и писать в оба: горячие данные идут через быстрое, основной слепок остаётся в основной БД. Вся система разом получила буст, без правки тонн кода в сервисах и handler'ах.
Слои такие, снаружи внутрь:
Слой 1. Точка входа: handlers, контроллеры, consumers, cron, CLI.
Слой 2. Сервисный слой — бизнес-логика.
Слой 3. Репозиторный слой — нормализация и оркестрация источников.
Слой 4. Источники данных: разные СУБД, брокеры сообщений (на отправку), файловая система, внешние API.
Ядро (нулевой слой). Домен — ни от чего не зависит.
Правило одно: зависимости идут только внутрь. Реализуется через инверсию: интерфейс (порт) объявляется в том слое, который им пользуется; реализация лежит в слое ниже.
Зачем это не только людям, но и нейронкам
Раньше «предсказуемый масштаб правки» был ценностью прежде всего для людей: проще ревьюить, проще держать систему в голове. Сейчас у кодовой базы появился второй постоянный читатель — кодовые агенты (Copilot, Claude Code, Cursor и прочие), которые эту базу правят. Им луковичная архитектура помогает ровно по тем же причинам, что и человеку, только эффект сильнее.
Локальность = меньше контекста. Чтобы поменять бизнес-правило, агенту достаточно прочитать один сервис и его порты, а не весь репозиторий. Чёткие границы слоёв прямо сокращают объём, который модель должна удержать, чтобы не сломать соседнее. У модели «голова» меньше и дороже человеческой — экономия контекста для неё критичнее.
Контракт порта — это готовый интерфейс для агента. Когда сигнатура порта и доменные типы зафиксированы, агенту можно отдать узкую задачу «реализуй вот этот порт в слое 4» — и он физически не дотянется до бизнес-логики, потому что её не видно из его слоя. Архитектура работает как песочница для не всегда аккуратного исполнителя: границы слоёв ограничивают радиус поражения.
Слои естественно нарезаются на параллельные задачи. Домен → репо → сервис → handler с заранее согласованными контрактами — это набор независимых кусков, которые можно раздать разным агентам, не боясь, что они передерутся за один файл. В монолите, где контроллер ходит в БД напрямую, так не нарежешь: любая правка тянет за собой всё, и параллелить нечего.
Лакмус-тесты ловят галлюцинации. «Тест сервиса без БД и сети» — это не только дисциплина для людей, но и быстрый автоматический сигнал, что агент утащил инфраструктуру не туда. Если сгенерированный код вдруг требует поднятой БД в тесте сервиса — значит, модель протекла сквозь слой, и это видно сразу, а не на ревью.
Грубо говоря: чем чище границы, тем меньше нужно объяснять — и человеку, и модели. Хаотичный код одинаково плохо помещается в обе «головы», просто у нейронки этот предел жёстче и виден быстрее. Архитектура, которая раньше окупалась на горизонте месяцев, с агентами начинает окупаться на горизонте одной задачи.
Слой 1. Точка входа
Задача: принять «сырой» внешний сигнал и превратить его в удобоваримый запрос для бизнес-логики. Маппинг ответа обратно в формат транспорта.
Что тут лежит:
HTTP-controllers, gRPC-handlers, GraphQL-resolvers, consumers очередей, cron-jobs, CLI-команды;
парсинг входных данных (protobuf, JSON, форма, аргументы CLI → доменная структура);
валидация формата (поле обязательное, число в диапазоне, формат email);
аутентификация и базовая авторизация — «имеет ли вызывающий вообще право обращаться к ручке»;
маппинг ответа сервисного слоя в формат транспорта;
маппинг доменных ошибок в коды транспорта (
UserNotFound→ 404,InsufficientFunds→ 422 или gRPCFAILED_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 или gRPCNOT_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 знает про слой 2; слой 2 — про порт, реализация которого живёт в слое 3; слой 3 — про слой 4. Сервис не имеет права импортировать модуль handlers; репо не имеет права импортировать сервис.
Порты внутри, адаптеры снаружи. Интерфейс репозитория объявлен в сервисном слое — там, где его потребляют. Реализация — в репо. Это и есть инверсия зависимостей: без неё «онион» только по структуре папок, а по сути — обычный многослойник, где сервис прибит к инфраструктуре гвоздями.
Доменные объекты живут в центре. Они не зависят ни от транспорта, ни от хранилища.
Каждый слой говорит на своём языке. Транспорт — protobuf/JSON. Сервис — домен. Репо — модели источников. Транспортный/DB-слой — строки таблиц и HTTP-ответы. Перевод — строго на границе.
Composition root — единственное место сборки. Никаких глобальных синглтонов, статических локаторов, инициализации «по факту импорта».
Сквозные вещи — через контекст и 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 — это новый файл только в слое транспорта плюс регистрация в репо.
Тесты сервиса не требуют поднятой БД и сети — подсовываем фейковые репо.
Логика бизнес-сценариев лежит в одном месте, а не размазана по адаптерам и контроллерам.
В легаси-проекте каждый отрефакторенный кусок становится островом стабильности: его проще тестировать, проще переносить в отдельный сервис при декомпозиции, проще менять без страха зацепить что-то ещё.
Главная ценность не в красоте картинки со слоями. Она в том, что для любой правки масштаб предсказуем заранее: правка снаружи остаётся снаружи, правка ближе к ядру осознанно обсуждается как влияющая на многое. Архитектура не запрещает менять домен — она делает так, что цена такой правки видна до того, как ты её отправил на ревью. Это то, ради чего архитектура вообще существует.
Оригинал статьи — с актуальной версией текста — лежит в моём блоге: ololo.tech/articles/development/architecture.
