LearnLLDSOLIDS: Single responsibility

Single responsibility principle

Invoice.ts was edited 31 times last quarter. Finance changed how GST is rounded. Design changed the invoice layout twice. The platform team moved the table from MySQL to Postgres. Three teams, one file, a merge conflict most weeks. Then a layout change shipped with a tax bug in it, because the person fixing the header also “tidied” a line in the total that looked unused.

The file was not too long. It was answering to too many people.

Your answer

An Invoice class calculates its total with tax, renders itself as HTML, and saves itself to the database. Which different people or teams might ask for a change to this one class? List them.

The idea

The usual wording is “a class should have only one reason to change”. That is easy to recite and hard to use, because what counts as a reason? Robert C. Martin’s later wording is the one you can act on: a class should be responsible to one actor. An actor is a person or team who asks for changes.

The picture to keep
The chef does not do the accounts

In a restaurant the chef cooks, the cashier bills and the rider delivers. When the GST rate changes, the cashier’s work changes and the kitchen does not hear about it. If the chef also kept the books, every tax notice would interrupt dinner, and a mistake in the ledger could burn the dal.

In one line: One class, one boss. If two different teams can ask for changes to it, it is two classes.

Before: three bosses

type Item = { name: string; price: number; quantity: number }
 
class Invoice {
  constructor(private readonly items: Item[]) {}
 
  // Finance decides this.
  total(): number {
    const subtotal = this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    )
    return subtotal + subtotal * 0.05
  }
 
  // Design decides this.
  toHtml(): string {
    return `<h1>Invoice</h1><p>Total: ${this.total()}</p>`
  }
 
  // The platform team decides this.
  save(): string {
    return `INSERT INTO invoices (total) VALUES (${this.total()})`
  }
}
 
const items: Item[] = [{ name: 'Masala dosa', price: 120, quantity: 2 }]
const invoice = new Invoice(items)
 
console.log(invoice.total()) // 252
console.log(invoice.save()) // INSERT INTO invoices (total) VALUES (252)

It works. The problem is not visible in the code, it is visible in the git log. Every change by any of the three teams opens this file, re-tests this file, and can break the other two teams’ parts of it.

After: one boss each

type Item = { name: string; price: number; quantity: number }
 
// Finance.
class Invoice {
  constructor(private readonly items: readonly Item[]) {}
 
  total(): number {
    const subtotal = this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    )
    return subtotal + subtotal * 0.05
  }
}
 
// Design.
function renderInvoiceHtml(invoice: Invoice): string {
  return `<h1>Invoice</h1><p>Total: ${invoice.total()}</p>`
}
 
// Platform.
class InvoiceRepository {
  save(invoice: Invoice): string {
    return `INSERT INTO invoices (total) VALUES (${invoice.total()})`
  }
}
 
const items: Item[] = [{ name: 'Masala dosa', price: 120, quantity: 2 }]
const invoice = new Invoice(items)
const repository = new InvoiceRepository()
 
console.log(renderInvoiceHtml(invoice)) // <h1>Invoice</h1><p>Total: 252</p>
console.log(repository.save(invoice))
// > INSERT INTO invoices (total) VALUES (252)

Same behaviour, three places. A layout change now opens one function. The move to Postgres opens one class. Nobody tidying the header can reach the tax line.

Two details are worth a look. The renderer is a plain function, because it has no state to protect. The repository is a class, because the real one would hold a database connection. And readonly Item[] means Invoice promises not to change the array it was given.

Figure 1. Each team now has one place to change. The renderer and the repository read the invoice through its public total(), and neither can alter how it is calculated.

How to spot it

Three tests, in the order I use them.

Say what the class does without using “and”. “It calculates the total and renders HTML and saves to the database” is three jobs. “It knows what the customer owes” is one.

List who would ask for a change. If you write down two job titles, you have found the seam. This is the test that works when the first one is ambiguous.

Look at the imports. A class that imports a tax library, a template engine and a database driver is three things glued together. The import list does not lie.

Getting it wrong in the other direction

One method per class

InvoiceSubtotalCalculator, InvoiceTaxCalculator, InvoiceTotalCalculator. Each has one method, and to understand how a total is computed you now read three files. Single responsibility does not mean a class does one thing. It means the things it does change for the same reason. Subtotal, tax and total all change when finance says so. They belong together.

The class called Manager

OrderManager, UserHelper, PaymentUtils. A vague name is a sign the class has no single job to be named after, so it becomes the place where anything order related gets put. If you cannot name it after what it does, it probably does several things.

How do you decide where to split?

This is the follow up, and “one reason to change” on its own will not survive it. Say: “I ask who would request a change. If two different stakeholders could ask for changes to the same class for unrelated reasons, I split along that line. And I keep together what changes together.” Then give the invoice example in two sentences. An answer with a named actor in it sounds like someone who has done it.

TypeScript you just picked up
type Item = { name: string; price: number; quantity: number }
Plain data gets a type alias. No class needed, there are no rules to protect.
readonly Item[]
The class may read the array but not push to it or reorder it.
function renderInvoiceHtml(invoice: Invoice): string
A stateless responsibility is a function. It takes the object it works on as a parameter.
(sum, item) => sum + item.price * item.quantity
The types of sum and item are inferred from the array and the starting value 0.
export class Invoice
In a real project each of these three lives in its own file and is exported from it.

Checkpoint

Checkpoint

1. What is the most useful meaning of "one reason to change"?

2. A class computes subtotal, tax and grand total for an invoice. A colleague wants to split it into three classes for single responsibility. Should you?

3. UserService validates signup forms, hashes passwords, sends the welcome email and writes audit logs. What is the first sign in day to day work that this is a problem?

Say this in 60 seconds

Single responsibility says a class should have one reason to change, and the useful way to read that is one actor: one person or team who asks for changes to it. An invoice class that calculates tax, renders HTML and saves to the database answers to finance, design and the platform team, so their changes collide in one file. I split it along those lines: the invoice keeps the money rules, rendering becomes its own function, and persistence becomes a repository. I do not split further than that, because things that change together belong together. The way I find a violation is to describe the class without the word and, or to look for a file that turns up in every pull request.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.