Когда вы пишете новый метод, возникает вопрос: в какой класс его положить? Один разработчик кладёт в сервис, другой — в доменный объект, третий — в отдельный хелпер. Без ориентира каждый решает по-своему, и через год код становится трудно читаемым.
GRASP (General Responsibility Assignment Software Patterns) — девять принципов из книги Крейга Лармана «Applying UML and Patterns», которые дают конкретные критерии: смотри на эти признаки — и узнаешь, кому отдать ответственность.
SOLID описывает, каким должен быть класс в целом. GRASP отвечает на более конкретный вопрос: кто за что отвечает прямо сейчас.
Information Expert — логика там, где данные
Самый частый вопрос при проектировании: «в сервисе или в доменном объекте?»
Без ориентира разработчик кладёт всё в сервис, потому что так привычнее. В итоге доменный объект становится пассивным контейнером атрибутов, а сервис — длинным скриптом, который дёргает эти атрибуты и сам считает итог. Такую ситуацию называют анемичной моделью: данные в одном месте, логика в другом.
Information Expert говорит: отдай ответственность тому, у кого уже есть данные для её выполнения.
Вычислить сумму заказа — у кого данные? У самого заказа: он знает строки и цены.
class Order:
def __init__(self, lines: list[OrderLine]) -> None:
self._lines = lines
def total(self) -> Money:
return sum((line.subtotal() for line in self._lines), start=Money.ZERO)
Теперь метод OrderService.calculate_total(order), который тащит данные через атрибуты, просто не нужен. При изменении структуры строк меняется только Order, а не ещё и сервис.
Правило работает на двух уровнях: для мелких вычислений (метод принадлежит объекту с данными) и для крупных операций (если нужны внешние зависимости — база, очередь — то это уже уровень сервиса, а не агрегата).
Creator — кто создаёт объект
Раньше: обработчик HTTP-запроса создаёт OrderLine и кладёт его в заказ через присваивание. Или фабричный метод в сервисе создаёт строку, ничего не зная о правилах заказа.
Боль: инвариант «нельзя добавить строку в завершённый заказ» некому проверить — он размазан по всем точкам создания.
Creator говорит: объект A создаёт тот, кто содержит A, агрегирует A или владеет данными для его инициализации.
OrderLine создаёт Order — он содержит строки и знает, когда их можно добавлять:
class Order:
def add_line(self, product: Product, quantity: int) -> None:
self._ensure_status(Status.CREATED)
self._lines.append(OrderLine.of(product.id, product.price, quantity))
Обработчик запроса больше не знает про OrderLine и не может обойти проверку статуса. Инвариант защищён в одном месте.
Controller — кто координирует операцию
GRASP Controller — это не HTTP-контроллер фреймворка.
Транспортный слой (HTTP, очередь сообщений) отвечает только за одно: принять запрос, передать его дальше, вернуть ответ. Если в него перетечёт бизнес-логика, она станет недостижимой из другого транспорта и непроверяемой без запуска HTTP-стека.
Controller по GRASP — выделенный объект, который координирует выполнение одной операции: получает нужные данные, вызывает домен, сохраняет результат.
# Транспортный слой — только разбирает запрос и делегирует
@router.post("/v1/orders/{order_id}/cancel")
def cancel(
order_id: UUID,
req: CancelOrderRequest,
handler: CancelOrderHandler = Depends(get_cancel_order_handler),
) -> None:
handler.handle(CancelOrderCommand(order_id, req.reason))
# GRASP Controller — координирует операцию
class CancelOrderHandler:
def __init__(self, orders: OrderRepository, session: Session) -> None:
self._orders = orders
self._session = session
def handle(self, command: CancelOrderCommand) -> None:
with self._session.begin():
order = self._orders.find_by_id(command.order_id)
order.cancel(command.reason)
self._orders.save(order)
Когда такой роли в системе нет, координация расползается по транспортному слою — и та же операция не может быть вызвана ни из очереди сообщений, ни из теста без HTTP.
Low Coupling — меньше связей между классами
Связь — это всё, что заставит класс измениться вслед за другим: тип поля, прямой вызов метода, наследование.
Чем больше связей, тем больнее любое изменение: правишь один класс — волна расходится по десяти.
Low Coupling говорит: из равных вариантов выбирай тот, что создаёт меньше связей.
На практике это означает: зависеть от Protocol, а не от конкретного класса; общаться через события, а не через прямые вызовы; не делать один объект зависящим от половины системы.
Хороший диагноз высокого coupling — конструктор с семью параметрами: значит, класс знает о слишком многих.
High Cohesion — один класс, одна тема
Низкая связность выглядит так: класс OrderService на восемьсот строк, в котором живут создание заказа, отмена, экспорт в Excel и подсчёт статистики. Эти четыре темы не связаны между собой — меняешь одну, рискуешь зацепить другую.
High Cohesion говорит: класс делает близкие по смыслу вещи; несвязанные ответственности — в разных классах.
CancelOrderHandler связен: всё в нём служит одной операции. Когда правила отмены изменятся, знаешь точно, какой файл открывать.
Тот же принцип применяется к пакетам: пакет shop.order со всем, что относится к заказу, связнее, чем пакет shop.services с сервисами всех доменов разом.
Low Coupling и High Cohesion — пара: минимум связей между классами, максимум связности внутри каждого.
Polymorphism — поведение по типу без условных операторов
match по типу клиента, if по способу оплаты, условные цепочки по формату экспорта — каждый новый вариант требует лезть в тот же код и добавлять новую ветку.
Polymorphism говорит: поведение, зависящее от типа, — полиморфизму, а не цепочкам условий.
class DiscountPolicy(Protocol):
def apply(self, price: Money) -> Money: ...
class NoDiscount:
def apply(self, price: Money) -> Money:
return price
class VipDiscount:
def apply(self, price: Money) -> Money:
return price * Decimal("0.8")
Новый тип скидки — новый класс, никакой правки существующего кода.
Для закрытого набора вариантов (их количество не будет расти) union-типы (Card | Sbp | Cash в Python, union types в TypeScript) с match — равноправная альтернатива. Тайп-чекер проверяет полноту веток через assert_never. Полиморфизм через Protocol выигрывает, когда набор открытый — плагины, новые провайдеры, новые тарифы.
Подробный пример со скидками — в статье SOLID. Готовые структуры под полиморфизм — в каталоге GoF (Strategy, Template Method).
Pure Fabrication — выдуманный класс ради чистоты
В предметной области нет понятия «репозиторий». Покупатель не говорит «положи заказ в OrderRepository». Но если не придумать такой класс, логика работы с базой данных размажется по доменным объектам, и домен сцепится с конкретным хранилищем.
Pure Fabrication говорит: класс, не представляющий реального доменного понятия, допустим, если он улучшает связность и снижает coupling.
OrderRepository, OrderMapper, Clock, UuidGenerator — всё это выдумки ради чистоты дизайна. Принцип легализует их: это не нарушение, а осознанное архитектурное решение.
Indirection — посредник разрывает прямую связь
Два класса знают друг о друге — значит, изменение одного тянет изменение другого.
Indirection говорит: введи посредника, и оба будут знать только о нём, но не друг о друге.
Примеры посредников: шина событий между издателем и подписчиками, порт-Protocol между сервисом и внешней системой, диспетчер между транспортом и обработчиками.
Есть и обратная сторона. Классика: «нет проблемы, которую нельзя решить дополнительным уровнем косвенности — кроме проблемы слишком многих уровней». Посредник оправдан, когда он разрывает связь, которая должна быть разорвана. Protocol с единственной реализацией внутри одного маленького модуля — это накладные расходы без выгоды.
Protected Variations — стабильный интерфейс вокруг точки изменений
Всё меняется: провайдеры, форматы, внешние API, требования. Вопрос в том, расходятся ли эти изменения волной по всему коду или поглощаются в одном месте.
Protected Variations говорит: найди точки вероятных изменений и закрой их стабильным интерфейсом.
Платёжный провайдер сменится — значит, код работает с протоколом PaymentGateway, а не с SDK конкретного провайдера. Формат внешнего события эволюционирует — значит, между ним и доменом стоит слой преобразования. Чужой API нестабилен — значит, его изолируют за адаптером.
OCP и DIP из SOLID — конкретные техники реализации этого принципа. Гексагональная архитектура — его систематическое применение ко всему сервису.
Обратная сторона — YAGNI: защищать стоит вероятные изменения, а не все мыслимые. Интерфейс ради интерфейса — это тоже накладные расходы.
Коротко
- Information Expert: логика живёт там, где данные. Если для вычисления нужны только поля объекта — метод принадлежит ему, не сервису.
- Creator: объект создаёт тот, кто им владеет или имеет данные для инициализации. Агрегат создаёт свои внутренние сущности.
- Controller: выделенный координатор операции, отдельный от транспортного слоя — тогда логику можно вызвать из любого транспорта и теста.
- Low Coupling: меньше связей между классами — изменения локальны. Зависеть от
Protocol, не от реализации. - High Cohesion: класс делает одно, и всё в нём связано между собой. Несвязанные вещи — в разные классы.
- Polymorphism:
matchпо типу — сигнал заменить наProtocolи реализации. Для закрытых наборов — union-типы. - Pure Fabrication: класс вне домена (Repository, Mapper, Clock) — не нарушение, а осознанная выдумка ради чистоты.
- Indirection: посредник (протокол, шина, диспетчер) разрывает прямую связь. Но посредников не должно быть больше, чем нужно.
- Protected Variations: стабильный интерфейс вокруг точки изменений поглощает их локально, а не распространяет волной.
Что почитать дальше
- SOLID на примерах — принципы о том, каким должен быть класс, которому вы отдали ответственность.
- Паттерны GoF — готовые конструкции для Polymorphism, Indirection и Pure Fabrication.
- Тактические паттерны DDD — Information Expert и Creator в контексте агрегатов.