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_rowstoledger_entries.
Before making the changes, the developer splits the file.
Which split follows the single responsibility principle?
// 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] };}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
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?
// 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)}`;}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?
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?
// 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} />;};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
#888888to#1f4fd1; - the backend team: the endpoint from
/api/shows/to/api/v2/shows/.
Which of the three requests will edit more than one file?
// 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> );}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?
// 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 {}Go deeper
Pro tip
- Make the rules file's purity a check in review: a rules file that imports
@nestjs/commonor 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.
- Robert C. Martin, The Single Responsibility Principle (8 May 2014), the curriculum row's first source: https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html (
martin-2014-srp.txt). The source of steps 1–4 and 7–8; quoted in P2. - react.dev, Reusing Logic with Custom Hooks (as of 2026-10-02), the row's second source: https://react.dev/learn/reusing-logic-with-custom-hooks (
react-reusing-logic-custom-hooks.txt). The source of step 14; quoted in P3. - NestJS documentation, Controllers (NestJS 12, as of 2026-10-02): https://docs.nestjs.com/controllers. That page renders in the browser and its static HTML holds no chapter text, so the text was read from its source file at the file's latest commit on 2026-10-02: https://raw.githubusercontent.com/nestjs/docs.nestjs.com/5d335de5360b7ea6afd313eee915124cc07c1650/content/controllers.md (
nestjs-controllers.txt). Teaching only (step 10): "Controllers are responsible for handling incoming requests and sending responses back to the client." It keys no item. - NestJS documentation, Providers (as of 2026-10-02): https://docs.nestjs.com/providers, read from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/components.md (
nestjs-providers.txt). Teaching only (steps 10 and 12): "Controllers should handle HTTP requests and delegate more complex tasks to providers." It keys no item.
Related lessons
- Read first: SOLID at a glance, with LSP and ISP in TypeScript
- Related: Dependency inversion and NestJS dependency injection
- Related: Component decomposition and client data model
- Related: Test pyramid and testing trophy
- Related: Orienting in an unfamiliar codebase fast
- Related: Refactoring safely
- Related: When a pattern is over-engineering
Checked against the tools and docs as of 2026-10-02.