InterviewKata
Low-level design · Module 03a of 11 · Level 1: Foundations

Single responsibility in depth

Module 03 gave single responsibility one line. This module makes it measurable: a responsibility is a group of people who can ask for changes. You'll see where to cut a class, how a bad cut shows up in the change history, and why splitting alone doesn't remove coupling.

1. The idea

A restaurant's menu is changed by the chef, its prices by the owner, and its printing by the designer. If all three edit one shared file, every price change risks a typo in a dish, and every new font means re-checking the prices. Give each person their own part, and a request from one of them can't break the others' work.

"A class should do one thing" sounds clear until you try to measure "one thing". Is saving an expense one thing? Is checking that the shares add up part of it? Robert C. Martin's sharper version is: a class should have one reason to change, and a reason to change is a person or team who can ask for one. If two different groups can each send you change requests for the same class, it has two responsibilities, however tidy it looks.

Inside one ExpenseServiceWho asks for changesA request they might send
Working out each person's shareProduct team"Add percentage splits", "round in the payer's favour"
Saving the expensePlatform team"Move expenses to the new database"
Telling people what they oweMessaging team"Send a push, not an email", "translate into Spanish"
The order of the stepsWhoever owns the feature"Check the group's limit before saving"

Cohesion

How much the parts inside one class belong together. A class whose methods all use the same few fields and change for the same reason is highly cohesive. Splitting by who asks usually raises cohesion.

Coupling

How much one class needs to know about another. Splitting creates new connections; keep them thin (an interface, a few values passed in) or the pieces still move together.

Separation of concerns

The same idea at every size: functions, classes, modules, services. SRP is how it looks for one class. Architecture Module 07 applies it to components.

2. How it works

Follow one change request through two designs. The code does the same thing in both; what differs is who has to touch what.

3. Try it yourself

Read the change history

Six real change requests arrive over a few months. Pick a design and see which classes each request edits. Good splits put each team's requests in its own class; bad splits show up as classes that always change together.

4. In code

An expense service split by who asks for changes: the product team's split rules, the platform team's storage, the messaging team's notifications, and a coordinator that runs the steps. The second expense uses a split type that was added without touching any other class.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass(frozen=True)
class Expense:
    id: int
    paid_by: str
    amount: int               # cents
    shares: dict[str, int]    # person -> cents they owe for this expense


def money(cents: int) -> str:
    return f"{cents // 100}.{cents % 100:02d}"


# Asked for by the product team: how a bill is divided.
class SplitCalculator:
    def equal(self, amount: int, people: list[str]) -> dict[str, int]:
        base, extra = divmod(amount, len(people))
        return {p: base + (1 if i < extra else 0) for i, p in enumerate(people)}

    def by_percent(self, amount: int, percents: dict[str, int]) -> dict[str, int]:
        shares = {p: amount * pct // 100 for p, pct in percents.items()}
        first = next(iter(shares))
        shares[first] += amount - sum(shares.values())   # the first person absorbs rounding
        return shares


# Asked for by the platform team: where expenses are kept.
class ExpenseRepository(ABC):
    @abstractmethod
    def save(self, expense: Expense) -> None: ...

    @abstractmethod
    def count(self) -> int: ...


class InMemoryExpenses(ExpenseRepository):
    def __init__(self) -> None:
        self.rows: list[Expense] = []

    def save(self, expense: Expense) -> None:
        self.rows.append(expense)

    def count(self) -> int:
        return len(self.rows)


# Asked for by the messaging team: what people are told, and how.
class ExpenseNotifier:
    def notify(self, expense: Expense) -> None:
        for person, owed in expense.shares.items():
            if person != expense.paid_by:
                print(f"#{expense.id} {person} owes {expense.paid_by} {money(owed)}")


# The workflow itself: changes only if the steps of adding an expense change.
class ExpenseService:
    def __init__(self, repo: ExpenseRepository, notifier: ExpenseNotifier) -> None:
        self.repo, self.notifier = repo, notifier

    def add(self, paid_by: str, amount: int, shares: dict[str, int]) -> Expense:
        if sum(shares.values()) != amount:
            raise ValueError("shares must add up to the amount")
        expense = Expense(self.repo.count() + 1, paid_by, amount, shares)
        self.repo.save(expense)
        self.notifier.notify(expense)
        return expense


if __name__ == "__main__":
    split, repo = SplitCalculator(), InMemoryExpenses()
    service = ExpenseService(repo, ExpenseNotifier())
    service.add("Ben", 10000, split.equal(10000, ["Ana", "Ben", "Chen"]))
    # The product team adds percentage splits: only SplitCalculator changed.
    service.add("Ana", 6000, split.by_percent(6000, {"Ana": 50, "Ben": 30, "Chen": 20}))
    print(f"stored: {repo.count()} expenses")
interface Expense { id: number; paidBy: string; amount: number; shares: Map<string, number>; } // cents

const money = (c: number): string => `${Math.floor(c / 100)}.${String(c % 100).padStart(2, "0")}`;

// Asked for by the product team: how a bill is divided.
class SplitCalculator {
  equal(amount: number, people: string[]): Map<string, number> {
    const base = Math.floor(amount / people.length), extra = amount % people.length;
    return new Map(people.map((p, i): [string, number] => [p, base + (i < extra ? 1 : 0)]));
  }
  byPercent(amount: number, percents: Map<string, number>): Map<string, number> {
    const shares = new Map<string, number>();
    for (const [p, pct] of percents) shares.set(p, Math.floor((amount * pct) / 100));
    const first = [...shares.keys()][0];
    const total = [...shares.values()].reduce((s, v) => s + v, 0);
    shares.set(first, (shares.get(first) ?? 0) + amount - total); // the first person absorbs rounding
    return shares;
  }
}

// Asked for by the platform team: where expenses are kept.
interface ExpenseRepository { save(expense: Expense): void; count(): number; }

class InMemoryExpenses implements ExpenseRepository {
  private rows: Expense[] = [];
  save(expense: Expense): void { this.rows.push(expense); }
  count(): number { return this.rows.length; }
}

// Asked for by the messaging team: what people are told, and how.
class ExpenseNotifier {
  notify(e: Expense): void {
    for (const [person, owed] of e.shares) if (person !== e.paidBy) console.log(`#${e.id} ${person} owes ${e.paidBy} ${money(owed)}`);
  }
}

// The workflow itself: changes only if the steps of adding an expense change.
class ExpenseService {
  constructor(private repo: ExpenseRepository, private notifier: ExpenseNotifier) {}
  add(paidBy: string, amount: number, shares: Map<string, number>): Expense {
    if ([...shares.values()].reduce((s, v) => s + v, 0) !== amount) throw new Error("shares must add up to the amount");
    const expense: Expense = { id: this.repo.count() + 1, paidBy, amount, shares };
    this.repo.save(expense);
    this.notifier.notify(expense);
    return expense;
  }
}

const split = new SplitCalculator(), repo = new InMemoryExpenses();
const service = new ExpenseService(repo, new ExpenseNotifier());
service.add("Ben", 10000, split.equal(10000, ["Ana", "Ben", "Chen"]));
// The product team adds percentage splits: only SplitCalculator changed.
service.add("Ana", 6000, split.byPercent(6000, new Map([["Ana", 50], ["Ben", 30], ["Chen", 20]])));
console.log(`stored: ${repo.count()} expenses`);
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

record Expense(int id, String paidBy, int amount, Map<String, Integer> shares) {} // cents

// Asked for by the product team: how a bill is divided.
class SplitCalculator {
    Map<String, Integer> equal(int amount, List<String> people) {
        int base = amount / people.size(), extra = amount % people.size();
        Map<String, Integer> shares = new LinkedHashMap<>();
        for (int i = 0; i < people.size(); i++) shares.put(people.get(i), base + (i < extra ? 1 : 0));
        return shares;
    }

    Map<String, Integer> byPercent(int amount, Map<String, Integer> percents) {
        Map<String, Integer> shares = new LinkedHashMap<>();
        percents.forEach((p, pct) -> shares.put(p, amount * pct / 100));
        String first = shares.keySet().iterator().next();
        int total = shares.values().stream().mapToInt(Integer::intValue).sum();
        shares.put(first, shares.get(first) + amount - total); // the first person absorbs rounding
        return shares;
    }
}

// Asked for by the platform team: where expenses are kept.
interface ExpenseRepository {
    void save(Expense expense);
    int count();
}

class InMemoryExpenses implements ExpenseRepository {
    private final List<Expense> rows = new ArrayList<>();
    public void save(Expense expense) { rows.add(expense); }
    public int count() { return rows.size(); }
}

// Asked for by the messaging team: what people are told, and how.
class ExpenseNotifier {
    static String money(int c) { return (c / 100) + "." + String.format("%02d", c % 100); }

    void notify(Expense e) {
        e.shares().forEach((person, owed) -> {
            if (!person.equals(e.paidBy())) System.out.println("#" + e.id() + " " + person + " owes " + e.paidBy() + " " + money(owed));
        });
    }
}

// The workflow itself: changes only if the steps of adding an expense change.
class ExpenseService {
    private final ExpenseRepository repo;
    private final ExpenseNotifier notifier;

    ExpenseService(ExpenseRepository repo, ExpenseNotifier notifier) { this.repo = repo; this.notifier = notifier; }

    Expense add(String paidBy, int amount, Map<String, Integer> shares) {
        if (shares.values().stream().mapToInt(Integer::intValue).sum() != amount) throw new IllegalArgumentException("shares must add up to the amount");
        Expense expense = new Expense(repo.count() + 1, paidBy, amount, shares);
        repo.save(expense);
        notifier.notify(expense);
        return expense;
    }
}

public class Main {
    public static void main(String[] args) {
        SplitCalculator split = new SplitCalculator();
        InMemoryExpenses repo = new InMemoryExpenses();
        ExpenseService service = new ExpenseService(repo, new ExpenseNotifier());
        service.add("Ben", 10000, split.equal(10000, List.of("Ana", "Ben", "Chen")));
        // The product team adds percentage splits: only SplitCalculator changed.
        Map<String, Integer> pct = new LinkedHashMap<>();
        pct.put("Ana", 50); pct.put("Ben", 30); pct.put("Chen", 20);
        service.add("Ana", 6000, split.byPercent(6000, pct));
        System.out.println("stored: " + repo.count() + " expenses");
    }
}

Output (the same in all three languages)

#1 Ana owes Ben 33.34
#1 Chen owes Ben 33.33
#2 Ben owes Ana 18.00
#2 Chen owes Ana 12.00
stored: 2 expenses

5. In the interview

6. Remember it

Memory hook: "One class, one team asking"A class has one responsibility when only one group can ask for it to change.
How this hook works: what “One class, one team asking” means

What the slogan means, phrase by phrase

“One class”
The unit we're judging: a class (or a module).
“one team asking”
Count the people or teams who can send change requests; that's the number of responsibilities.

Why this picture fits: A restaurant menu: the chef edits dishes, the owner edits prices, the designer edits the print. One file each.

The story → the concept

The restaurantThe code
The chef's dishesSplitCalculator (product team)
The owner's pricesExpenseRepository (platform team)
The designer's printExpenseNotifier (messaging team)
The menu itselfExpenseService, the workflow

Watch it on an example

  1. Product adds percentage splits: only SplitCalculator changes.
  2. Messaging switches to push: only ExpenseNotifier changes.
  3. A new currency field touches everything, and that's fine: it's a change to the data itself.

The trap it saves you from

Splitting by step or by method (Validator, Calculator, Saver…), so pieces that always change together live in different files.

It still works when…

Even in tiny programs: you don't have to split, but you should be able to say who would ask for each change.

7. Common mistakes

Counting methods instead of askersA class with ten methods can have one responsibility; a class with three can have three.
Splitting by stepValidator, Calculator, Saver for one rule: they change together, so they belong together.
Classes called Helper, Utils or ManagerA name that only makes sense next to the original class usually means the split has no purpose of its own.
Thick seamsNew classes that pass each other half their internals are still coupled; keep the connection to an interface and a few values.

8. Practice exercise (25 minutes)

  1. List the askers (5 min). Take the ParkingLot class from your last design. For each method, write which team would ask to change it.
  2. Split once (10 min). Move one team's methods into its own class with a name that stands alone. What does the original class still do?
  3. Test the split (5 min). Write one test for the new class without creating the old one. Did you need fakes?
  4. Find a bad split (5 min). In code you know, find two classes that always change in the same commits. Should they be one?

9. Self-check quiz

What is the clearest test that a class has two responsibilities?

Responsibilities come from the people who request changes, not from size.

ExpenseValidator and ExpenseCalculator appear together in nearly every commit. What does that suggest?

Things that change together belong together.

After splitting, what still runs the steps in order?

Here ExpenseService orchestrates; it changes only if the steps change.

Which change is fine to touch every class?

A change to the data itself crosses every responsibility; that's expected.

10. Go deeper

ResourceTimeWhy
Module 03b: Open/closed in depthNextOnce responsibilities are separate, new behaviour can arrive as new classes.
Modularity, cohesion and coupling (Software architecture)30 minCohesion and coupling measured for components.
P11: Expense sharing45 minThe full problem this module's example comes from.
The Single Responsibility Principle (Robert C. Martin)10 minThe author's own explanation of "reason to change" as people.