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

SOLID и GRASP — про проектирование классов. Принципы из этой статьи старше и шире: они про инженерные решения вообще — от строчки кода до выбора, нужна ли вам ещё одна служба. Их часто цитируют как заклинания; здесь — что каждый значит на самом деле, где у него границы и как он выглядит в коде.

Обязательно

DRY — не повторяй знаниеспросят на собеседовании

Представьте: правило «заказ дороже 10 000 ₽ — бесплатная доставка» записано в трёх местах — в обработчике, в SQL-отчёте и в форме на фронтенде. Порог повысили до 15 000 ₽ — сделали три правки, и одну забыли. Пользователи получают бесплатную доставку, когда не должны.

LIMIT = Money(15_000) обработчик отчёт форма

Знание в одном месте: три потребителя читают одну константу; копия в каждом это три места для правки и одно забытое, которое раздаёт доступ по старому порогу.

DRY (Don't Repeat Yourself) — каждая единица знания должна иметь одно авторитетное место в системе.

Ключевое слово — знание, не текст. DRY не нарушен, когда два куска кода совпадают текстуально, но несут разные знания. Лимит длины имени покупателя и лимит длины названия товара могут оба равняться 255 — но это два независимых решения, и склеивать их в одну константу MAX_NAME_LENGTH вредно: изменение одного потащит другое.

CUSTOMER_NAME_MAX_LENGTH = 255  # независимое решение

PRODUCT_TITLE_MAX_LENGTH = 255  # тоже независимое

Склейка случайно похожих вещей в одну абстракцию — самая дорогая форма нарушения DRY. Потом каждое изменение требует if-параметров, потому что «общая» константа оказывается не такой уж общей.

Практическое эмпирическое правило — правило трёх: терпите дублирование до третьего повторения. К третьему разу станет видно, что в нём общее знание, а что просто совпадение.

KISS — из работающих решений выбирай простейшееспросят на собеседовании

Каждая дополнительная концепция в коде — налог на каждого будущего читателя. Асинхронный код на asyncio добавляет нетривиальные конструкции и другой стиль отладки — и оправдан только тогда, когда профиль нагрузки этого требует. Очередь задач на таблице PostgreSQL со SKIP LOCKED проще брокера сообщений — если событий тысяча в час, брокер «на вырост» означает отдельный кластер, мониторинг и команду, которая в нём разбирается.

KISS (Keep It Simple) — простота измеряется числом концепций, которые нужно держать в голове, чтобы понять код.

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

YAGNI — не строй до потребностиспросят на собеседовании

Разработчик делает конфигурируемость «на будущее», хотя второго потребителя пока нет. Добавляет поле version в API, которым никто не версионирует. Разворачивает сложную многослойную архитектуру для прототипа, у которого ещё нет ни одного реального пользователя.

YAGNI (You Aren't Gonna Need It) — не делай сейчас то, что нужно потом. Всё это — ставки на будущее, которые обычно не сбываются, а обслуживания требуют уже сегодня.

KISS — про форму решения, YAGNI — про время: не «сделай проще», а «не делай сейчас».

Важная граница: YAGNI — про функциональность, не про качество. Тесты, понятные имена и миграции со стратегией отката «понадобятся потом» всегда — на них принцип не распространяется.

Separation of Concerns — разные аспекты в разных модулях

Код без разделения обязанностей выглядит так: метод в бизнес-логике сам разбирает HTTP-заголовки, сам открывает транзакцию, сам форматирует ответ. Поменяли формат ответа — трогаем бизнес-логику. Поменяли базу данных — трогаем HTTP-слой.

Separation of Concerns — разные аспекты системы должны жить в разных модулях: смешение делает каждый из них неизменяемым без риска сломать другой.

Транзакции, безопасность, кеширование, метрики отделяются от бизнес-логики через декораторы и middleware. HTTP-разбор отделён от обработки роутером и Pydantic-моделями. Конфигурация — от кода переменными окружения и settings-объектами.

Признак смешения — импорт через границу: HTTP-типы в бизнес-логике, объекты базы данных в HTTP-обработчике.

Law of Demeter — не лезь во внутренности чужих объектов

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

# нарушение — лезем во внутренности через несколько уровней
city = order.customer.address.city

Чем глубже цепочка, тем сильнее связанность: OrderService теперь знает о внутреннем устройстве Customer и Address. Стоит переименовать поле в Address — ломается код в OrderService.

Law of Demeter — метод вызывает методы своих полей, параметров и локальных объектов, но не «друзей друзей».

# лечение — спрашиваем у ближайшего объекта
city = order.shipping_city()

Метод shipping_city() на объекте Order знает про внутренности — это его ответственность. OrderService больше не знает ничего лишнего.

Важная оговорка: fluent API (select(...).where(...).order_by(...) в SQLAlchemy) и цепочки трансформаций — не нарушение. Там каждый вызов возвращает тот же объект-построитель или трансформирует значения, а не лезет в чужие внутренности. Закон — про структурную связанность, а не про количество точек в строке.

Composition over Inheritance — делегируй, не наследуйспросят на собеседовании

Представьте AbstractOrderService с методом process, который вызывает validate. Каждый наследник переопределяет validate. Потом нужно переиспользовать валидацию в другом сервисе — и выясняется, что она прибита к иерархии. Или выходит новая версия фреймворка, и AbstractOrderService меняет внутренний порядок вызовов — все наследники неожиданно ломаются.

Composition over Inheritance — переиспользуй поведение через делегирование, а не через наследование классов.

# наследование — хрупко
class BaseOrderService(ABC):

    @abstractmethod
    def validate(self, order: Order) -> None: ...

    def process(self, order: Order) -> None:
        self.validate(order)


# композиция — гибко
class OrderProcessor:

    def __init__(self, validator: OrderValidator, repository: OrderRepository) -> None:
        self._validator = validator
        self._repository = repository

    def process(self, order: Order) -> None:
        self._validator.validate(order)
        self._repository.save(order)

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

Когда наследование уместно: точки расширения, которые библиотека или фреймворк сам спроектировал под наследование (Template Method по явному контракту). Своё прикладное поведение — всегда через композицию.

Fail Fast — ошибка должна проявляться рано

None прокрался через пять слоёв и всплыл как AttributeError в чужом модуле в пятницу ночью на проде. На поиск причины ушло три часа, потому что ошибка возникла далеко от того места, где сломалось.

Fail Fast — ошибка должна проявиться как можно раньше и как можно ближе к своей причине.

Три рубежа:

  • Старт приложения — некорректная конфигурация должна валить сервис при запуске, а не в рантайме на проде.
  • Граница запроса — входные данные проверяются до бизнес-логики.
  • Конструктор объекта — объект не должен существовать в некорректном состоянии.
@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: Currency

    def __post_init__(self) -> None:
        scale = -self.amount.as_tuple().exponent
        if scale > self.currency.fraction_digits:
            raise ValueError(
                f"scale {scale} превышает допустимый для {self.currency}"
            )

Антипод — «оборонительное» глотание: except Exception as e: logger.error(e); return None. Такой код превращает раннюю ошибку в позднюю и уничтожает контекст, который помог бы её диагностировать.

Principle of Least Astonishment — код делает то, что обещает

Метод называется get_order(), но заодно меняет статус заказа. GET-эндпоинт с побочным эффектом. __eq__, сравнивающий по идентичности у типа-значения. Всё это работает, но врёт читателю.

Principle of Least Astonishment — код должен делать то, что ожидает читатель; удивление — признак дефекта дизайна.

Цена — не эстетика: код, которому нельзя верить по сигнатуре, приходится читать целиком, каждому, каждый раз.

Конвенции — прикладная форма принципа: query-методы не должны изменять состояние; GET-запрос должен быть безопасным, а PUT — идемпотентным. Нарушение этих конвенций ломает кеши и логику повторных запросов, которые на них рассчитывают.

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

Бритва Оккама — не плоди сущности сверх необходимого

Второй микросервис, когда один домен и одна команда. Redis при ста запросах в секунду, «потому что масштабирование». Отдельный общий модуль ради трёх утилит. Брокер сообщений для трёх событий в день.

Бритва Оккама в проектировании: каждая сущность — сервис, брокер, база, библиотека, слой, паттерн — должна оправдывать своё существование.

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

KISS — про форму кода, бритва Оккама — про состав системы. Оба про экономию, на разных уровнях.

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

Глубже: неизменяемость как проектное решениерасширенное

Неизменяемость мелькала выше по касательной: у Fail Fast, у Least Astonishment, у value objects. Пора сказать прямо: объект, который нельзя изменить после создания, это не синтаксическая мелочь, а решение, которое упрощает всё остальное.

Что даёт. Неизменяемый объект можно отдать в две asyncio-задачи или в пул потоков без мыслей о гонке. Его можно класть в set и делать ключом словаря: @dataclass(frozen=True) сам сгенерирует __hash__ по полям. Его можно сравнивать по значению и кешировать результат вычисления. И главное, его не надо копировать «на всякий случай» перед передачей в чужой код.

Как делают в Python. @dataclass(frozen=True) для своих типов, tuple вместо list в полях, frozenset вместо set, types.MappingProxyType как read-only окно на словарь, typing.Final для констант модуля. Изменение выражают созданием нового объекта: dataclasses.replace(order, status=Status.CONFIRMED).

Где ловушка. Заморозка неглубокая: frozen=True с полем items: list запрещает переприсвоить items, но order.items.append(...) работает. Поэтому у неизменяемого типа поля тоже неизменяемые, иначе гарантия только на бумаге. И Final проверяет только анализатор типов, интерпретатор его не видит.

Где не надо. Агрегат с жизненным циклом (заказ, который подтверждают и отменяют) меняется по определению; неизменяемыми делают его части: деньги, адрес, позицию, событие. Горячий цикл, который строит объект по кусочкам, собирают в изменяемом виде и замораживают на выходе.

Глубже: тестопригодность как критерий дизайнарасширенное

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

Тест требует unittest.mock.patch("app.orders.service.datetime") или подмены uuid.uuid4 по пути модуля: значит, время и случайность зашиты внутрь. Лечение: функция или объект получает их аргументом, clock: Callable[[], datetime], и тест передаёт константу без патчей.

Тест поднимает PostgreSQL ради проверки правила «нельзя подтвердить пустой заказ»: правило живёт рядом с базой, а не в объекте. Лечение: правило в методе агрегата, который тестируется без инфраструктуры, об этом статья про слой core.

Тест проверяет три несвязанных исхода одним вызовом и ломается от любой правки: у функции несколько ответственностей, то есть нарушен Separation of Concerns.

Тест патчит httpx.AsyncClient.post по пути библиотеки: зависимость от внешней системы не оформлена портом. Лечение: Protocol с нужным методом и поддельная реализация в памяти, тогда patch не нужен вовсе.

Правило простое: patch по строковому пути модуля в тесте допустим для чужого кода, который вы не контролируете; для своего это сигнал передать зависимость явно.

Коротко

  • DRY — одно знание в одном месте; случайное совпадение текста — не нарушение DRY.
  • KISS — из работающих решений выбирай то, что проще объяснить; каждая концепция — налог на читателя.
  • YAGNI — не строй до реальной потребности; на тесты и понятные имена не распространяется.
  • Separation of Concerns — разные аспекты в разных модулях; импорт через границу — признак смешения.
  • Law of Demeter — метод работает с ближайшими объектами, не лезет через цепочку во внутренности; fluent API — не нарушение.
  • Composition over Inheritance — переиспользуй через делегирование; наследование уместно только там, где фреймворк явно под него спроектирован.
  • Fail Fast — ошибка дешевле ранняя; конструктор, граница запроса, старт приложения — три рубежа.
  • Least Astonishment — код делает то, что обещает по сигнатуре и конвенциям стека.
  • Бритва Оккама — каждая сущность в системе должна оправдывать своё существование.
  • Неизменяемость (frozen=True, кортежи, MappingProxyType) даёт безопасное разделение между задачами и хешируемость; трудный тест с патчами времени и глобалей — симптом дизайна, а не теста.

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