handbookНаписать

patterns/behavioral/strategy/practice/README.md · практика · 4 языка · 31 тест

Практика: расчёт доставки (Shipping rates) ​

Способ доставки выбирается правилами из конфига, а не if в коде: страна, вес и сумма заказа решают, какая стратегия посчитает цену.

Базовый пример: Стратегия. Там стратегию в Checkout кладёт тест руками. В жизни главный вопрос другой: откуда стратегия берётся и кто решает, какая из них нужна этому заказу.

Задача ​

Интернет-магазин считает стоимость доставки на кассе. Сначала был один тариф. Потом добавились пункты выдачи для лёгких посылок по России, курьер по России и Беларуси с ценой за килограмм, международная доставка, а маркетинг попросил «бесплатно от 5000». Тарифы и пороги меняются раз в месяц, новые службы доставки появляются раз в квартал.

Если писать это как if country == 'RU' and weight <= 1000, касса превращается в клубок условий, где цена и правило выбора перемешаны. Каждая правка тарифа — релиз.

Что урезано ​

  • Деньги — целые копейки, одна валюта.
  • Конфиг — массив в тесте. В проекте это YAML или таблица в БД, но форма та же: упорядоченные правила и запасной способ.
  • Нет вызовов API перевозчиков, нет сроков доставки, нет исключений вроде «негабарит».
  • Нет DI-контейнера: стратегии регистрируются руками, в проекте это делает контейнер или модуль сборки.

Решение ​

Два разных вопроса разводятся по двум разным местам.

Сколько стоит — это стратегия. Контракт ShippingMethod получает Parcel (страна, вес, сумма) и возвращает цену. Реализаций три: FlatRate, PerKilogram и FreeOver, которая оборачивает другую стратегию и обнуляет цену от порога суммы.

Какая стратегия нужна — это правила. Rule описывает условие из конфига: код способа, список стран, максимальный вес. ShippingPolicy хранит упорядоченные правила, запасной код и реестр «код → стратегия». Первое подошедшее правило называет код, реестр выдаёт стратегию.

Checkout не знает ни правил, ни тарифов: спросил у политики стратегию, вызвал её.

Структура ​

реестр по коду

otherwise

Checkout

-policy: ShippingPolicy

+shippingCost(parcel) : int

ShippingPolicy

-rules: Rule[]

-fallback: string

-methods: Map<string, ShippingMethod>

+fromConfig(config) : ShippingPolicy

+register(code, method)

+resolve(parcel) : ShippingMethod

Rule

+method: string

+countries: string[]

+maxWeightGrams: int

+matches(parcel) : bool

«interface»

ShippingMethod

+quote(parcel) : int

FlatRate

PerKilogram

FreeOver

-otherwise: ShippingMethod

Checkout стал ещё тоньше, чем в базовом примере: он не получает стратегию в конструкторе, а получает политику и спрашивает стратегию под каждую посылку. Выбор переехал из вызывающего кода в данные.

Правила из конфига ​

Конфиг — это список правил в порядке приоритета плюс запасной код:

yaml
fallback: international
rules:
  - { method: pickup,  countries: [RU],     max_weight_g: 1000 }
  - { method: courier, countries: [RU, BY] }

ShippingPolicy::fromConfig превращает его в объекты Rule. Отсутствующее поле означает «без ограничения». Порядок важен: лёгкая посылка по России подходит под оба правила, выигрывает первое.

Реестр заполняется кодом при сборке приложения: register('pickup', new FreeOver(5000, new FlatRate(199))). Код способа — единственное, что связывает конфиг и код. Правило, которое ссылается на незарегистрированный код, падает при первом же запросе с понятной ошибкой, а не возвращает «ноль рублей».

Добавить новую службу доставки — одна строка в конфиге и одна регистрация. Ни Checkout, ни ShippingPolicy, ни существующие стратегии не меняются.

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

Политика собирается один раз при старте приложения: конфиг читается из файла, ShippingPolicy::fromConfig превращает его в правила, а модуль сборки регистрирует стратегии по кодам. Дальше Checkout живёт в контейнере, и корзина просто зовёт shippingCost для каждой посылки: лёгкая по России уходит в пункт выдачи, крупный заказ получает бесплатную доставку, Беларусь - курьера, Германия - запасной international.

В конфиге уже есть правило express для Казахстана, а стратегию для него ещё не зарегистрировали. Политика честно падает, а вызывающий код решает, что с этим делать: корзина ловит ошибку, пишет её в лог и показывает способ как недоступный. Остальные посылки считаются как обычно.

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

Реализации ​

Стратегии не знают о правилах, а правила о тарифах: FreeOver берёт порог и «обычную» стратегию, поэтому «бесплатно от 5000» можно прикрутить к любому способу. Вес округляется вверх до начатого килограмма одинаково во всех языках.

ЯзыкОсобенность
PHPinterface ShippingMethod, Rule с nullable-полями, fromConfig с array-shape для PHPStan
GoСтратегия — type Rate func(Parcel) int; Rule и Config с json-тегами, тест декодирует конфиг из JSON; нулевое значение поля = «без ограничения»
PythonСтратегия — callable; Rule.from_config и ShippingPolicy.from_config как @classmethod
TypeScriptТип Rule совпадает с формой конфига, отдельного парсинга нет; стратегии — объектные литералы из фабрик

Запуск: make test T=patterns/behavioral/strategy/practice

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

Тесты одинаковы во всех языках. Таблица из пяти посылок проверяет сами правила: лёгкая по России — пункт выдачи, тяжёлая — курьер за три начатых килограмма, Беларусь — курьер, потому что пунктов выдачи там нет, Германия — запасной способ, крупный заказ — бесплатно. Второй тест добавляет доставку дроном через конфиг и регистрацию и убеждается, что остальной код не трогали. Третий — правило с незарегистрированным кодом падает сразу.

Где граница ​

  • Условия в правилах усложняются («кроме островов», «только в будни», «если в корзине есть габаритный товар») — правила становятся объектами-спецификациями, см. Specification, или уезжают в движок правил.
  • Способ доставки должен знать о других способах (сравнить, выбрать дешёвый) — это уже не одна стратегия, а их сравнение; политика может вернуть список.
  • Стратегий две и новые не планируются — таблица правил избыточна, хватит if.
  • Порядок правил в конфиге начал решать слишком многое — это сигнал, что критерии выбора надо сделать явными полями, а не полагаться на «первое подошедшее».

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

  • PHP — Rule с ?array/?int и явная проверка === null; fromConfig документирован array-shape, иначе PHPStan не пропустит обращения к ключам.
  • Go — конфиг декодируется стандартным encoding/json прямо в структуры с тегами, поэтому отдельного «парсера» нет; «нет ограничения» выражено нулевым значением, и это надо помнить при чтении Matches.
  • Python — from_config как classmethod, Self в сигнатурах; стратегии — замыкания, как в базовом примере.
  • TypeScript — тип правила и есть схема конфига, поэтому JSON-конфиг подставляется без преобразований; за счёт структурной типизации стратегии — объектные литералы, класс не нужен.

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

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