handbookНаписать

patterns/enterprise/null-object/practice/README.md · практика · 4 языка · 38 тестов

база: Нулевой объект

Практика: гость, лояльность и аналитика (Shop profile) ​

Гость вместо null-пользователя, отсутствующая программа лояльности и выключенная аналитика как нулевые объекты, которые выбираются в одном месте, и ненайденный заказ, который нулевым объектом быть не должен.

Базовый пример: Нулевой объект. Там нулевой журнал подставляется по умолчанию, и вопроса «а можно ли» не возникает. В жизни важнее две вещи: откуда берётся нулевой объект (кто решает, что перед нами гость) и где он вреден, потому что молча превращает ошибку в «ничего».

Задача ​

Магазин считает корзину. Покупатель бывает вошедшим и анонимным, вошедший может участвовать в программе лояльности или нет, аналитика на стенде разработки и в тестах выключена. В первой версии $user был null для анонимов, программа лояльности - null для тех, кто в ней не состоит, трекер - null без настройки. В итоге в расчёте корзины, на странице заказов и в оплате одни и те же проверки: if ($user !== null && $user->loyalty !== null), а где-то их забыли, и аноним с пустой сессией ронял страницу.

Потом кто-то решил «заодно» убрать проверку и в оплате: пусть ненайденный заказ возвращает пустой заказ на 0 рублей. Оплата по устаревшей ссылке начала «успешно» проходить, аналитика записала оплату, которой не было.

Что урезано ​

  • Пользователи и заказы - словари в памяти. В проекте это репозитории над БД, но форма та же: «найти по ключу».
  • Сессия - два аргумента: id пользователя (может отсутствовать) и id сессии. Нет авторизации, куки и истечения сессии.
  • Деньги - целые копейки, одна валюта; скидка и бонус - целые проценты, округление вниз.
  • Корзина не хранится: от неё остался только ключ, под которым она лежала бы (user:<id> или session:<id>).

Решение ​

Каждый вид «отсутствия» получил свой нулевой объект с тем же контрактом, что и настоящий:

  • Guest вместо null-покупателя: скидки нет, история пустая, корзина живёт под ключом сессии, программа лояльности - нулевая.
  • NoLoyalty вместо null-программы: бонус всегда 0.
  • NullTracker вместо null-трекера: события уходят в никуда. В Go его роль играет nil-указатель *MemoryTracker.

Решение «перед нами гость» принимается один раз, в Customers.current, на границе между сессией и предметной областью. Это единственная проверка на отсутствие пользователя во всём коде. Storefront дальше её не повторяет.

Заказ нулевого объекта не получил. Orders отдаёт его двумя способами: find возвращает Order или «ничего» (?Order, Order | None, (Order, bool)) для мест, где отсутствие - нормальная ветка, а get бросает OrderNotFound для мест, где отсутствие - ошибка.

Структура ​

нет пользователя

Storefront

-orders: Orders

-tracker: Tracker

+quote(customer, subtotalCents) : Quote

+recentOrders(customer, limit) : string[]

+pay(orderId) : int

«interface»

Customer

+cartKey: string

+discountPercent: int

+orderHistory: string[]

+loyalty: Loyalty

«interface»

Loyalty

+bonusFor(totalCents) : int

«interface»

Tracker

+track(event)

Member

Guest

PointsProgram

NoLoyalty

MemoryTracker

NullTracker

Customers

+current(userId, sessionId) : Customer

Orders

+find(id) : Optional<Order>

+get(id) : Order

Storefront получает покупателя, трекер и заказы и ни разу не спрашивает, настоящие ли они. В quote скидка, бонус и ключ корзины берутся у покупателя, событие уходит трекеру, и путь один для всех. В pay всё наоборот: orders.get бросает исключение, и событие «оплачено» до трекера не доходит.

Откуда берётся нулевой объект ​

Customers.current превращает сессию в покупателя: нет id пользователя - гость, есть, но аккаунт удалён - тоже гость, иначе - участник. Остальной код получает Customer и работает с ним одинаково. Если проверку на null нельзя убрать совсем, её нужно собрать в одно место на границе, и нулевой объект - то, что эта граница возвращает.

Orders показывает, где граница проходит иначе. find - это Optional: отсутствие видно в типе, и вызывающий обязан решить, что делать (страница заказа покажет «не найден»). get для оплаты: отсутствие - ошибка, с текстом, по которому видно, какой заказ искали. Пустой заказ на 0 рублей был бы нулевым объектом и сделал бы ровно то, что описано в «Задаче»: оплата прошла, хотя платить было нечего.

В Go есть третий вариант - метод на nil-указателе. Track и Events у *MemoryTracker сначала проверяют t == nil, поэтому var disabled *MemoryTracker - рабочий выключенный трекер без отдельного типа. Так же Member с пустым полем Program отвечает NoLoyalty{}: нулевое значение структуры становится нулевым объектом.

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

Customers, Orders и общий трекер аналитики создаются при старте приложения, а Storefront собирается на каждый запрос. Из запроса приходят три вещи: id пользователя из сессии, id сессии и согласие на аналитику из cookie. Customers.current превращает первые две в покупателя, согласие выбирает трекер: настоящий или NullTracker (в Go - nil-указатель *MemoryTracker). Дальше quote считается одинаково для всех: ann получает скидку и бонус, гость и zoe с удалённым аккаунтом - ноль и корзину в сессии.

Оплата показывает обратную сторону. Заказа A-9 нет, pay бросает OrderNotFound, и обработчик отвечает 404, а событие "оплачено" в аналитику не уходит. В итоговом списке событий нет ни гостя без согласия, ни несуществующего заказа.

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

Реализации ​

Настоящие объекты отличаются от нулевых только ответами: Member хранит скидку, историю и программу, PointsProgram начисляет процент, MemoryTracker копит события для теста. Нулевые отвечают нейтрально и состояния не держат, кроме id сессии у гостя: корзина гостя не пропадает, у неё другой ключ.

ЯзыкОсобенность
PHPСвойства в интерфейсе (PHP 8.4): public string $cartKey { get; }; Guest отвечает хуками get => 0, Member - обычными readonly-полями
GoВыключенный трекер - nil-указатель *MemoryTracker; Find возвращает (Order, bool), Get - ошибку с сентинелом ErrOrderNotFound
PythonProtocol со свойствами; у Guest нейтральные поля с init=False, их нельзя переопределить при создании
TypeScriptnoLoyalty и nullTracker - объектные литералы; find возвращает Order | undefined, компилятор заставит проверить

Запуск: make test T=patterns/enterprise/null-object/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках, корзина везде на 2000 копеек.

  • Таблица расчёта: участник программы со скидкой 10% получает 1800 и бонус 90 (5% от 1800), участник без программы - 2000 и 0, гость - 2000 и 0 с ключом корзины session:s-1. Один и тот же quote без единой ветки.
  • Таблица сессий: вошедший - участник, аноним и удалённый аккаунт - гость. Это проверка единственного места, где решается «гость или нет».
  • История гостя пустая, и recentOrders возвращает пустой список без проверок.
  • Выключенная аналитика не меняет ни расчёт, ни оплату, а включённая записывает quote user:ann и paid A-2. В Go выключенный трекер - nil-указатель, и у него тоже можно спросить события.
  • Ненайденный заказ: find честно говорит «нет», pay падает с OrderNotFound и текстом order 'B-7' not found, а трекер не получил ни одного события. Это тест на границу: нулевой объект здесь спрятал бы ошибку.

Где граница ​

  • Отсутствие - ошибка (ненайденный заказ при оплате, неизвестный товар в корзине, неуказанный адрес доставки) - исключение или error, а не нулевой объект.
  • Отсутствие - нормальная ветка, которую вызывающий должен обработать по-своему (страница «заказ не найден», кнопка «Войти»), - Optional/T | None/(T, bool): тип заставляет вспомнить о ветке, а нулевой объект её прячет.
  • Клиент начинает спрашивать isGuest() в десятке мест - нулевой объект перестал работать, поведение надо перенести в сам Customer (метод «что показать в шапке») или признать, что гость - отдельный сценарий.
  • Нейтрального ответа нет («курс валют для гостя», «адрес по умолчанию») - нулевой объект выдумает данные, лучше явная ошибка.
  • В Go метод на nil-указателе работает только тогда, когда каждый метод типа к этому готов. Забыли проверку в одном - паника. И nil-указатель в интерфейсе не равен nil-интерфейсу: NewStorefront(orders, nil) упадёт на первом событии.

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

  • PHP - ?-> сокращает проверку, но не убирает её; свойства в интерфейсе (8.4) позволяют Guest отвечать хуками без полей и конструктора для каждого ответа. ?Order в find и ?? throw в get - Optional и ошибка рядом, в одну строку.
  • Go - нулевые значения уже наполовину нулевые объекты: пустой срез, пустая строка, nil-map для чтения. Приём доводит это до методов: nil-приёмник и поле интерфейса, которое при nil отвечает NoLoyalty{}. Optional в Go - идиома «comma ok» (Order, bool).
  • Python - Order | None и есть Optional, а Protocol позволяет гостю быть обычным dataclass без наследования. Неизменяемость (frozen=True, tuple для истории) нужна, чтобы нейтральный ответ никто не испортил: в изменяемую пустую историю кто-нибудь однажды добавил бы заказ.
  • TypeScript - при strictNullChecks Order | undefined - Optional, проверенный компилятором; ?. в клиентах - лёгкий путь, но та же проверка в каждом месте. Нулевые объекты - литералы, структурная типизация не требует классов.

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

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