Не повторяйся, не усложняй, не делай впрок (DRY, KISS, YAGNI)
Три коротких правила против лишнего кода: одно знание - в одном месте, простое решение вместо хитрого, и ничего "на будущее".
Проблема
Небольшой магазин: счёт покупателю, ценник на витрине, стоимость доставки и выгрузка счёта бухгалтеру. На этом коде легко наступить на все три грабли сразу.
DRY (Don't Repeat Yourself). Ставка НДС и правило округления записаны в двух местах: в счёте и в ценнике. Ставку поменяли, нашли одно место из двух - и ценник на витрине разошёлся со счётом на кассе.
final class Invoice
{
public function totalCents(): int
{
return (int) round($this->netCents() * 1.2);
}
}
final class PriceTag
{
public function grossCents(int $netCents): int
{
return $netCents + intdiv($netCents * 20, 100); // the same rule, rounded differently
}
}KISS (Keep It Simple, Stupid). Правило доставки одно: "бесплатно от 3000, иначе 299". Но его оформили как настраиваемый движок: правила с условиями, приоритетами и операторами в массиве. Чтобы понять, сколько стоит доставка, приходится читать интерпретатор.
$engine = new DeliveryRuleEngine([
['field' => 'total', 'op' => '>=', 'value' => 300000, 'fee' => 0, 'priority' => 10],
['field' => 'total', 'op' => '<', 'value' => 300000, 'fee' => 29900, 'priority' => 20],
]);
$fee = $engine->evaluate(['total' => $orderCents]);YAGNI (You Aren't Gonna Need It). Бухгалтерии нужен один CSV. А в коде интерфейс ExportFormat, реестр форматов, загрузка плагинов по имени из конфига - под XML, JSON и Excel, которых никто не просил.
interface ExportFormat
{
public function name(): string;
public function export(Invoice $invoice): string;
}
final class ExporterRegistry
{
/** @var array<string, ExportFormat> */
private array $formats = [];
public function register(ExportFormat $format): void { /* ... */ }
public function export(string $name, Invoice $invoice): string { /* ... */ }
}Решение
- DRY: ставка и округление живут в одном объекте
VatRule. ИInvoice, иPriceTagполучают его и не знают ни числа 20, ни способа округления. Поменяли ставку - поменялось везде, потому что "везде" - это одно место. - KISS: доставка - обычная функция
DeliveryFee::forOrderс однимifи двумя константами. Её читают за секунду и проверяют двумя тестами на границе. - YAGNI: выгрузка - одна функция
CsvExport::invoiceпод единственный нужный формат. Когда появится второй формат, тогда и появится повод для общего интерфейса, и будет понятно, каким он должен быть.
DRY говорит не о строках кода, а о знании: одно бизнес-правило должно иметь одного владельца. KISS и YAGNI - о том, чтобы не платить сложностью за то, чего нет сейчас.
Структура
VatRule - единственная стрелка, в которую сходятся оба потребителя ставки. DeliveryFee и CsvExport намеренно стоят отдельно и ни от чего не зависят: это функции, а не каркас. Суммы в копейках (int), НДС округляется вверх от половины копейки одной формулой во всех языках.
Как это собирается
VatRule создаётся один раз при старте приложения: ставка приходит из конфига, объект регистрируется в DI-контейнере, и все, кому нужен НДС, получают один и тот же экземпляр. В примере корзина превращается в Invoice, тот же VatRule отдаётся PriceTag для витрины, итог счёта передаётся в DeliveryFee::forOrder, а CsvExport::invoice готовит выгрузку. Ценник Cable и строка Cable в счёте считаются одним правилом, поэтому не могут разойтись.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | final readonly class VatRule с проверкой ставки в конструкторе; DeliveryFee и CsvExport - статические методы, потому что classmap не грузит свободные функции |
| Go | VatRule - значение со скрытым полем, создаётся через NewVatRule с error; доставка и выгрузка - обычные функции пакета |
| Python | @dataclass(frozen=True) с проверкой в __post_init__; доставка и выгрузка - функции модуля, константы - на уровне модуля |
| TypeScript | классы VatRule, Invoice, PriceTag; строки счёта - структурный interface InvoiceLine, литералы подходят без конструктора |
Запуск: make test T=principles/dry-kiss-yagni
Плюсы и минусы
Главный тест - про DRY: при трёх разных ставках счёт и ценник на одну и ту же сумму дают одинаковый результат, потому что считают одним VatRule. Остальные тесты закрепляют округление до копейки, отказ от ставки вне 0..100, границу бесплатной доставки и формат CSV.
- ✅ Бизнес-правило меняется в одном месте, и это место легко найти.
- ✅ Простой код читается без документации и проверяется парой тестов на границах.
- ✅ Нет мёртвых абстракций, которые надо поддерживать и обходить при каждой правке.
- ❌ DRY, применённый к случайно похожему коду, связывает то, что должно меняться отдельно (ниже).
- ❌ YAGNI требует честности: если второй формат уже в плане на этот месяц, откладывать интерфейс - не экономия, а переделка.
Когда не применять
DRY вредит, когда сходство кода случайное. Срок возврата товара и длина пробного периода сегодня оба 14 дней, и проверки выглядят одинаково. Но это два разных правила с разными владельцами: юристы поменяют срок возврата, маркетинг - пробный период. Общая функция свяжет их, и правка одного молча сломает другое.
// two rules that only look alike today
final class Policy
{
public static function withinDays(int $days): bool
{
return $days <= 14;
}
}
$canReturn = Policy::withinDays($daysSincePurchase);
$trialActive = Policy::withinDays($daysSinceSignup);Здесь правильно оставить две функции с двумя константами, даже если они совпадают. Проверка простая: если одно место поменяется, обязано ли поменяться второе? Если нет - это не повтор знания, а совпадение.
- KISS не повод писать наивно там, где сложность в самой задаче: расчёт налогов по регионам не станет проще от одного
if. - YAGNI не про отказ от базовой гигиены: валидация, обработка ошибок и тесты нужны сразу, это не "на будущее".
- Правило из трёх повторов: первый раз пишем, второй раз копируем и замечаем, на третий выносим. Раньше чаще всего угадывается не та абстракция.
Отличия между языками
- PHP - classmap-автозагрузка не подхватывает свободные функции, поэтому
DeliveryFeeиCsvExport- классы со статическими методами. Это техническая обёртка, а не задел под расширение: у них нет интерфейсов и состояния. - Go - простота встроена в культуру языка: функции пакета и константы без классов, конструктор
NewVatRuleвозвращаетerror. Захотелось интерфейса "на будущее" - в Go его принято объявлять у потребителя, когда потребитель появится. - Python - функции и константы модуля,
dataclassдля данных. Соблазн YAGNI здесь в другом: метаклассы, реестры через декораторы и магия импорта для одного формата. - TypeScript - строки счёта - обычные объектные литералы под
interface InvoiceLine, отдельный класс не нужен. Функции экспортируются из модуля напрямую, как в Go и Python.
Связанные темы
- Закон Деметры - ещё один принцип о том, кто чем владеет
- Композиция вместо наследования - как не плодить классы под каждую комбинацию
- Стратегия - момент, когда второй вариант правила уже есть и интерфейс оправдан