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

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

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

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

Обязательно

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

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

сервис считает OrderService.total() тянет lines и prices геттерами заказ — пассивный контейнер Expert Order.total(pricing) считает по своим lines политика цены передана аргументом

Сумму считает тот, у кого данные: заказ знает свои строки, а правила цены получает аргументом, чтобы не тянуть к себе тарифы и налоги.

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

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 — конструктор с семью параметрами: значит, класс знает о слишком многих.

# семь зависимостей: класс знает о половине системы
class OrderService:
    def __init__(self, orders: OrderRepository, customers: CustomerRepository, pricing: PricingPolicy,
                 notifications: NotificationPort, warehouse: WarehouseClient,
                 audit: AuditLog, clock: Clock) -> None: ...

# то же после деления по операциям: две зависимости и событие наружу
class CancelOrderHandler:
    def __init__(self, orders: OrderRepository, events: EventPublisher) -> None: ...

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

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 — всё это выдумки ради чистоты дизайна. Принцип легализует их: это не нарушение, а осознанное архитектурное решение.

# так не надо: домен сам знает про базу
class Order:
    def save(self, conn: Connection) -> None:
        conn.execute("INSERT INTO orders (id) VALUES (%s)", (self.id,))

# так надо: выдуманный класс берёт хранение на себя
class OrderRepository(Protocol):
    def save(self, order: Order) -> None: ...
    def find_by_id(self, order_id: OrderId) -> Order | None: ...

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

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

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

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

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

живой пример

from dataclasses import dataclass, field
from typing import Callable


@dataclass(frozen=True)
class OrderCancelled:
    order_id: str


@dataclass
class EventBus:
    subscribers: list[Callable[[OrderCancelled], None]] = field(default_factory=list)

    def subscribe(self, subscriber: Callable[[OrderCancelled], None]) -> None:
        self.subscribers.append(subscriber)

    def publish(self, event: OrderCancelled) -> None:
        for subscriber in self.subscribers:
            subscriber(event)


class CancelOrderHandler:
    def __init__(self, bus: EventBus) -> None:
        self.bus = bus

    def handle(self, order_id: str) -> None:
        print(f"заказ {order_id} отменён")
        self.bus.publish(OrderCancelled(order_id))


bus = EventBus()
bus.subscribe(lambda e: print(f"  письмо про {e.order_id}"))
bus.subscribe(lambda e: print(f"  возврат денег по {e.order_id}"))
CancelOrderHandler(bus).handle("A-1")
print("обработчик не знает ни про письмо, ни про возврат")
Запустить

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

Подписчики и обработчик знают только о шине. Переезд уведомлений в отдельный сервис это новый подписчик, а не правка обработчика.

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

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

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

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

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

живой пример

from typing import Protocol


class PaymentGateway(Protocol):
    def charge(self, amount: int) -> str: ...


class AcmeGateway:
    def charge(self, amount: int) -> str:
        return f"провайдер A: списано {amount}"


class BetaGateway:
    def charge(self, amount: int) -> str:
        return f"провайдер B: списано {amount} (другой SDK, другой формат ответа)"


class PayOrderHandler:
    def __init__(self, gateway: PaymentGateway) -> None:
        self.gateway = gateway

    def pay(self, amount: int) -> str:
        return self.gateway.charge(amount)


print(PayOrderHandler(AcmeGateway()).pay(1500))
print(PayOrderHandler(BetaGateway()).pay(1500))
print("провайдер сменился, PayOrderHandler не изменился")
Запустить

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

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

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

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

Дополнительно: при первом чтении можно пропустить

Глубже: когда принципы спорят между собойрасширенное

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

Expert против Low Coupling. Information Expert говорит: сумма заказа считается в Order, потому что позиции лежат там. Но сумма зависит от скидки по тарифу клиента, налога региона и округления по правилам бухгалтерии. Если тянуть всё это в Order ради Expert, заказ начинает импортировать тарифы, налоги и бухгалтерию, и Low Coupling проигран. Разрешение даёт Pure Fabrication: правило выносят в объект с собственным именем, а заказ получает его аргументом.

class Order:
    def total(self, pricing: PricingPolicy) -> Money:
        return pricing.apply(self.lines)

Order остаётся экспертом по своим позициям, PricingPolicy экспертом по правилам цены, и связь между ними одна, через Protocol.

Creator против фабрики. Creator отдаёт создание владельцу: заказ создаёт свои позиции. Когда для создания нужны данные трёх объектов (каталог, остатки, клиент), владелец перестаёт быть лучшим кандидатом, и снова появляется Pure Fabrication, фабрика с методом create. Признак, что пора: конструктор принимает репозитории.

Controller против роутера. В FastAPI соблазн назвать контроллером функцию под @router.post. Это транспорт: он разбирает HTTP и зовёт обработчик команды. Controller по GRASP это обработчик, который можно вызвать из Kafka-потребителя и из теста без HTTP; роутер туда логику не кладёт.

Protected Variations против YAGNI. Стабильный интерфейс вокруг точки изменений стоит вводить там, где изменение уже случалось или заведомо случится: внешняя система, хранилище, формат события. Один Protocol на каждый класс «на всякий случай» это Indirection без причины, и статья про принципы называет это нарушением YAGNI.

Коротко

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

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