handbookНаписать

principles/law-of-demeter/README.md · тема · 4 языка · 19 тестов

Закон Деметры (Law of Demeter) ​

Объект общается только с ближайшими соседями: спрашивает заказ, куда его везти, а не лезет через заказ в покупателя и его адрес.

Проблема ​

Служба доставки считает стоимость по городу и весу. Город она достаёт сама, цепочкой через чужие объекты:

php
final class Shipping
{
    public function costFor(Order $order): int
    {
        $city = $order->customer->address->city;
        $base = $this->baseByCity[$city];

        return $base + intdiv($order->weightGrams + 999, 1000) * $this->perKgCents;
    }
}

Shipping теперь знает, что у заказа есть покупатель, у покупателя адрес, а у адреса город. Такая же цепочка есть в расчёте сроков, в печати накладной и в уведомлении курьеру. Появляется доставка в подарок на другой адрес - и правка расползается по всем местам с цепочкой, а пропущенное место молча везёт подарок домой покупателю. Тест Shipping тоже вынужден собирать покупателя с адресом, хотя ему нужен один город.

Решение ​

Закон Деметры, он же "говори только с друзьями": метод вызывает методы своего объекта, своих полей и своих параметров, но не объектов, которые они вернули. На практике это "одна точка": order.shippingCity(), а не order.customer.address.city.

Знание "куда везти заказ" принадлежит заказу. Order отвечает на вопрос shippingCity(): есть подарочный адрес - его город, нет - домашний город покупателя (Customer::homeCity). Shipping зависит от узкого контракта Shippable с двумя вопросами и ничего не знает о покупателях и адресах. Правило подарка появилось в одном месте, и доставку оно не задело.

Структура ​

«interface»

Shippable

+shippingCity() : string

+weightGrams() : int

Order

-customer: Customer

-weightGrams: int

-deliverTo: Address

+shippingCity() : string

+weightGrams() : int

Customer

+name: string

-address: Address

+homeCity() : string

Address

+city: string

+street: string

Shipping

-baseByCity: map

-perKgCents: int

+costFor(Shippable) : int

Стрелка от Shipping идёт только к Shippable. Цепочка Order -> Customer -> Address осталась, но спрятана внутри: каждый объект спрашивает только своего соседа. Стоимость: база по городу плюс тариф за каждый начатый килограмм, неизвестный город - ошибка CannotShip.

Как это собирается ​

Shipping создаётся один раз при старте: тариф по городам и цена килограмма приходят из конфига, объект живёт в DI-контейнере. Заказы приходят из корзины уже собранными: с покупателем и, если это подарок, с адресом получателя. Оформление заказа отдаёт каждый Order в costFor и печатает город, который назвал сам заказ, а ошибку CannotShip превращает в понятное сообщение вместо падения всей страницы.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

ЯзыкОсобенность
PHPinterface Shippable, final readonly классы; адрес покупателя private, наружу только homeCity(); ?? throw для неизвестного города
GoShippable - интерфейс из двух методов, Order удовлетворяет ему неявно; подарочный адрес задаёт DeliverTo, возвращая копию заказа
PythonProtocol вместо базового класса: тест подставляет любой объект с двумя методами; поля с _ помечают внутреннее устройство
TypeScriptструктурный interface Shippable: в тесте посылкой служит объектный литерал; подарочный адрес - необязательный параметр конструктора

Запуск: make test T=principles/law-of-demeter

Плюсы и минусы ​

Тесты проверяют то, ради чего закон: подарочный адрес меняет город и цену, а Shipping при этом не меняется; посылка без покупателя и адреса (пункт выдачи) считается тем же Shipping, потому что ему нужны только два ответа. Отдельно - отказ для города без тарифа.

  • ✅ Правило внутри объекта ("куда везти") живёт в одном месте и меняется без правки клиентов.
  • ✅ Клиент зависит от узкого контракта, его легко тестировать подставной посылкой.
  • ✅ Внутреннее устройство (Customer, Address) можно перестроить, пока ответы те же.
  • ❌ У объекта появляются методы-посредники (shippingCity, homeCity). Если их десятки, объект превращается в фасад над всем графом.
  • ❌ Слепое следование "одной точке" плодит обёртки там, где цепочка безвредна.

Когда не применять ​

  • Цепочка по данным, а не по поведению: DTO, ответ API, конфиг. $config->db->host или response.data.items - навигация по структуре, а не вмешательство в чужую логику.
  • Fluent-интерфейсы и билдеры: query->where(...)->orderBy(...)->limit(10) возвращает тот же объект, точек много, а собеседник один.
  • Value object из языка или библиотеки: order.createdAt.year - дата не прячет правил, которые стоило бы инкапсулировать.
  • Когда вопрос к соседу ни к чему не относится: если Order начинает отвечать на customerPhoneAreaCode(), лучше передать клиенту Customer напрямую.

Отличия между языками ​

  • PHP - без private и метода homeCity() закон держится только на дисциплине: публичные свойства читаются цепочкой из любого места. readonly защищает от записи, но не от чтения через три объекта.
  • Go - видимость на уровне пакета, а не типа: внутри пакета o.customer.address.City компилируется. Закон соблюдается на границе пакета, где неэкспортируемые поля недоступны, и через интерфейс у потребителя.
  • Python - приватности нет, подчёркивание - только договорённость. Protocol позволяет Shipping описать, что ему нужно, а не от кого наследоваться.
  • TypeScript - private проверяется компилятором, а структурная типизация даёт самый короткий тест: посылка - литерал с двумя функциями. Опциональная цепочка ?. облегчает обход графа и этим же соблазняет его нарушать.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS