open atlas
↑ К треку
Архитектурные паттерны ARCH · 03 · 03

Где слоистость протекает

Слоистая архитектура протекает через три сбоя: transaction-script trap (процедуры вместо слоёв), layer-skipping (presentation тянется в infrastructure) и fat service. Каждый — сила, мотивировавшая hexagonal и clean architecture.

ARCH Middle ◷ 22 min
Уровень
ОсновыJuniorMiddleSenior

Через восемнадцать месяцев после запуска B2B-платформы заказов старший инженер провёл аудит кодовой базы. Четырёхслойная структура была на месте — пакеты были названы правильно. Но OrderController разросся до 900 строк, потому что инженеры добавляли туда быструю логику, не рискуя трогать сервис. У OrderService было 61 метод, и он напрямую импортировал PostgresOrderRepository, StripeClient, SendGridClient, PDFGenerator и S3Client — сервисный слой стал слоем интеграции, а не оркестрации. И был метод getBillingReportData() в OrderController, напрямую вызывавший OrderRepository.findRawSql(), потому что разработчику, написавшему его, нужен был специфичный JOIN, который ни один метод сервиса не предоставлял. Слои существовали как названия пакетов. Дисциплина, делающая слои ценными, — сохранение чётких ответственностей и ограниченных импортов каждого слоя — была полностью размыта. Архитектура была слоистой по структуре и big ball of mud на практике.

Утечка 1: Transaction-script trap

Transaction-script trap — это то, что происходит, когда слоистость применяется структурно, но ментальная модель команды остаётся процедурной. Каждый use case становится длинной процедурой в методе сервиса — шаг 1, шаг 2, шаг 3, если то, то другое — с доменными объектами как пассивными контейнерами данных, которыми процедура манипулирует.

// Transaction script, притворяющийся слоистой архитектурой
class OrderService {
  async confirmOrder(orderId: string, userId: string): Promise<void> {
    // Шаг 1: загрузка
    const order = await this.orderRepo.findById(orderId);
    const user = await this.userRepo.findById(userId);
    const invoices = await this.invoiceRepo.findByOrderId(orderId);

    // Шаг 2: валидация (вся логика встроена, ничего в domain)
    if (order.status !== 'pending') throw new Error('Не pending');
    if (!user.hasRole('manager')) throw new Error('Нет прав');
    if (invoices.some(i => i.status === 'overdue')) throw new Error('Просроченные счета');
    if (order.totalAmount > 10000 && !order.approvalId) throw new Error('Требуется одобрение');

    // Шаг 3: мутация (прямая установка полей)
    order.status = 'confirmed';
    order.confirmedAt = new Date();
    order.confirmedBy = userId;

    // Шаг 4: побочные эффекты (сервис напрямую вызывает infrastructure)
    await this.orderRepo.save(order);
    await this.emailClient.send(order.customerEmail, 'Заказ подтверждён', ...);
    await this.eventBus.publish({ type: 'OrderConfirmed', orderId });
  }
}

Слои существуют, но логика живёт в одной длинной процедуре внутри сервиса. Тестирование этого метода требует реальной базы данных (или мокирования 5 зависимостей). Добавление нового правила означает редактирование процедуры. Domain layer не имеет поведения — сервис одновременно является и application layer, и domain layer.

Это Transaction Script, маскирующийся под слоистую архитектуру. Пакет сервисов и пакет domain технически присутствуют — но пакет domain пуст от реальной логики, а пакет сервисов поглотил всё.

Утечка 2: Layer-skipping и leaky abstractions

Layer-skipping происходит, когда вышестоящий слой импортирует из нижестоящего, минуя промежуточные. Канонический пример: контроллер, напрямую импортирующий класс репозитория.

// Контроллер импортирует infrastructure напрямую
import { PostgresOrderRepository } from '../infra/PostgresOrderRepository';

class BillingReportController {
  private repo = new PostgresOrderRepository();

  async getBillingReport(req: Request): Promise<Response> {
    // Прямой SQL в контроллере
    const rows = await this.repo.findRawSql(`
      SELECT o.id, o.total_amount, i.invoice_number
      FROM orders o JOIN invoices i ON i.order_id = o.id
      WHERE o.status = 'confirmed' AND o.confirmed_at > $1
    `, [req.query.since]);
    return Response.json(rows);
  }
}

Это именно проблема getBillingReportData из вступительной истории. Разработчику нужен был специфичный SQL JOIN, которого не предоставлял ни один существующий метод сервиса или domain. Вместо того чтобы добавить метод в правильный слой, он напрямую обратился к infrastructure.

Утечка самоподкрепляется. Как только один контроллер импортирует репозиторий, другие разработчики видят паттерн и повторяют его. За несколько месяцев контроллеры по всему приложению обзаводятся прямыми зависимостями на репозиторий. Infrastructure layer теперь напрямую связан с presentation layer — замена базы данных требует изменения контроллеров.

Leaky abstractions — более тонкая форма того же сбоя: граница абстракции номинально существует, но детали реализации нижнего слоя просачиваются сквозь неё. Доменный сервис, возвращающий объект Knex.QueryBuilder вместо доменного типа, утечёт query builder в вышестоящий слой. Интерфейс репозитория, принимающий параметр EntityManager, утечёт ORM в application layer. Вызывающий теперь зависит от деталей реализации, хотя граница интерфейса внешне присутствует.

Почему это работает

Почему происходит layer-skipping? Непосредственная причина — давление: дедлайн, разовая потребность в отчёте, сложный запрос, который быстрее написать в SQL, чем выразить через доменные методы. Глубинная причина: у слоистой архитектуры нет механизма принуждения. Нет ошибки компилятора, когда контроллер импортирует репозиторий. Единственное принуждение — дисциплина, ревью и командные соглашения, которые деградируют под давлением. Именно этот структурный пробел адресует hexagonal architecture: делая домен владельцем его портов (интерфейсов), она превращает правильное направление зависимости в путь наименьшего сопротивления, а не в опциональное соглашение.

Утечка 3: Fat service и god object

Fat service — конечное состояние приложения, где вся бизнес-логика тяготеет к сервисному слою. Начинается с разумных методов — createOrder, confirmOrder, cancelOrder — и растёт по мере накопления функциональности. Через 18 месяцев у OrderService 61 метод и импорты из 11 различных зависимостей.

Fat service имеет структурные свойства god object:

  • Знает обо всём: инфраструктурные клиенты, доменные сущности, внешние сервисы, запросы для отчётов, логика уведомлений, audit-логирование.
  • От него зависит всё: presentation layer, фоновые задания, batch-процессоры, интеграционные адаптеры.
  • Не тестируется изолированно: чтобы создать экземпляр OrderService, нужно замокировать 11 зависимостей. Тест для confirmOrder должен настроить полное дерево зависимостей.
  • Изменения распространяются повсюду: изменение OrderService может затронуть любого из 61 вызывающего.

Fat service — результат правильного инстинкта слоистости (держать бизнес-логику вне контроллеров) в сочетании с отсутствием domain layer, способного поглотить эту логику. Без rich domain model, хранящего инварианты и сущности, и без чётких границ use case’ов в application layer, сервисный слой становится catch-all.

// Что импортирует раздутый OrderService через 18 месяцев:
import { OrderRepository } from '../infra/OrderRepository';
import { InvoiceRepository } from '../infra/InvoiceRepository';
import { UserRepository } from '../infra/UserRepository';
import { StripeClient } from '../infra/StripeClient';
import { SendGridClient } from '../infra/SendGridClient';
import { PDFGenerator } from '../infra/PDFGenerator';
import { S3Client } from '../infra/S3Client';
import { EventBus } from '../infra/EventBus';
import { AuditLogger } from '../infra/AuditLogger';
import { ReportingQueryRunner } from '../infra/ReportingQueryRunner';
import { FeatureFlagClient } from '../infra/FeatureFlagClient';

Сервисный слой стал интеграционным слоем. Он не оркестрирует доменную логику — он И ЕСТЬ доменная логика, смешанная с вызовами infrastructure. Вычленить один use case (например, подтверждение заказа) означает распутать его из 61 другого метода, разделяющих то же дерево зависимостей.

lesson.inset.note

Три утечки не независимы — они усиливают друг друга. Transaction-script trap выхолащивает domain layer, поэтому fat service поглощает логику, которую domain должен был держать. Fat service накапливает всё больше зависимостей на infrastructure, соблазняя обойти его, чтобы добраться до этих зависимостей напрямую. Layer-skipping затем создаёт тесную связь между presentation и infrastructure. В итоге все три сбоя приходят вместе в зрелую кодовую базу, начинавшуюся с добрых архитектурных намерений.

Почему эти утечки мотивировали hexagonal и clean architecture

Каждая из трёх утечек указывает на структурный пробел в плоской n-tier-слоистости:

Transaction-script trap указывает на пробел: нет механизма, обязывающего domain layer содержать реальную логику. Плоский четырёхслойный стек не предотвращает поглощение сервисом всего поведения.

Layer-skipping указывает на пробел: нет механизма, предотвращающего импорт вышестоящими слоями нижестоящих. Правило зависимостей — соглашение, а не структурное ограничение. В простой структуре пакетов ничто не мешает контроллеру импортировать репозиторий.

Fat service указывает на пробел: нет механизма, определяющего границу use case’а. Класс сервиса может расти без ограничений, поглощая задачи, которые должны быть в отдельных компонентах.

Hexagonal architecture (unit 04) адресует все три, инвертируя зависимость на границе домена. Domain владеет своими портами — интерфейсами, которые он требует от внешнего мира. Ни один класс infrastructure не может быть импортирован domain, потому что domain определяет интерфейс, а infrastructure реализует его. Сервис (application layer) ограничен своим первичным портом, а не классом, случайно находящимся в пакете «services». Внутренность гексагона (domain + application) тестируема без какой-либо infrastructure, потому что infrastructure всегда за интерфейсом, которым владеет domain.

Clean architecture (unit 05) обобщает это до концентрических слоёв с тем же правилом зависимостей: ни один внутренний слой не импортирует внешний. Ядро сущностей, кольцо use case’ов, адаптеры интерфейсов и фреймворки/драйверы разделены жёсткими ограничениями зависимостей — не соглашениями об именовании пакетов.

Викторина

OrderService команды вырос до 80 методов и импортирует из 12 инфраструктурных классов. Новый инженер предлагает разбить его на OrderCommandService (записи) и OrderQueryService (чтения). Это исправляет проблему fat service и почему?

Викторина

Интерфейс репозитория IOrderRepository определён в domain layer. Единственный его метод — `findByCustomerAndDateRange(customerId: string, from: Date, to: Date): Promise<Order[]>`. PostgreSQL-реализация строит сложный запрос с несколькими JOIN. Это leaky abstraction?

Викторина

Команда решает принять hexagonal architecture для исправления трёх утечек слоистости. Как hexagonal architecture структурно предотвращает каждую из трёх утечек, разобранных в этом уроке?

Вспомните перед уходом
  1. 01
    Что такое transaction-script trap и чем он отличается от легитимного использования паттерна Transaction Script?
  2. 02
    Что такое layer-skipping и почему он самоподкрепляется, как только появился?
  3. 03
    Каковы три структурных пробела в плоской n-tier-слоистости, которые обнажают три утечки, и какой архитектурный ответ мотивирует каждый?
Итог

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

Transaction-script trap: сервисный слой поглощает всю бизнес-логику как длинные процедуры, пока domain layer остаётся пустым. Слои существуют по названию, но не как разделение ответственностей. Доменные объекты — пассивные пакеты данных без применяемых инвариантов. Тестирование требует полного дерева зависимостей.

Layer-skipping: вышестоящие слои минуют промежуточные и импортируют из нижележащих напрямую. Контроллер импортирует репозиторий, потому что нужный запрос было быстрее написать в raw SQL. Как только один пропуск появился — он нормализуется. Infrastructure оказывается связан с presentation, лишая слоистость обещанной замещаемости. Leaky abstractions — более тонкая форма: границы интерфейсов существуют, но детали реализации (query builder’ы, ORM, типы строк SQL) просачиваются сквозь них.

Fat service: когда domain пуст и границы use case’ов не определены, сервисный слой становится catch-all. Через 18 месяцев — 60 методов и 11 импортов из infrastructure. Это god object, замаскированный под сервисный слой: не тестируемый изолированно, рискованный для изменений, из которого невозможно вычленить один use case.

Каждая утечка называет силу, мотивировавшую следующее поколение структурных паттернов. Hexagonal architecture (unit 04) инвертирует зависимость на границе domain: domain владеет своими портами, infrastructure реализует их, core тестируем без инфраструктуры. Clean architecture (unit 05) обобщает это до концентрических слоёв с жёстким правилом зависимостей, применяемым структурой, а не соглашением. Обе появились потому, что дисциплина плоской слоистости деградирует под давлением дедлайнов и растущих кодовых баз.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.