Класс должен иметь только одну причину для изменения. Иными словами: один класс – одна обязанность.
Если объект делает слишком много, его сложнее поддерживать и изменять, а правка в одной части может сломать другую.
Причина для изменения, а не количество методов
Формулировку часто понимают как «класс должен быть маленьким». Это не так: важна не длина, а источник изменений. Спросите себя, кто может прийти с требованием поменять этот класс. Если таких заказчиков двое – бухгалтерия и отдел вёрстки писем, – обязанностей тоже две.
Как выглядит нарушение
Классический пример самого Мартина – класс сотрудника, у которого три заказчика:
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
public class Employee
{
public decimal CalculatePay() => RegularHours() * _rate; // просит бухгалтерия
public string ReportHours() => $"{RegularHours():0.0} ч"; // просит отдел кадров
public void Save() => Db.Execute("UPDATE employees ...", this); // просит администратор
// Общий кусок, из-за которого всё и ломается
private decimal RegularHours() => _shifts.Sum(s => Math.Min(s.Hours, 8));
private readonly decimal _rate;
private readonly List<Shift> _shifts = new();
}
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
class Employee {
public:
double CalculatePay() const { return RegularHours() * rate_; } // просит бухгалтерия
std::string ReportHours() const { return Format(RegularHours()); } // просит отдел кадров
void Save() const { Db().Execute("UPDATE employees ...", *this); } // просит администратор
private:
// Общий кусок, из-за которого всё и ломается
double RegularHours() const {
double total = 0;
for (const auto& shift : shifts_) total += std::min(shift.hours, 8.0);
return total;
}
double rate_ = 0;
std::vector<Shift> shifts_;
};
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
type Employee struct {
rate float64
shifts []Shift
}
func (e Employee) CalculatePay() float64 { return e.regularHours() * e.rate } // бухгалтерия
func (e Employee) ReportHours() string { // отдел кадров
return fmt.Sprintf("%.1f ч", e.regularHours())
}
func (e Employee) Save() error { // администратор базы
return db.Exec("UPDATE employees ...", e)
}
// Общий кусок, из-за которого всё и ломается
func (e Employee) regularHours() float64 {
total := 0.0
for _, shift := range e.shifts {
total += math.Min(shift.Hours, 8)
}
return total
}
# Бухгалтерия, отдел кадров и администратор базы – три источника правок
class Employee:
def calculate_pay(self) -> float: # просит бухгалтерия
return self._regular_hours() * self._rate
def report_hours(self) -> str: # просит отдел кадров
return f"{self._regular_hours():.1f} ч"
def save(self) -> None: # просит администратор базы
db.execute("UPDATE employees ...", self)
# Общий кусок, из-за которого всё и ломается
def _regular_hours(self) -> float:
return sum(min(shift.hours, 8) for shift in self._shifts)
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
class Employee {
calculatePay(): number {
return this.regularHours() * this.rate; // просит бухгалтерия
}
reportHours(): string {
return `${this.regularHours().toFixed(1)} ч`; // просит отдел кадров
}
save(): void {
db.execute('UPDATE employees ...', this); // просит администратор базы
}
// Общий кусок, из-за которого всё и ломается
private regularHours(): number {
return this.shifts.reduce((sum, shift) => sum + Math.min(shift.hours, 8), 0);
}
}
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
public class Employee {
public BigDecimal calculatePay() { // просит бухгалтерия
return regularHours().multiply(rate);
}
public String reportHours() { // просит отдел кадров
return regularHours() + " ч";
}
public void save() { // просит администратор базы
db.execute("UPDATE employees ...", this);
}
// Общий кусок, из-за которого всё и ломается
private BigDecimal regularHours() {
return shifts.stream()
.map(s -> s.getHours().min(BigDecimal.valueOf(8)))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
class Employee(private val rate: Double, private val shifts: List<Shift>) {
fun calculatePay() = regularHours() * rate // просит бухгалтерия
fun reportHours() = "%.1f ч".format(regularHours()) // просит отдел кадров
fun save() = db.execute("UPDATE employees ...", this) // просит администратор базы
// Общий кусок, из-за которого всё и ломается
private fun regularHours() = shifts.sumOf { minOf(it.hours, 8.0) }
}
Методов всего три, класс маленький – и всё равно это нарушение. Смотреть надо не на размер, а на то, кто приходит с правками:
- три заказчика на один файл – бухгалтерия меняет правила оплаты, кадры – форму отчёта, администратор базы – схему таблицы; каждый со своим сроком и своими причинами;
- правка одного тихо ломает другое – кадры просят не засчитывать в отчёт больше восьми
часов, кто-то правит
RegularHours(), и вместе с отчётом меняется зарплата. Ошибку заметят в день выплаты; - тест ни о чём не говорит – класс падает по трём независимым причинам, и красный тест не подсказывает, какая сработала;
- правки сталкиваются – три команды коммитят в один файл и разбирают конфликты.
Как разделить
Данные остаются у сотрудника, а каждая обязанность уезжает к своему заказчику:
// Данные – в одном классе, три обязанности – в трёх
public class Employee
{
public decimal Rate { get; init; }
public IReadOnlyList<Shift> Shifts { get; init; } = [];
}
public class PayCalculator // бухгалтерия
{
public decimal Calculate(Employee employee) =>
employee.Shifts.Sum(s => Math.Min(s.Hours, 8)) * employee.Rate;
}
public class HourReporter // отдел кадров
{
public string Report(Employee employee) =>
$"{employee.Shifts.Sum(s => s.Hours):0.0} ч";
}
public class EmployeeRepository // администратор базы
{
public void Save(Employee employee) => Db.Execute("UPDATE employees ...", employee);
}
// Данные – в одном классе, три обязанности – в трёх
struct Employee {
double rate = 0;
std::vector<Shift> shifts;
};
class PayCalculator { // бухгалтерия
public:
double Calculate(const Employee& employee) const;
};
class HourReporter { // отдел кадров
public:
std::string Report(const Employee& employee) const;
};
class EmployeeRepository { // администратор базы
public:
void Save(const Employee& employee) const {
Db().Execute("UPDATE employees ...", employee);
}
};
// Данные – в одном типе, три обязанности – в трёх
type Employee struct {
Rate float64
Shifts []Shift
}
type PayCalculator struct{} // бухгалтерия
func (PayCalculator) Calculate(e Employee) float64 {
total := 0.0
for _, shift := range e.Shifts {
total += math.Min(shift.Hours, 8)
}
return total * e.Rate
}
type HourReporter struct{} // отдел кадров
func (HourReporter) Report(e Employee) string {
total := 0.0
for _, shift := range e.Shifts {
total += shift.Hours
}
return fmt.Sprintf("%.1f ч", total)
}
type EmployeeRepository struct{} // администратор базы
func (EmployeeRepository) Save(e Employee) error { return db.Exec("UPDATE employees ...", e) }
# Данные – в одном классе, три обязанности – в трёх
@dataclass
class Employee:
rate: float
shifts: list[Shift]
class PayCalculator: # бухгалтерия
def calculate(self, employee: Employee) -> float:
return sum(min(s.hours, 8) for s in employee.shifts) * employee.rate
class HourReporter: # отдел кадров
def report(self, employee: Employee) -> str:
return f"{sum(s.hours for s in employee.shifts):.1f} ч"
class EmployeeRepository: # администратор базы
def save(self, employee: Employee) -> None:
db.execute("UPDATE employees ...", employee)
// Данные – в одном классе, три обязанности – в трёх
interface Employee {
rate: number;
shifts: Shift[];
}
class PayCalculator {
calculate(employee: Employee): number {
return employee.shifts.reduce((sum, s) => sum + Math.min(s.hours, 8), 0) * employee.rate;
}
}
class HourReporter {
report(employee: Employee): string {
return `${employee.shifts.reduce((sum, s) => sum + s.hours, 0).toFixed(1)} ч`;
}
}
class EmployeeRepository {
save(employee: Employee): void {
db.execute('UPDATE employees ...', employee);
}
}
// Данные – в одном классе, три обязанности – в трёх
public record Employee(BigDecimal rate, List<Shift> shifts) { }
public class PayCalculator { // бухгалтерия
public BigDecimal calculate(Employee employee) { ... }
}
public class HourReporter { // отдел кадров
public String report(Employee employee) { ... }
}
public class EmployeeRepository { // администратор базы
public void save(Employee employee) {
db.execute("UPDATE employees ...", employee);
}
}
// Данные – в одном классе, три обязанности – в трёх
data class Employee(val rate: Double, val shifts: List<Shift>)
class PayCalculator { // бухгалтерия
fun calculate(employee: Employee) =
employee.shifts.sumOf { minOf(it.hours, 8.0) } * employee.rate
}
class HourReporter { // отдел кадров
fun report(employee: Employee) = "%.1f ч".format(employee.shifts.sumOf { it.hours })
}
class EmployeeRepository { // администратор базы
fun save(employee: Employee) = db.execute("UPDATE employees ...", employee)
}
Обратите внимание на главное: расчёт часов теперь считается дважды и по-разному. Это не дублирование, которое надо выносить в общий метод, – это два разных правила, которые раньше случайно совпадали. Пока они жили в одном методе, любая правка одного меняла второе.
Что даёт соблюдение
- Изменение требования затрагивает один файл. Поменялись правила начисления зарплаты – правится расчёт, и только он; формат отчёта об этом не знает.
- Тест становится осмысленным. У класса одна причина упасть, поэтому по красному тесту сразу понятно, что именно сломалось.
- Меньше конфликтов при слиянии. Бухгалтерия и отдел отчётности правят разные файлы, а не соседние строки одного класса.
- Класс легко назвать. Если имя подбирается без союза «и» – ответственность одна. Это самая дешёвая проверка принципа, и работает она ещё до написания кода.
Чем платят
Классов становится больше, и по одному сценарию приходится ходить по нескольким файлам. Принцип окупается там, где код действительно меняется; дробить стабильный класс ради самого дробления смысла нет.