handbookНаписать

principles/solid/single-responsibility/README.md · тема · 4 языка · 16 тестов

Принцип единственной ответственности (Single Responsibility Principle) ​

У модуля одна причина для изменения: подсчёт продаж и форматирование отчёта меняются по разным причинам, поэтому живут порознь.

Проблема ​

Отчёт о продажах за день. Класс SalesReport сам складывает продажи по товарам и сам собирает текст письма:

php
final class SalesReport
{
    /** @param list<array{product: string, units: int, price: int}> $sales */
    public function render(array $sales): string
    {
        $totals = [];
        foreach ($sales as $sale) {
            $totals[$sale['product']] = ($totals[$sale['product']] ?? 0) + $sale['units'] * $sale['price'];
        }
        $text = '';
        foreach ($totals as $product => $cents) {
            $text .= sprintf("%s: %.2f\n", $product, $cents / 100);
        }
        return $text . sprintf('total: %.2f', array_sum($totals) / 100);
    }
}

Бухгалтерия просит тот же отчёт в CSV. Вариантов два: скопировать цикл подсчёта во второй метод или добавить флаг $format и if внутри. В первом случае правило подсчёта (например, возвраты со знаком минус) придётся править в двух местах, во втором - любая правка вёрстки письма заставляет перепроверять арифметику. У класса две причины меняться: финансовые правила и вид отчёта, и они мешают друг другу.

Решение ​

Разделить по причинам изменения. SalesCalculator только считает: из списка Sale получается SalesSummary с итогами по товарам в копейках. Форматтеры только рисуют: TextReportFormatter и CsvReportFormatter получают готовую сводку и ничего не знают о том, откуда взялись числа.

Граница проходит по данным: SalesSummary - контракт между подсчётом и выводом. Новый формат - новый форматтер, подсчёт не трогается. Новое финансовое правило - правка калькулятора, форматтеры не трогаются.

Структура ​

Sale

+product: string

+units: int

+priceCents: int

SalesSummary

+products: ProductTotal[]

+totalCents: int

ProductTotal

+product: string

+units: int

+amountCents: int

SalesCalculator

+summarize(sales) : SalesSummary

TextReportFormatter

+format(summary) : string

CsvReportFormatter

+format(summary) : string

Суммы - целые копейки, перевод в рубли с точкой (1000.00) делает только форматтер: это решение о виде, а не о расчёте. Товары в сводке отсортированы по имени, чтобы отчёт не зависел от порядка продаж.

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

В приложении задача по расписанию берёт продажи за день из таблицы заказов, один раз вызывает SalesCalculator::summarize и раздаёт одну и ту же SalesSummary разным получателям: текст уходит менеджеру в письмо, CSV - бухгалтеру. В примере продажи - литерал, а оба отчёта склеены в одну строку вывода. Подсчёт выполняется один раз, и в письме и в таблице гарантированно одни и те же числа.

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

Реализации ​

Ответственность - это не обязательно класс. В PHP граница проходит между классами, в остальных языках - между функциями одного модуля, а контрактом служит неизменяемая структура данных.

ЯзыкОсобенность
PHPSalesCalculator, TextReportFormatter, CsvReportFormatter - отдельные классы, сводка - final readonly class SalesSummary
GoФункции Summarize, FormatText, FormatCSV; CSV пишет encoding/csv, ошибка записи возвращается как error
PythonФункции summarize, format_text, format_csv над frozen dataclass; CSV через модуль csv
TypeScriptФункции над readonly интерфейсами; сводка - обычный объект, который легко собрать руками в тесте

Запуск: make test T=principles/solid/single-responsibility

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

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

  • ✅ Правка вида отчёта не может сломать арифметику, и наоборот.
  • ✅ Каждая часть тестируется отдельно, на своих данных.
  • ✅ Новый формат добавляется рядом, подсчёт переиспользуется как есть.
  • ❌ Больше типов: появляется промежуточная SalesSummary, которую нужно поддерживать.
  • ❌ Легко перестараться и раздробить код на десяток классов по одной строке.

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

  • Отчёт один, формат один, и оба меняются вместе - разделение будет церемонией.
  • Скрипт на один раз: проще прочитать функцию на 20 строк, чем три класса.
  • "Ответственность" выбрана по техническому признаку (класс для foreach, класс для sprintf), а не по причине изменения - так связность только падает.

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

  • PHP - разделение выражено классами, потому что так устроены автозагрузка и внедрение зависимостей. Сводка - отдельный readonly класс, иначе контракт был бы массивом без типа.
  • Go - пакет уже группирует функции, поэтому достаточно трёх функций над структурой Summary. FormatCSV возвращает error, а подсчёт ошибок не возвращает: разные ответственности видны даже по сигнатурам.
  • Python - модуль функций над dataclass. Отдельный класс для каждой функции без состояния был бы лишним.
  • TypeScript - структурные типы позволяют собрать SalesSummary литералом прямо в тесте, без конструктора и калькулятора.

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

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