Low-level designCore10 min

Single responsibility principle

Give each class one reason to change. Code that changes for different reasons, at the request of different people, belongs in different classes, so a change for one can't break the other.

Watch it evolve1 / 10
The problem

A running club sums up every week

TrainingWeek adds up the runs, prints a summary, and appends the week to the history file.

80
Start
Stage 1
Stage 2
Stage 3
Final

1 · The situation

A week of runs, summed up and saved

The Riverside Runners club has a small app. Every Sunday it does three things with each runner's week:

  • adds up the runs: how far in total, and the longest run;
  • prints a one-line summary for the runner;
  • appends a line to history.csv, which the app's progress chart reads.

The first version is the one most of us would write: a class TrainingWeek that holds the week's runs and does all three, in totalKm() and longestKm(), printSummary() and save(). A small private helper, distance(), formats a distance to one decimal place, and both the summary and the history use it. The names are the same in all four languages, except that Python writes methods in snake case: print_summary().

The first versionJava
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

class TrainingWeek {
    private final int number;
    private final List<Double> runs = new ArrayList<>();

    TrainingWeek(int number) {
        this.number = number;
    }

    void add(double km) {
        runs.add(km);
    }

    double totalKm() {
        return runs.stream().mapToDouble(Double::doubleValue).sum();
    }

    double longestKm() {
        return runs.stream().mapToDouble(Double::doubleValue).max().orElse(0);
    }

    void printSummary() {
        System.out.println("Week " + number + ": " + runs.size() + " runs, "
                + distance(totalKm()) + " km, longest " + distance(longestKm()) + " km");
    }

    void save(Path history) throws IOException {
        String line = number + "," + runs.size() + "," + distance(totalKm()) + "\n";
        Files.writeString(history, line, StandardOpenOption.APPEND);
    }

    // One decimal place, everywhere.
    private String distance(double km) {
        return String.format(Locale.ROOT, "%.1f", km);
    }
}

public class Main {
    public static void main(String[] args) throws IOException {
        Path history = Files.createTempFile("history", ".csv");
        Files.writeString(history, "39,4,31.0\n");

        TrainingWeek week = new TrainingWeek(40);
        week.add(5.2);
        week.add(8.0);
        week.add(12.4);
        week.printSummary();
        week.save(history);

        System.out.println("history.csv:");
        System.out.print(Files.readString(history));
        Files.delete(history);
    }
}
TrainingWeek− number: int− runs: List<Double>~ add(double)~ totalKm(): double~ longestKm(): double~ printSummary()~ save(Path)− distance(double): String

The class diagram of this version, drawn from its code: one class does everything.

It works. The program starts from a history file that already holds last week (week 39), records three runs for week 40, prints the summary, saves the week, and shows the file:

Week 40: 3 runs, 25.6 km, longest 12.4 km
history.csv:
39,4,31.0
40,3,25.6

2 · The change

A change to the summary breaks the history

The club's new coach plans training in miles, and the runners ask for the summary in miles too. Both numbers in the summary go through distance(), so the obvious edit is to convert there, and to change the two labels from km to mi:

The miles change, made in the obvious placeJava
class TrainingWeek {
    private final int number;
    private final List<Double> runs = new ArrayList<>();

    TrainingWeek(int number) {
        this.number = number;
    }

    void add(double km) {
        runs.add(km);
    }

    double totalKm() {
        return runs.stream().mapToDouble(Double::doubleValue).sum();
    }

    double longestKm() {
        return runs.stream().mapToDouble(Double::doubleValue).max().orElse(0);
    }

    void printSummary() {
        System.out.println("Week " + number + ": " + runs.size() + " runs, "
                + distance(totalKm()) + " mi, longest " + distance(longestKm()) + " mi");
    }

    void save(Path history) throws IOException {
        String line = number + "," + runs.size() + "," + distance(totalKm()) + "\n";
        Files.writeString(history, line, StandardOpenOption.APPEND);
    }

    // In miles now, one decimal place.
    private String distance(double km) {
        return String.format(Locale.ROOT, "%.1f", km * 0.621371);
    }
}

The summary is right. The history file is not:

Week 40: 3 runs, 15.9 mi, longest 7.7 mi
history.csv:
39,4,31.0
40,3,15.9

Week 40 is now saved in miles, right under week 39 in kilometers.

Nothing crashed and no test of the summary failed. save() also calls distance(), so the history now mixes units, and the progress chart shows week 40 at well under its real distance. The edit was asked for by the runners, about the summary, and it changed the file format that the progress chart depends on.

The problem isn't the edit. It's that one class answers to several people, each of whom changes it for their own reason:

Who asks

What they change

Where it lands today

The coach

How a week adds up: does a walk count as a run?

TrainingWeek

The runners

How the summary reads: miles, wording, layout

TrainingWeek

The app team

How the history is stored: a new column, a database

TrainingWeek

Three reasons to change, one class. That has more costs than the bug:

  • Shared code couples the jobs. Any helper two jobs both use, like distance(), carries a change for one into the other.
  • Tests need the console and the disk. To check the summary's wording you capture what it prints, and to check the history you need a real file.
  • Unrelated edits collide. Two people working on the summary and the storage edit the same file at the same time.

The practice checks flag this version: TrainingWeek prints output and writes to storage besides its own work.

3 · The fix

Give each reason to change its own class

Start from the original version and pull each job that answers to someone else out of TrainingWeek, running the program after each step:

SummaryPrinter prints a week's summary, and HistoryStore appends a week to the history file. Each one is given the TrainingWeek to work on, and each formats its own numbers.

Java
class SummaryPrinter {
    void print(TrainingWeek week) {
        System.out.println("Week " + week.number() + ": " + week.runCount() + " runs, "
                + distance(week.totalKm()) + " km, longest " + distance(week.longestKm()) + " km");
    }

    private String distance(double km) {
        return String.format(Locale.ROOT, "%.1f", km);
    }
}

class HistoryStore {
    private final Path file;

    HistoryStore(Path file) {
        this.file = file;
    }

    void save(TrainingWeek week) throws IOException {
        String line = String.format(Locale.ROOT, "%d,%d,%.1f\n",
                week.number(), week.runCount(), week.totalKm());
        Files.writeString(file, line, StandardOpenOption.APPEND);
    }
}
TrainingWeek− number: int− runs: List<Double>~ add(double)~ number(): int~ runCount(): int~ totalKm(): double~ longestKm(): doubleSummaryPrinter~ print(TrainingWeek)− distance(double): StringHistoryStore− file: Path~ save(TrainingWeek)

After the first two steps the program prints exactly what the first version printed: the split changes the structure, not the behavior. After the third, the summary is in miles and the history is still in kilometers:

Week 40: 3 runs, 15.9 mi, longest 7.7 mi
history.csv:
39,4,31.0
40,3,25.6
Before the split
2
After the split
1

Jobs the miles edit reaches. Before: the summary and the history file. After: the summary alone.

4 · The principle

Single responsibility: one reason to change

The single responsibility principle says a class should have one reason to change. A responsibility here is not a method or a task: it's a need the class serves, usually owned by one person or group who asks for its changes. Code that serves different needs, and so changes for different reasons, goes into different classes.

TrainingWeek− number: int− runs: List<Double>~ add(double)~ number(): int~ runCount(): int~ totalKm(): double~ longestKm(): doubleSummaryPrinter~ print(TrainingWeek)− distance(double): StringHistoryStore− file: Path~ save(TrainingWeek)

One class per reason to change: TrainingWeek for how a week adds up, SummaryPrinter for how the summary reads, HistoryStore for how the history is stored.

One reason to change doesn't mean one method. TrainingWeek has totalKm() and longestKm(), and both stay together because the same person, the coach, decides what counts. A class with twenty methods can have one responsibility; a class with two can have two.

Question to ask

What it reveals

Who would ask for a change to this method?

Methods with different answers serve different responsibilities

Which methods changed together in the last few changes?

Methods that change together belong together

Does testing this logic need a console, a file or a network?

Input and output are usually a separate responsibility from the logic

Can you say what the class does without "and"?

Each "and" is a candidate for its own class

The smell that points to a broken rule is divergent change: one class edited, over time, for several unrelated reasons. Its opposite, shotgun surgery, is one change spread across many classes, and it's what you get from splitting too far.

5 · When breaking it is fine

More classes, and some code that looks repeated

The split has costs:

  • More types. One class became three, and a reader follows a week through all of them.
  • Code that looks duplicated. SummaryPrinter and HistoryStore both format a distance to one decimal place.
The same format, in two classes on purposeJava
class SummaryPrinter {
    void print(TrainingWeek week) {
        System.out.println("Week " + week.number() + ": " + week.runCount() + " runs, "
                + distance(week.totalKm()) + " km, longest " + distance(week.longestKm()) + " km");
    }

    private String distance(double km) {
        return String.format(Locale.ROOT, "%.1f", km);
    }
}

class HistoryStore {
    private final Path file;

    HistoryStore(Path file) {
        this.file = file;
    }

    void save(TrainingWeek week) throws IOException {
        String line = String.format(Locale.ROOT, "%d,%d,%.1f\n",
                week.number(), week.runCount(), week.totalKm());
        Files.writeString(file, line, StandardOpenOption.APPEND);
    }
}

That repetition is deliberate. The two lines look the same today, but they answer to different people: the summary can switch to miles, and the history can't. Merge them into one shared helper and you have rebuilt the bug. Code is only truly duplicated when it must change together.

Leave the jobs together when:

  • They always change together. If every change to the content is also a change to how it's shown, such as a new field, splitting them only means editing two classes each time.
  • The program is small and short-lived. A one-off script that sums a file and prints the answer doesn't need three classes.
  • There's only one person asking. If the same person owns the logic and its output, and always will, there is one reason to change.

6 · Getting it right

Split by who asks, not by verb

  • Don't split what changes together. Putting totalKm() and longestKm() in separate classes would scatter one person's changes across two files.
  • Watch shared helpers. A private method that two jobs both call is where a change for one leaks into the other.
  • Keep input and output at the edges. A class that does no printing or storage can be tested with plain values.
  • Pass the object in. SummaryPrinter.print(week) takes the week it prints, so it works with any week and needs no setup.

A responsibility doesn't have to be a class. Languages with functions outside classes often give a job a function of its own:

Language

The usual shape

Java

A class per responsibility; code can't live outside a class

C++

A class, or free functions in a namespace for a job with no state, such as printing a summary

JavaScript

A class, or a module that exports functions; a module is a natural home for one responsibility

Python

A class, or functions in a module; print_summary(week) as a plain function is idiomatic

7 · Look-alikes

Close ideas, different questions

Several ideas are about keeping things apart. What tells them apart is the question each one asks.

Idea

The question it asks

Applies to

Single responsibility

Does this class have more than one reason to change?

Classes and modules

Cohesion

How closely do the parts of this module belong together?

Any module; a measure, not a rule

Separation of concerns

Is each concern of the program handled in its own part?

Any scale: functions, classes, layers, services

Interface segregation

Does a caller depend on methods it doesn't use?

Interfaces, seen from the caller's side

Do one thing

Does this function do one step at one level of detail?

Functions

Single responsibility is a way to get high cohesion: a class whose parts all serve one need is cohesive by construction. And "do one thing" is the rule people often mistake it for, but it's about the size of a function, not about who a class answers to.

8 · In practice

The garage that prints its own receipts

In the parking-lot practice problem, Hilltop Garage's contractor left a draft in which ParkingLot parks cars, prices each stay, caps the fee itself, and prints the receipt. Raising the daily cap means editing the garage's own code. The checks report it as "ParkingLot also overrides what pricing returns and prints output".

They look for a class that, besides its own work, prints output, reads or writes files or a database, or changes what a collaborator behind an interface returns. A class named for that job, such as a printer or a repository, is left alone. Design the parking lot, or fix the contractor's draft, with the checks reading your classes.

Check yourself

4 questions

1. After the split, the app team moves the history from a CSV file to the club's server. Which classes change?
2. SummaryPrinter and HistoryStore both format a distance to one decimal place. A teammate wants one shared helper for it. What's the risk?
3. Which class most clearly breaks the single responsibility principle?
4. Does single responsibility mean a class should have only one public method?

Takeaways

Remember this

  • A class should have one reason to change: everything in it serves the same need.
  • A reason to change is usually a person or group who asks for changes, such as a coach, the runners or the app team.
  • A helper shared by two jobs is where a change for one quietly breaks the other.
  • Code that looks the same but changes for different reasons is not real duplication; keep it apart.
  • Split by who asks for changes, not by method: many methods that change together belong in one class.