Course program← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF
7Методические материалы

SRP – the Single Responsibility Principle

This lesson was translated automatically – it may contain errors.

A class should have only one reason to change. In other words: one class – one responsibility.

If an object does too much, it is harder to maintain and to change, and a fix in one part can break another.

A reason to change, not a number of methods

The wording is often read as «a class should be small». It is not: what matters is not the length but the source of changes. Ask yourself who may come with a request to change this class. If there are two such customers – the accounting department and the team that lays out the e-mails – there are two responsibilities as well.

What a violation looks like

The classic example from Martin himself is an employee class with three customers:

// Accounting, HR and the database administrator – three sources of changes
public class Employee
{
    public decimal CalculatePay() => RegularHours() * _rate;        // asked for by accounting
    public string ReportHours() => $"{RegularHours():0.0} h";       // asked for by HR
    public void Save() => Db.Execute("UPDATE employees ...", this); // asked for by the administrator

    // The shared piece – that is where it all breaks
    private decimal RegularHours() => _shifts.Sum(s => Math.Min(s.Hours, 8));

    private readonly decimal _rate;
    private readonly List<Shift> _shifts = new();
}
// Accounting, HR and the database administrator – three sources of changes
class Employee {
public:
    double CalculatePay() const { return RegularHours() * rate_; }   // asked for by accounting
    std::string ReportHours() const { return Format(RegularHours()); } // asked for by HR
    void Save() const { Db().Execute("UPDATE employees ...", *this); } // asked for by the administrator

private:
    // The shared piece – that is where it all breaks
    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_;
};
// Accounting, HR and the database administrator – three sources of changes
type Employee struct {
	rate   float64
	shifts []Shift
}

func (e Employee) CalculatePay() float64 { return e.regularHours() * e.rate } // accounting

func (e Employee) ReportHours() string { // HR
	return fmt.Sprintf("%.1f h", e.regularHours())
}

func (e Employee) Save() error { // the database administrator
	return db.Exec("UPDATE employees ...", e)
}

// The shared piece – that is where it all breaks
func (e Employee) regularHours() float64 {
	total := 0.0
	for _, shift := range e.shifts {
		total += math.Min(shift.Hours, 8)
	}

	return total
}
# Accounting, HR and the database administrator – three sources of changes
class Employee:
    def calculate_pay(self) -> float:  # asked for by accounting
        return self._regular_hours() * self._rate

    def report_hours(self) -> str:  # asked for by HR
        return f"{self._regular_hours():.1f} h"

    def save(self) -> None:  # asked for by the database administrator
        db.execute("UPDATE employees ...", self)

    # The shared piece – that is where it all breaks
    def _regular_hours(self) -> float:
        return sum(min(shift.hours, 8) for shift in self._shifts)
// Accounting, HR and the database administrator – three sources of changes
class Employee {
    calculatePay(): number {
        return this.regularHours() * this.rate; // asked for by accounting
    }

    reportHours(): string {
        return `${this.regularHours().toFixed(1)} h`; // asked for by HR
    }

    save(): void {
        db.execute('UPDATE employees ...', this); // asked for by the database administrator
    }

    // The shared piece – that is where it all breaks
    private regularHours(): number {
        return this.shifts.reduce((sum, shift) => sum + Math.min(shift.hours, 8), 0);
    }
}
// Accounting, HR and the database administrator – three sources of changes
public class Employee {
    public BigDecimal calculatePay() { // asked for by accounting
        return regularHours().multiply(rate);
    }

    public String reportHours() { // asked for by HR
        return regularHours() + " h";
    }

    public void save() { // asked for by the database administrator
        db.execute("UPDATE employees ...", this);
    }

    // The shared piece – that is where it all breaks
    private BigDecimal regularHours() {
        return shifts.stream()
                .map(s -> s.getHours().min(BigDecimal.valueOf(8)))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}
// Accounting, HR and the database administrator – three sources of changes
class Employee(private val rate: Double, private val shifts: List<Shift>) {
    fun calculatePay() = regularHours() * rate // asked for by accounting

    fun reportHours() = "%.1f h".format(regularHours()) // asked for by HR

    fun save() = db.execute("UPDATE employees ...", this) // asked for by the database administrator

    // The shared piece – that is where it all breaks
    private fun regularHours() = shifts.sumOf { minOf(it.hours, 8.0) }
}

Three methods in all, the class is small – and it is still a violation. What to look at is not the size but who comes with the changes:

  • three customers for one file – accounting changes the pay rules, HR the shape of the report, the database administrator the table schema; each on their own schedule and for their own reasons;
  • a fix for one quietly breaks another – HR asks not to count more than eight hours in the report, someone edits RegularHours(), and the salary changes along with the report. The error is noticed on payday;
  • the test says nothing – the class fails for three independent reasons, and a red test does not tell which one fired;
  • changes collide – three teams commit into one file and resolve the conflicts.

How to split it up

The data stays with the employee, and every responsibility moves to its own customer:

// The data in one class, the three responsibilities in three
public class Employee
{
    public decimal Rate { get; init; }
    public IReadOnlyList<Shift> Shifts { get; init; } = [];
}

public class PayCalculator // accounting
{
    public decimal Calculate(Employee employee) =>
        employee.Shifts.Sum(s => Math.Min(s.Hours, 8)) * employee.Rate;
}

public class HourReporter // HR
{
    public string Report(Employee employee) =>
        $"{employee.Shifts.Sum(s => s.Hours):0.0} h";
}

public class EmployeeRepository // the database administrator
{
    public void Save(Employee employee) => Db.Execute("UPDATE employees ...", employee);
}
// The data in one class, the three responsibilities in three
struct Employee {
    double rate = 0;
    std::vector<Shift> shifts;
};

class PayCalculator {  // accounting
public:
    double Calculate(const Employee& employee) const;
};

class HourReporter {  // HR
public:
    std::string Report(const Employee& employee) const;
};

class EmployeeRepository {  // the database administrator
public:
    void Save(const Employee& employee) const {
        Db().Execute("UPDATE employees ...", employee);
    }
};
// The data in one type, the three responsibilities in three
type Employee struct {
	Rate   float64
	Shifts []Shift
}

type PayCalculator struct{} // accounting

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{} // HR

func (HourReporter) Report(e Employee) string {
	total := 0.0
	for _, shift := range e.Shifts {
		total += shift.Hours
	}

	return fmt.Sprintf("%.1f h", total)
}

type EmployeeRepository struct{} // the database administrator

func (EmployeeRepository) Save(e Employee) error { return db.Exec("UPDATE employees ...", e) }
# The data in one class, the three responsibilities in three
@dataclass
class Employee:
    rate: float
    shifts: list[Shift]

class PayCalculator:  # accounting
    def calculate(self, employee: Employee) -> float:
        return sum(min(s.hours, 8) for s in employee.shifts) * employee.rate

class HourReporter:  # HR
    def report(self, employee: Employee) -> str:
        return f"{sum(s.hours for s in employee.shifts):.1f} h"

class EmployeeRepository:  # the database administrator
    def save(self, employee: Employee) -> None:
        db.execute("UPDATE employees ...", employee)
// The data in one class, the three responsibilities in three
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)} h`;
    }
}

class EmployeeRepository {
    save(employee: Employee): void {
        db.execute('UPDATE employees ...', employee);
    }
}
// The data in one class, the three responsibilities in three
public record Employee(BigDecimal rate, List<Shift> shifts) { }

public class PayCalculator { // accounting
    public BigDecimal calculate(Employee employee) { ... }
}

public class HourReporter { // HR
    public String report(Employee employee) { ... }
}

public class EmployeeRepository { // the database administrator
    public void save(Employee employee) {
        db.execute("UPDATE employees ...", employee);
    }
}
// The data in one class, the three responsibilities in three
data class Employee(val rate: Double, val shifts: List<Shift>)

class PayCalculator { // accounting
    fun calculate(employee: Employee) =
        employee.shifts.sumOf { minOf(it.hours, 8.0) } * employee.rate
}

class HourReporter { // HR
    fun report(employee: Employee) = "%.1f h".format(employee.shifts.sumOf { it.hours })
}

class EmployeeRepository { // the database administrator
    fun save(employee: Employee) = db.execute("UPDATE employees ...", employee)
}

Note the main thing: the hours are now counted twice and in different ways. This is not the kind of duplication to be pulled into a shared method – these are two different rules that used to coincide by accident. While they lived in one method, any change to one changed the other.

What following it gives

  • A changed requirement touches one file. The pay rules changed – the calculation is edited and only it; the report format knows nothing about it.
  • The test becomes meaningful. The class has one reason to fail, so a red test tells you right away what exactly broke.
  • Fewer merge conflicts. Accounting and the reporting department edit different files, not neighbouring lines of one class.
  • The class is easy to name. If the name comes without the word «and» – there is one responsibility. It is the cheapest check of the principle, and it works before any code is written.

What it costs

There are more classes, and one scenario now spans several files. The principle pays off where the code really changes; splitting a stable class for the sake of splitting makes no sense.