Когда вы пишете новый метод, возникает вопрос: в какой класс его положить? Один разработчик кладёт в сервис, другой — в доменный объект, третий — в отдельный хелпер. Без ориентира каждый решает по-своему, и через год код становится трудно читаемым.
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 — конструктор с семью параметрами: значит, класс знает о слишком многих.
# семь зависимостей: класс знает о половине системы
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 с явным именем, а не компромисс внутри класса.
Что почитать дальше
- SOLID на Python — принципы о том, каким должен быть класс, которому вы отдали ответственность.
- Паттерны GoF на Python — готовые конструкции для Polymorphism, Indirection и Pure Fabrication.
- Тактические паттерны DDD на Python — Information Expert и Creator в контексте агрегатов.