Закон Деметры (Law of Demeter)
Объект общается только с ближайшими соседями: спрашивает заказ, куда его везти, а не лезет через заказ в покупателя и его адрес.
Проблема
Служба доставки считает стоимость по городу и весу. Город она достаёт сама, цепочкой через чужие объекты:
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 с двумя вопросами и ничего не знает о покупателях и адресах. Правило подарка появилось в одном месте, и доставку оно не задело.
Структура
Стрелка от Shipping идёт только к Shippable. Цепочка Order -> Customer -> Address осталась, но спрятана внутри: каждый объект спрашивает только своего соседа. Стоимость: база по городу плюс тариф за каждый начатый килограмм, неизвестный город - ошибка CannotShip.
Как это собирается
Shipping создаётся один раз при старте: тариф по городам и цена килограмма приходят из конфига, объект живёт в DI-контейнере. Заказы приходят из корзины уже собранными: с покупателем и, если это подарок, с адресом получателя. Оформление заказа отдаёт каждый Order в costFor и печатает город, который назвал сам заказ, а ошибку CannotShip превращает в понятное сообщение вместо падения всей страницы.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | interface Shippable, final readonly классы; адрес покупателя private, наружу только homeCity(); ?? throw для неизвестного города |
| Go | Shippable - интерфейс из двух методов, Order удовлетворяет ему неявно; подарочный адрес задаёт DeliverTo, возвращая копию заказа |
| Python | Protocol вместо базового класса: тест подставляет любой объект с двумя методами; поля с _ помечают внутреннее устройство |
| 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проверяется компилятором, а структурная типизация даёт самый короткий тест: посылка - литерал с двумя функциями. Опциональная цепочка?.облегчает обход графа и этим же соблазняет его нарушать.
Связанные темы
- DRY, KISS, YAGNI - знание должно иметь одного владельца
- Композиция вместо наследования - объект собирается из узких частей
- Фасад - когда методов-посредников так много, что это уже отдельный объект