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

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

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 в контексте агрегатов.