← назад к разделу

Когда вы пишете новый метод, возникает вопрос: в какой класс его положить? Один разработчик кладёт в сервис, другой — в доменный объект, третий — в отдельный хелпер. Без ориентира каждый решает по-своему, и через год код становится трудно читаемым.

GRASP (General Responsibility Assignment Software Patterns) — девять принципов из книги Крейга Лармана «Applying UML and Patterns», которые дают конкретные критерии: смотри на эти признаки — и узнаешь, кому отдать ответственность.

SOLID описывает, каким должен быть класс в целом. GRASP отвечает на более конкретный вопрос: кто за что отвечает прямо сейчас.

Information Expert — логика там, где данные

Самый частый вопрос при проектировании: «в сервисе или в доменном объекте?»

Без ориентира разработчик кладёт всё в сервис, потому что так привычнее. В итоге доменный объект становится пассивным контейнером геттеров, а сервис — длинным скриптом, который дёргает эти геттеры и сам считает итог. Такую ситуацию называют анемичной моделью: данные в одном месте, логика в другом.

Information Expert говорит: отдай ответственность тому, у кого уже есть данные для её выполнения.

Вычислить сумму заказа — у кого данные? У самого заказа: он знает строки и цены.

public class Order {

    private final List<OrderLine> lines;

    public Money total() {
        return lines.stream()
            .map(OrderLine::subtotal)
            .reduce(Money.ZERO, Money::add);
    }
}

Теперь метод OrderService.calculateTotal(order), который тащит данные через геттеры, просто не нужен. При изменении структуры строк меняется только Order, а не ещё и сервис.

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

Creator — кто создаёт объект

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

Боль: инвариант «нельзя добавить строку в завершённый заказ» некому проверить — он размазан по всем точкам создания.

Creator говорит: объект A создаёт тот, кто содержит A, агрегирует A или владеет данными для его инициализации.

OrderLine создаёт Order — он содержит строки и знает, когда их можно добавлять:

public class Order {

    public void addLine(Product product, int quantity) {
        ensureStatus(Status.CREATED);
        lines.add(OrderLine.of(product.id(), product.price(), quantity));
    }
}

Контроллер больше не знает про OrderLine и не может обойти проверку статуса. Инвариант защищён в одном месте.

Controller — кто координирует операцию

GRASP Controller — это не HTTP-контроллер фреймворка.

Транспортный слой (HTTP, очередь сообщений) отвечает только за одно: принять запрос, передать его дальше, вернуть ответ. Если в него перетечёт бизнес-логика, она станет недостижимой из другого транспорта и непроверяемой без запуска HTTP-стека.

Controller по GRASP — выделенный объект, который координирует выполнение одной операции: получает нужные данные, вызывает домен, сохраняет результат.

// Транспортный слой — только разбирает запрос и делегирует
@RestController
public class OrderController {

    private final CancelOrderHandler handler;

    @PostMapping("/v1/orders/{orderId}/cancel")
    public void cancel(@PathVariable UUID orderId, @RequestBody CancelOrderRequest req) {
        handler.handle(new CancelOrderCommand(orderId, req.reason()));
    }
}

// GRASP Controller — координирует операцию
@Component
public class CancelOrderHandler {

    private final OrderRepository orders;

    @Transactional
    public void handle(CancelOrderCommand command) {
        Order order = orders.findById(command.orderId());
        order.cancel(command.reason());
        orders.save(order);
    }
}

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

Low Coupling — меньше связей между классами

Связь — это всё, что заставит класс измениться вслед за другим: тип поля, прямой вызов метода, наследование.

Чем больше связей, тем больнее любое изменение: правишь один класс — волна расходится по десяти.

Low Coupling говорит: из равных вариантов выбирай тот, что создаёт меньше связей.

На практике это означает: зависеть от интерфейса, а не от конкретного класса; общаться через события, а не через прямые вызовы; не делать один объект зависящим от половины системы.

Хороший диагноз высокого coupling — конструктор с семью параметрами: значит, класс знает о слишком многих.

High Cohesion — один класс, одна тема

Низкая связность выглядит так: класс OrderService на восемьсот строк, в котором живут создание заказа, отмена, экспорт в Excel и подсчёт статистики. Эти четыре темы не связаны между собой — меняешь одну, рискуешь зацепить другую.

High Cohesion говорит: класс делает близкие по смыслу вещи; несвязанные ответственности — в разных классах.

CancelOrderHandler связен: всё в нём служит одной операции. Когда правила отмены изменятся, знаешь точно, какой файл открывать.

Тот же принцип применяется к пакетам: пакет shop.order со всем, что относится к заказу, связнее, чем пакет shop.services с сервисами всех доменов разом.

Low Coupling и High Cohesion — пара: минимум связей между классами, максимум связности внутри каждого.

Polymorphism — поведение по типу без условных операторов

switch по типу клиента, if по способу оплаты, условные цепочки по формату экспорта — каждый новый вариант требует лезть в тот же код и добавлять новую ветку.

Polymorphism говорит: поведение, зависящее от типа, — полиморфизму, а не цепочкам условий.

interface DiscountPolicy {
    Money apply(Money price);
}

class NoDiscount implements DiscountPolicy {
    public Money apply(Money price) { return price; }
}

class VipDiscount implements DiscountPolicy {
    public Money apply(Money price) { return price.multiply(0.8); }
}

Новый тип скидки — новый класс, никакой правки существующего кода.

Для закрытого набора вариантов (их количество не будет расти) запечатанные типы (sealed interface в Java, union types в TypeScript) — равноправная альтернатива. Компилятор проверяет полноту веток. Полиморфизм через интерфейс выигрывает, когда набор открытый — плагины, новые провайдеры, новые тарифы.

Подробный пример со скидками — в статье SOLID. Готовые структуры под полиморфизм — в каталоге GoF (Strategy, Template Method).

Pure Fabrication — выдуманный класс ради чистоты

В предметной области нет понятия «репозиторий». Покупатель не говорит «положи заказ в OrderRepository». Но если не придумать такой класс, логика работы с базой данных размажется по доменным объектам, и домен сцепится с конкретным хранилищем.

Pure Fabrication говорит: класс, не представляющий реального доменного понятия, допустим, если он улучшает связность и снижает coupling.

OrderRepository, OrderMapper, Clock, UuidGenerator — всё это выдумки ради чистоты дизайна. Принцип легализует их: это не нарушение, а осознанное архитектурное решение.

Indirection — посредник разрывает прямую связь

Два класса знают друг о друге — значит, изменение одного тянет изменение другого.

Indirection говорит: введи посредника, и оба будут знать только о нём, но не друг о друге.

Примеры посредников: шина событий между издателем и подписчиками, интерфейс-порт между сервисом и внешней системой, диспетчер между транспортом и обработчиками.

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

Protected Variations — стабильный интерфейс вокруг точки изменений

Всё меняется: провайдеры, форматы, внешние API, требования. Вопрос в том, расходятся ли эти изменения волной по всему коду или поглощаются в одном месте.

Protected Variations говорит: найди точки вероятных изменений и закрой их стабильным интерфейсом.

Платёжный провайдер сменится — значит, код работает с интерфейсом PaymentGateway, а не с SDK конкретного провайдера. Формат внешнего события эволюционирует — значит, между ним и доменом стоит слой преобразования. Чужой API нестабилен — значит, его изолируют за адаптером.

OCP и DIP из SOLID — конкретные техники реализации этого принципа. Гексагональная архитектура — его систематическое применение ко всему сервису.

Обратная сторона — YAGNI: защищать стоит вероятные изменения, а не все мыслимые. Интерфейс ради интерфейса — это тоже накладные расходы.

Коротко

  • Information Expert: логика живёт там, где данные. Если для вычисления нужны только поля объекта — метод принадлежит ему, не сервису.
  • Creator: объект создаёт тот, кто им владеет или имеет данные для инициализации. Агрегат создаёт свои внутренние сущности.
  • Controller: выделенный координатор операции, отдельный от транспортного слоя — тогда логику можно вызвать из любого транспорта и теста.
  • Low Coupling: меньше связей между классами — изменения локальны. Зависеть от интерфейса, не от реализации.
  • High Cohesion: класс делает одно, и всё в нём связано между собой. Несвязанные вещи — в разные классы.
  • Polymorphism: switch по типу — сигнал заменить на интерфейс и реализации. Для закрытых наборов — запечатанные типы.
  • Pure Fabrication: класс вне домена (Repository, Mapper, Clock) — не нарушение, а осознанная выдумка ради чистоты.
  • Indirection: посредник (интерфейс, шина, диспетчер) разрывает прямую связь. Но посредников не должно быть больше, чем нужно.
  • Protected Variations: стабильный интерфейс вокруг точки изменений поглощает их локально, а не распространяет волной.

Что почитать дальше

  • SOLID на примерах — принципы о том, каким должен быть класс, которому вы отдали ответственность.
  • Паттерны GoF — готовые конструкции для Polymorphism, Indirection и Pure Fabrication.
  • Тактические паттерны DDD — Information Expert и Creator в контексте агрегатов.