Принцип единственной ответственности (Single Responsibility Principle)
У модуля одна причина для изменения: подсчёт продаж и форматирование отчёта меняются по разным причинам, поэтому живут порознь.
Проблема
Отчёт о продажах за день. Класс SalesReport сам складывает продажи по товарам и сам собирает текст письма:
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 - контракт между подсчётом и выводом. Новый формат - новый форматтер, подсчёт не трогается. Новое финансовое правило - правка калькулятора, форматтеры не трогаются.
Структура
Суммы - целые копейки, перевод в рубли с точкой (1000.00) делает только форматтер: это решение о виде, а не о расчёте. Товары в сводке отсортированы по имени, чтобы отчёт не зависел от порядка продаж.
Как это собирается
В приложении задача по расписанию берёт продажи за день из таблицы заказов, один раз вызывает SalesCalculator::summarize и раздаёт одну и ту же SalesSummary разным получателям: текст уходит менеджеру в письмо, CSV - бухгалтеру. В примере продажи - литерал, а оба отчёта склеены в одну строку вывода. Подсчёт выполняется один раз, и в письме и в таблице гарантированно одни и те же числа.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Ответственность - это не обязательно класс. В PHP граница проходит между классами, в остальных языках - между функциями одного модуля, а контрактом служит неизменяемая структура данных.
| Язык | Особенность |
|---|---|
| PHP | SalesCalculator, 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литералом прямо в тесте, без конструктора и калькулятора.
Связанные темы
- SOLID - все пять принципов
- Принцип открытости/закрытости - новый формат отчёта без правки существующих
- Фасад - когда разделённые части нужно снова собрать за одной точкой входа