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

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

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

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

куда положить метод: в сервис или туда, где лежат данные? OrderService Order lines: цена, кол-во total(order)складывает сам getLines(), getPrice() пассивный: одни геттеры логика в сервисе, данные в объекте — анемичная модель order.total() один вызов total()считает по своим данным метод переехал к данным — Information Expert

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

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

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

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

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

При этом анемичная модель бывает честным выбором. Сервис, который принимает форму, проверяет три поля и кладёт строку в базу, никаких инвариантов не защищает, и доменный объект в нём будет пустой церемонией. Правило простое: есть правила, которые должны выполняться всегда, — модель богатая; есть только поля и их чтение-запись — пусть так и остаётся.

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

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

живой пример

import java.util.List;

public class OrderTotalDemo {

    record OrderLine(String product, int price, int quantity) {
        int subtotal() {
            return price * quantity;
        }
    }

    record Order(List<OrderLine> lines) {
        int total() {
            return lines.stream().mapToInt(OrderLine::subtotal).sum();
        }
    }

    public static void main(String[] args) {
        Order order = new Order(List.of(
            new OrderLine("mouse", 1200, 2),
            new OrderLine("mousepad", 300, 1)));
        System.out.println("сумма заказа: " + order.total());
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

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

Обратная сторона правила — конфликт с Low Coupling, о котором речь пойдёт ниже. Эксперт по данным иногда оказывается плохим кандидатом: чтобы посчитать итог со скидкой по программе лояльности и налогом по региону, заказу придётся узнать и про программу лояльности, и про справочник ставок, то есть про половину системы.

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

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

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

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

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

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

живой пример

import java.util.ArrayList;
import java.util.List;

public class CreatorDemo {

    record OrderLine(String product, int quantity) {}

    enum Status { CREATED, COMPLETED }

    static class Order {

        private final List<OrderLine> lines = new ArrayList<>();
        private Status status = Status.CREATED;

        void addLine(String product, int quantity) {
            if (status != Status.CREATED) {
                throw new IllegalStateException("заказ уже " + status);
            }
            lines.add(new OrderLine(product, quantity));
        }

        void complete() {
            status = Status.COMPLETED;
        }

        int lineCount() {
            return lines.size();
        }
    }

    public static void main(String[] args) {
        Order order = new Order();
        order.addLine("mouse", 2);
        System.out.println("строк в заказе: " + order.lineCount());
        order.complete();
        try {
            order.addLine("mousepad", 1);
        } catch (IllegalStateException e) {
            System.out.println("отказ: " + e.getMessage());
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

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

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

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

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

// Транспортный слой — только разбирает запрос и делегирует
@RestController
@RequiredArgsConstructor
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
@RequiredArgsConstructor
public class CancelOrderHandler {

    private final OrderRepository orders;

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

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

CancelOrderHandler HTTP-контроллер разобрал запрос слушатель очереди принял сообщение тест позвал напрямую Order.cancel() правила

Координатор операции вынесен из транспорта, поэтому одну и ту же отмену вызывают из HTTP, из очереди и из теста.

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

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

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

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

OrderService OrderRepository CustomerRepository PricingPolicy NotificationPort WarehouseClient AuditLog Clock

Семь параметров конструктора это семь причин пересобраться и семь заглушек в тесте.

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

Как выглядит нарушение — обычно по списку зависимостей в конструкторе:

// семь зависимостей: класс знает о половине системы
OrderService(OrderRepository orders, CustomerRepository customers, PricingPolicy pricing,
             NotificationPort notifications, WarehouseClient warehouse,
             AuditLog audit, Clock clock)

// то же после деления по операциям: две зависимости и событие наружу
CancelOrderHandler(OrderRepository orders, ApplicationEventPublisher events)

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

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

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

OrderService, 800 строк создание заказа отмена экспорт в Excel подсчёт статистики

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

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

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

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

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

На уровне пакетов разница видна нагляднее всего:

shop/                      shop/
  order/                     services/
  delivery/                  repositories/
  billing/                   dto/

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

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

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

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

живой пример

import java.util.List;
import java.util.Map;

public class DiscountDemo {

    interface DiscountPolicy {
        int finalPrice(int price);
    }

    static class NoDiscount implements DiscountPolicy {
        public int finalPrice(int price) {
            return price;
        }
    }

    static class VipDiscount implements DiscountPolicy {
        public int finalPrice(int price) {
            return price * 80 / 100;
        }
    }

    public static void main(String[] args) {
        Map<String, DiscountPolicy> policies =
            Map.of("regular", new NoDiscount(), "vip", new VipDiscount());
        for (String customer : List.of("regular", "vip")) {
            System.out.println(customer + ": " + policies.get(customer).finalPrice(5000));
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

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

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

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

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

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

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

// так не надо: домен сам знает про базу
public class Order {
    public void save(Connection connection) throws SQLException { /* SQL прямо в агрегате */ }
}

// так надо: выдуманный класс берёт хранение на себя
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
}

Где проходит граница между оправданной выдумкой и свалкой? Вопрос ровно один: у выдуманного класса должна быть тема, а не хозяин. OrderRepository — тема: хранение заказов, всё в нём про одно, и имя предсказывает содержимое. OrderManager, OrderHelper, OrderUtils темы не имеют, это «всё остальное, что связано с заказом», и такой класс растёт, пока в нём не окажутся проверка формы, отправка письма и разбор выгрузки.

Проверка простая: попробуйте назвать класс по тому, что он делает, не используя слов manager, helper и utils. Получилось — выдумка оправдана. Не получилось — ответственность не найдена, а отложена.

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

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

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

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

живой пример

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

public class IndirectionDemo {

    record OrderCancelled(String orderId) {}

    static class EventBus {
        private final List<Consumer<OrderCancelled>> subscribers = new ArrayList<>();

        void subscribe(Consumer<OrderCancelled> subscriber) {
            subscribers.add(subscriber);
        }

        void publish(OrderCancelled event) {
            subscribers.forEach(subscriber -> subscriber.accept(event));
        }
    }

    static class CancelOrderHandler {
        private final EventBus bus;

        CancelOrderHandler(EventBus bus) {
            this.bus = bus;
        }

        void handle(String orderId) {
            System.out.println("заказ " + orderId + " отменён");
            bus.publish(new OrderCancelled(orderId));
        }
    }

    public static void main(String[] args) {
        EventBus bus = new EventBus();
        bus.subscribe(event -> System.out.println("  письмо про " + event.orderId()));
        bus.subscribe(event -> System.out.println("  возврат денег по " + event.orderId()));
        new CancelOrderHandler(bus).handle("A-1");
        System.out.println("обработчик не знает ни про письмо, ни про возврат");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

CancelOrderHandler EventBus публикует событие письмо покупателю возврат денег журнал аудита

Обработчик знает только про шину: подписчиков добавляют и убирают, его код от этого не меняется.

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

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

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

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

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

живой пример

public class ProtectedVariationsDemo {

    interface PaymentGateway {
        String charge(int amount);
    }

    static class AcmeGateway implements PaymentGateway {
        public String charge(int amount) {
            return "провайдер A: списано " + amount;
        }
    }

    static class BetaGateway implements PaymentGateway {
        public String charge(int amount) {
            return "провайдер B: списано " + amount + " (другой SDK, другой формат ответа)";
        }
    }

    record PayOrderHandler(PaymentGateway gateway) {
        String pay(int amount) {
            return gateway.charge(amount);
        }
    }

    public static void main(String[] args) {
        System.out.println(new PayOrderHandler(new AcmeGateway()).pay(1500));
        System.out.println(new PayOrderHandler(new BetaGateway()).pay(1500));
        System.out.println("провайдер сменился — PayOrderHandler не изменился");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

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

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

Зачем это на ревью

Ценность девяти имён не в том, что они правильные, а в том, что они общие. Без общего словаря вопрос «почему этот метод здесь, а не в заказе?» превращается в спор вкусов: одному логичнее в сервисе, другому в объекте, и выигрывает тот, кто настойчивее. С GRASP тот же вопрос звучит как «у кого данные?» — и проверяется по коду, а не по ощущению.

Так же работают остальные: «этот класс делает три несвязанные вещи» — High Cohesion; «обработчику нужны семь зависимостей» — Low Coupling; «OrderManager — это про что?» — Pure Fabrication без темы; «интерфейс с единственной реализацией внутри одного модуля» — Indirection без выгоды; «здесь switch по типу, который уже встречался дважды» — Polymorphism. Имя превращает вкусовое замечание в проверяемое, и правку после такого замечания делают, не обижаясь.

Коротко

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

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