Skip to content

Single responsibility as one reason to change

Group code by who asks for its changes: each team's requests land in one place, and in no other team's code.

Predict first

Before you read: predict

A payments ledger keeps these five functions in one file. Three teams have sent requests that touch it:

  • finance: interest from 0.05% to 0.07% a day, and the late fee from £15 to £20;
  • collections: the reminder wording from "overdue" to "past due", and the final notice from 60 days to 90 days;
  • the platform team: the table from ledger_rows to ledger_entries.

Before making the changes, the developer splits the file.

Which split follows the single responsibility principle?

Code for this question
// statements.tsexport function interest(pence: number, daysLate: number): number {  return Math.round(pence * 0.0005 * daysLate);}export function lateFee(daysLate: number): number {  return daysLate > 30 ? 1500 : 0;}export function reminderLine(invoiceId: string, daysLate: number): string {  return `Invoice ${invoiceId} is ${daysLate} days overdue`;}export function letterKind(daysLate: number): string {  return daysLate >= 60 ? 'final notice' : 'reminder';}export function saveRow(invoiceId: string, pence: number) {  return { sql: 'INSERT INTO ledger_rows (invoice_id, pence) VALUES ($1, $2)', params: [invoiceId, pence] };}
Pick one

The 30-second version

A module should have one reason to change, and Martin's reason is a person: the team that asks for the change. Keep code that one team specifies together, and code that different teams specify apart, so one team's edit cannot alter another's output. Check a split by counting the files each likely request edits.

Group code by who asks for its changes: each team's requests land in one place, and in no other team's code.

How it works

Step 1 of 14

A reason is a person. Martin defines a reason to change by who asks: one person, or one group serving one business function.
In the kitchen
  • three officers each change a part of one form → one module that three groups specify (steps 1, 3)

Analogy: A cricket club's membership form (amateur sport)

A village cricket club prints one membership form. The treasurer sets its fee bands, the junior coach its age groups and training nights, and the welfare officer its consent wording.

At the cricket club In code
three officers each change a part of one form one module that three groups specify (steps 1, 3)
the treasurer adds a family rate, and the coach's age-group boxes slide off the page one group's edit changes another group's output (step 4)
the secretary fixes a typo a bug fix, which is not a reason to change (step 2)
a fee schedule, a team sheet and a consent form, one per officer a split by who asks (steps 3, 6)
age groups and training nights stay on the coach's sheet one group's code kept together (step 7)
one sheet per age group, so each new season means six edits a split that went too far (step 7)

In real terms: for each request, find who sent it and which files it edits; a file that two groups' requests edit, or a request that edits many files, is the signal.

Breaks down when: a form's sections sit visibly apart on the page, while two groups' functions in code can share one helper that nobody sees; on paper, nothing behaves like a shared function (step 4).

Contrasting case: a club with one officer who does everything has one asker; one form is right until a second officer takes over a part of it.

Examples, run for real

4.1 ExamHall EXH-2520: one results service, three groups asking

GET /results/:attemptId was served by one provider. Three requests arrived in one sprint: the mobile app team renames scorePercent to percent, the exam board raises the pass mark from 50 to 60, and the platform team renames the table attempt_scores to scores.

@Injectable()
export class ResultsService {
  constructor(private readonly db: Db) {}
  async result(attemptId: string) {
    const row = await this.db.one('SELECT score, max_score FROM attempt_scores WHERE attempt_id = $1', [attemptId]);
    if (!row) throw new NotFoundException();
    const percent = Math.round((100 * row.score) / row.max_score);
    return { attempt: attemptId, scorePercent: percent, passed: percent >= 50 };
  }
}
4.1 EXH-2520 results.service.ts as written: mobile team edits results.service.ts · exam board edits results.service.ts · platform team edits results.service.ts
4.1 MR !2820 controller, grading rules and repository: mobile team edits results.controller.ts · exam board edits grading-rules.ts · platform team edits results.repository.ts
4.1 MR !2820 same replies before and after the split: a-1 200 {"attempt":"a-1","scorePercent":72,"passed":true} · a-9 404

All three requests edited one file, so each edit sat beside the other groups' code (steps 4, 9). MR !2820 gives each group its own file: a controller that shapes the reply, plain grading functions and a repository that owns the SQL (steps 10–12). Here each layer happens to have one asker; a second group's rules would need a second rules file (step 3).

4.2 ExamHall EXH-2521: the results screen

ExamResultsComponent fetched an exam's attempts, worked out the median and the 90th percentile, and drew the bars. The backend team renames score to percent in the attempts API, the exam board moves to the 95th percentile, and the designer recolours the bars.

export function ExamResultsComponent({ examId }: { examId: string }) {
  const [rows, setRows] = useState<AttemptRow[]>([]);
  useEffect(() => { fetch(`/api/exams/${examId}/attempts`).then((r) => r.json()).then(setRows); }, [examId]);
  const scores = rows.map((r) => r.score).sort((a, b) => a - b);
  const at = (p: number) => scores[Math.max(0, Math.ceil(p * scores.length) - 1)] ?? 0;
  return (
    <figure>
      {scores.map((s, i) => <div key={i} style={{ height: s, background: '#9aa5b1' }} />)}
      <figcaption>median {at(0.5)} · high {at(0.9)}</figcaption>
    </figure>
  );
}
4.2 EXH-2521 ExamResults.tsx as written: backend team edits ExamResults.tsx · exam board edits ExamResults.tsx · designer edits ExamResults.tsx
4.2 MR !2821 data hook, statistics file and chart: backend team edits use-exam-results.ts · exam board edits exam-stats.ts · designer edits ResultsChart.tsx
4.2 MR !2821 after the backend's rename, files tsc names outside use-exam-results.ts: none

MR !2821 splits the screen by who asks: a data hook turns the API's rows into plain scores, so the backend's rename stops there; the statistics and the bars each get their own file (step 13).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

A shop's code has three files:

The merchandising team wants every shelf price to end in 9p, rounded up, so £12.04 shows as £12.09; next quarter they plan prices ending in 99p. Finance's invoice lines must keep the exact gross.

Where should the 9p rounding go?

Code for this question
// pricing/vat.tsexport function withVat(pence: number): number {  return Math.round(pence * 1.2);} // shelf/shelf-label.tsimport { withVat } from '../pricing/vat';export function shelfPrice(pence: number): string {  return `£${(withVat(pence) / 100).toFixed(2)}`;} // finance/invoice-line.tsimport { withVat } from '../pricing/vat';export function invoiceLine(sku: string, pence: number): string {  return `${sku}: net ${(pence / 100).toFixed(2)}, gross ${(withVat(pence) / 100).toFixed(2)}`;}
Pick one

Question 2 of 5

ExamHall's GradingRules class has nine methods: the pass mark, the merit band, the distinction band, rounding, partial credit, negative marking, late penalties, resit caps and moderation scaling. The exam board specifies all nine and sends its changes to them each term. A developer proposes splitting the class into nine one-method classes, citing the single responsibility principle.

Which reply holds up?

Pick one

Question 3 of 5

A streaming app's WatchStats component draws one bar per day of the week from a viewer's sessions. The team has moved the fetch into a custom Hook, useSessions(viewerId), and the component still adds up each day's minutes itself:

The Export button's click handler needs the same daily minutes for a CSV file.

What should the two lines that build days become?

Code for this question
// stats/WatchStats.tsxexport const WatchStats = ({ viewerId }: { viewerId: string }) => {  const sessions = useSessions(viewerId);  const days = Array<number>(7).fill(0);  for (const s of sessions) days[s.weekday] += s.minutes;  return <Bars values={days} />;};
Pick one

Question 4 of 5

A box office's sales panel:

Three requests arrive:

  • the manager: the figure from the average to the median, captioned "Median";
  • the designer: the bars from #888888 to #1f4fd1;
  • the backend team: the endpoint from /api/shows/ to /api/v2/shows/.

Which of the three requests will edit more than one file?

Code for this question
// sales/use-show-sales.tsimport { useEffect, useState } from 'react';export type NightSales = { night: string; tickets: number };export function useShowSales(showId: string) {  const [nights, setNights] = useState<NightSales[]>([]);  useEffect(() => {    fetch(`/api/shows/${showId}/sales`).then((r) => r.json()).then(setNights);  }, [showId]);  const average = nights.reduce((sum, n) => sum + n.tickets, 0) / (nights.length || 1);  return { nights, average };} // sales/SalesPanel.tsximport { useShowSales } from './use-show-sales';export function SalesPanel({ showId }: { showId: string }) {  const { nights, average } = useShowSales(showId);  return (    <figure>      {nights.map((n) => <div key={n.night} style={{ height: n.tickets, background: '#888888' }} />)}      <figcaption>Average: {average.toFixed(1)} tickets a night</figcaption>    </figure>  );}
Pick one

Question 5 of 5

A chat app's NestJS message API, split into five files:

The trust and safety team now allows messages from 500 to 1,000 characters.

Besides messages/message-rules.ts, which file does that request edit?

Code for this question
// messages/messages.controller.tsimport { Body, Controller, PayloadTooLargeException, Post } from '@nestjs/common';import { MessagesService } from './messages.service'; @Controller('messages')export class MessagesController {  constructor(private readonly messages: MessagesService) {}   @Post()  post(@Body() body: { roomId: string; text: string }) {    if (body.text.length > 500) throw new PayloadTooLargeException();    const saved = this.messages.post(body.roomId, body.text);    return { id: saved.id, held: saved.held };  }} // messages/messages.service.tsimport { Injectable } from '@nestjs/common';import { allowed } from './message-rules';import { MessageStore } from './message.store'; @Injectable()export class MessagesService {  constructor(private readonly store: MessageStore) {}  post(roomId: string, text: string) {    return this.store.insert(roomId, text, !allowed(text));  }} // messages/message-rules.tsconst BLOCKED = ['spoiler']; export function allowed(text: string): boolean {  return text.length <= 500 && !BLOCKED.some((w) => text.toLowerCase().includes(w));} // messages/message.store.tsimport { Injectable } from '@nestjs/common'; @Injectable()export class MessageStore {  rows: { id: string; roomId: string; text: string; held: boolean }[] = [];  insert(roomId: string, text: string, held: boolean) {    const row = { id: `m-${this.rows.length + 1}`, roomId, text, held };    this.rows.push(row);    if (this.rows.length > 500) this.rows.shift();    return row;  }} // messages/messages.module.tsimport { Module } from '@nestjs/common';import { MessagesController } from './messages.controller';import { MessagesService } from './messages.service';import { MessageStore } from './message.store'; @Module({ controllers: [MessagesController], providers: [MessagesService, MessageStore] })export class MessagesModule {}
Pick one

Go deeper

Pro tip
  1. Make the rules file's purity a check in review: a rules file that imports @nestjs/common or the database client has taken on a second asker. When: reviewing a split of a NestJS service (steps 10–12). Verify:
    tip 1 imports from @nestjs/common or the database module, before MR !2820: results.service.ts 2
    tip 1 after MR !2820: grading-rules.ts 0 · results.controller.ts 1 · results.repository.ts 2
Source

React's and NestJS's documentation change with releases, so their claims are labelled as of 2026-10-02, the date read; every React type and NestJS behaviour this lesson grades is also compiled or run on @types/react 19.3.0 and @nestjs/common 12.1.2.

Checked against the tools and docs as of 2026-10-02.