Single Responsibility: split calculation from formatting
Keep the money math in one class and the text in another.
Watch the narrated video
Every step
Problem
Steps 1–4Refactor
Steps 5–10Payoff
Step 11Python bonus
Step 12Recap
Steps 13–14The code: before and after
Before
@dataclass(frozen=True)
class Line:
item: str
qty: int
unit_price: int
class InvoiceReport:
def __init__(self, tax_percent: int) -> None:
self.tax_percent = tax_percent
def render(self, customer: str, lines: list[Line]) -> str:
out = [f"Invoice for {customer}"]
subtotal = 0
for line in lines:
amount = line.qty * line.unit_price
subtotal += amount
out.append(f"{line.item} x{line.qty} {amount}")
tax = subtotal * self.tax_percent // 100
out.append(f"Subtotal {subtotal}")
out.append(f"Tax {tax}")
out.append(f"Total {subtotal + tax}")
return "\n".join(out)
def main() -> None:
lines = [Line("Paper", 3, 5), Line("Ink", 1, 25)]
report = InvoiceReport(20)
print(report.render("Acme", lines))After
@dataclass(frozen=True)
class Line:
item: str
qty: int
unit_price: int
def amount(self) -> int:
return self.qty * self.unit_price
@dataclass(frozen=True)
class Totals:
subtotal: int
tax: int
total: int
class InvoiceReport:
def render(
self, customer: str, lines: list[Line], totals: Totals
) -> str:
out = [f"Invoice for {customer}"]
for line in lines:
out.append(f"{line.item} x{line.qty} {line.amount()}")
out.append(f"Subtotal {totals.subtotal}")
out.append(f"Tax {totals.tax}")
out.append(f"Total {totals.total}")
return "\n".join(out)
def render_csv(self, customer: str, totals: Totals) -> str:
return (f"{customer},{totals.subtotal},"
f"{totals.tax},{totals.total}")
class InvoiceCalculator:
def __init__(self, tax_percent: int) -> None:
self.tax_percent = tax_percent
def totals(self, lines: list[Line]) -> Totals:
subtotal = sum(line.amount() for line in lines)
tax = subtotal * self.tax_percent // 100
return Totals(subtotal, tax, subtotal + tax)
def main() -> None:
lines = [Line("Paper", 3, 5), Line("Ink", 1, 25)]
totals = InvoiceCalculator(20).totals(lines)
report = InvoiceReport()
print(report.render("Acme", lines, totals))
print(report.render_csv("Acme", totals))Transcript, step by step
Problem
Step 1: One invoice report Before
This code prints an invoice for a customer, in whole dollars. A Line is one row of the invoice, with an item, a quantity and a unit price. Invoice Report adds up the rows, applies the tax percent and writes the text, all in one method called render. The main function is the caller, and it prints Acme's invoice with a total of forty eight dollars. One loop did both jobs and there was only one kind of invoice, so a single method was a fair first choice.
Step 2: The same invoice as CSV Change request
Now the sales team wants the same invoice as one CSV row for their spreadsheet. The quick edit adds render CSV, and it copies the summing loop and the tax line from render. Main prints that row too, so the run adds Acme, forty, eight, forty eight. Mypy is happy, and today both outputs show the same total.
Step 3: Two jobs in one method The problem
Render does two jobs, because it calculates the money and it formats the text. Calculation is the subtotal, tax and total math, and formatting is turning those numbers into text. You can see render calculate here, in the loop, and here, at the tax, then format here, and render CSV calculate again here and here. So the tax rule now lives twice, in two methods that each work it out on their own. When the finance team says round tax up, both methods change, and the copy you miss sends the sales team a total that disagrees with the printed invoice.
Step 4: Two reasons to change Principle
Robert Martin calls this the Single Responsibility Principle, and it says a class should have one reason to change. A reason to change is a rule, or a group of people, that can ask for an edit. The finance team owns the tax rule, so they can ask you to change these two lines. The customer reads the printed invoice and the sales team reads the CSV, so they own the layout. Invoice Report answers to both groups, so it has two reasons to change.
Refactor
Step 5: Refactor first Back it out
So the CSV comes out again, and we refactor first. We'll split the math from the text in small moves, and then the CSV comes back as one short method.
Step 6: One loop per job Split Loop
The first move is Split Loop, which gives each job its own loop. The first loop only sums the subtotal, and the second only appends a row for each line. The line amount is now worked out in both loops, so the next move fixes that.
Step 7: A line knows its amount Move Method
Move Method puts the line amount into Line, as a method called amount. A row already holds its quantity and unit price, so it can multiply them itself. Both loops now ask the line for its amount instead of doing the math.
Step 8: The math gets a name Extract Method
Extract Method moves the summing loop and the tax line into a method called totals, and the leading underscore tells readers it's private to the class. It returns a small frozen dataclass, Totals, that holds the subtotal, the tax and the total. Render calls totals once and reads the three numbers back. So render has no math left except that one call.
Step 9: A class for the money Extract Class
Extract Class moves the tax percent and the totals method into a new class, Invoice Calculator. Invoice Report keeps a calculator, built from the tax percent its constructor still takes. Render asks the calculator for the totals, and main doesn't change.
Step 10: Hand render the totals Change Function Declaration
Change Function Declaration gives render the totals as a parameter. So Invoice Report loses its init method and the calculator it kept. Main now works out the totals with an Invoice Calculator, then hands them to the report. Invoice Report no longer knows a tax rule exists, because it only lays out the numbers it's given.
Payoff
Step 11: The CSV, again Payoff
Now the sales team's CSV goes back in. Render CSV is one return statement, and main prints its row. Invoice Calculator and render did not change. Render CSV has no loop and no tax math, because it reads the subtotal here and the tax here. A new tax rule edits Invoice Calculator only, and a new layout edits Invoice Report only. Both layouts print the same total, because the math lives in one place.
Python bonus
Step 12: Sum a generator Python bonus
The calculator's whole job is one sum, yet its loop spends three lines and a counter on it. Python's built-in sum takes a generator expression, so the subtotal becomes one line that reads like the rule. We used the same move in an earlier lesson, and it fits here too, because this sum is the number every layout prints.
Recap
Step 13: Where we started, where we ended Before / after
On the left, render summed the rows, worked out the tax and wrote the text, all in one method. Any second output had to copy that math. On the right, Invoice Calculator owns the totals and the tax rule. Invoice Report turns those totals into text, and the CSV is one return statement.
Step 14: When to reach for it Takeaways
Use it when one method computes values and turns them into text, and a second output needs the same values. Skip it when a one-off script prints one report once, because the extra classes add reading with no second caller. You'll spot it by arithmetic between string appends, and by a second output method that copies the math. Python's own format function does only the formatting half, so it puts commas and two decimals on a number and never works out the total.