Когда вы пишете новый метод, возникает вопрос: в какой класс его положить? Один разработчик кладёт в сервис, другой — в доменный объект, третий — в отдельный хелпер. Без ориентира каждый решает по-своему, и через год код становится трудно читаемым.
GRASP (General Responsibility Assignment Software Patterns) — девять принципов из книги Крейга Лармана «Applying UML and Patterns», которые дают конкретные критерии: смотри на эти признаки — и узнаешь, кому отдать ответственность.
SOLID описывает, каким должен быть класс в целом. GRASP отвечает на более конкретный вопрос: кто за что отвечает прямо сейчас.
Один и тот же расчёт в двух местах. Пока сумму считает сервис, он вынужден вытаскивать строки и цены наружу через геттеры. Как только метод переезжает в 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.
Координатор операции вынесен из транспорта, поэтому одну и ту же отмену вызывают из HTTP, из очереди и из теста.
Следующий вопрос, который читатель задаёт сразу: что тогда пишут внутри координатора, а что остаётся в агрегате? Граница проходит по данным. Координатор отвечает за ход операции: достать объекты, позвать домен, сохранить результат, опубликовать событие, решить, в какой транзакции это происходит. Агрегат отвечает за правила над собственным состоянием: можно ли отменить, что станет со статусом, какая получится сумма.
Практический признак — что нужно для проверки правила. Если правило проверяется по одному объекту и его полям, оно живёт в объекте: «отменить можно только неоплаченный заказ» — это Order.cancel(). Если для проверки надо сходить в репозиторий или в другой сервис, правило живёт в координаторе: «нельзя оформить заказ, пока у покупателя есть непогашенный долг» — это обработчик, потому что долг лежит в другом агрегате.
Low Coupling — меньше связей между классами
Правишь один класс, и волна расходится по десяти: связь это всё, что заставит класс измениться вслед за другим, тип поля, прямой вызов метода, наследование, и чем больше связей, тем больнее любое изменение. Хороший признак лишней связанности — конструктор с семью параметрами: класс знает о слишком многих.
Семь параметров конструктора это семь причин пересобраться и семь заглушек в тесте.
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 и подсчёт статистики. Эти четыре темы не связаны между собой — меняешь одну, рискуешь зацепить другую.
Четыре несвязанные темы в одном классе: правка любой из них открывает файл, где живут остальные три.
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("обработчик не знает ни про письмо, ни про возврат");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Подписчиков стало двое, обработчик не изменился и про них по-прежнему не знает. Добавится третий — тоже не изменится: это и есть разорванная прямая связь.
Обработчик знает только про шину: подписчиков добавляют и убирают, его код от этого не меняется.
Есть и обратная сторона. Классика: «нет проблемы, которую нельзя решить дополнительным уровнем косвенности — кроме проблемы слишком многих уровней». Посредник оправдан, когда он разрывает связь, которая должна быть разорвана. Интерфейс с единственной реализацией внутри одного маленького модуля — это накладные расходы без выгоды.
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 полезен прежде всего как язык ревью: «у кого данные?» проверяется по коду, «мне кажется логичнее» — нет.
Что почитать дальше
- SOLID на примерах — принципы о том, каким должен быть класс, которому вы отдали ответственность.
- Паттерны GoF — готовые конструкции для Polymorphism, Indirection и Pure Fabrication.
- Тактические паттерны DDD — Information Expert и Creator в контексте агрегатов.
- Clean Code: имена, функции, комментарии — что делать с классом дальше, когда ответственность ему уже досталась.