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.