PYTH-270 · Lesson 1 of 12 · 26 min
What good design means
▶ Video coming soon A video version of this lesson is on the way.
Ridgeline Coffee Roasters now runs on a small mountain of Python. The stock tool Jordan wrote in PYTH-101 became the ridgeline package in PYTH-102, gained a database in PYTH-215, a web shop in PYTH-220 and a dispatch assistant in PYTH-201. It works, but every change is getting harder. When Rosa asked for coffee subscriptions last week, Jordan found that the order code lived in one function, process_order, that had grown to 180 lines and handled cafe sales, online orders, wholesale accounts, loyalty points, discounts, shipping and the confirmation email. Changing the discount rules broke the loyalty points. Fixing the loyalty points broke the wholesale invoices.
Ridgeline has hired Lena Park, an experienced developer, for a few days a month to review the code and coach Jordan and Sam Okoye, the co-op student. Her first question was not about patterns or frameworks. It was: how expensive is it to change this code? This course is about answering that question well. It starts here with what good design means, how to recognise bad design, the principles that keep code easy to change, and how to tell whether a design is getting better or worse.
Design is about change
Software design is the set of decisions about how code is divided into pieces, what each piece is responsible for, and how the pieces talk to each other. Every program has a design, whether or not anyone chose it on purpose. A design is good when the changes people actually need to make are cheap and safe: cheap because the change touches few places, and safe because it does not break things far away.
That definition matters because code is read and changed far more often than it is written. A function written in an afternoon may be read a hundred times and edited twenty times over five years. If each edit takes an hour longer than it should, the poor design costs far more than it saved. Lena put it to Jordan simply: you are not designing for the computer, which will run anything that is correct; you are designing for the next person who has to change it, who is usually you, six months from now.
Good design is not the same as clever code, many classes, or the use of famous patterns. A twenty-line script that does one job and will never change is well designed if it is clear. A thousand-line application that must keep changing for years needs more structure. The skill is matching the amount of structure to the amount of change you expect.
Symptoms of a design in trouble
Robert C. Martin described four symptoms that show a design is decaying, and Ridgeline's order code has all of them. Rigidity means a simple change forces many other changes: adding subscriptions meant editing the totals, the email and the invoice code. Fragility means changes break things in unrelated places: the discount fix broke loyalty points. Immobility means useful parts cannot be reused elsewhere because they are tangled with everything else: the shipping calculation could not be used by the dispatch assistant without dragging in the email code. Viscosity means doing the right thing is harder than doing a hack, so people take shortcuts that make the design worse.
There are also symptoms you can see in the code itself, often called code smells. A smell is not proof of a problem, but it is a reason to look closer. Long functions and long parameter lists, duplicated code, the same if-chain repeated in several places, a class that knows the private details of another, magic numbers with no names, comments that apologise for confusing code, and functions named do_stuff or process all suggest that responsibilities are mixed up.
def process_order(kind, items, customer, code=None):
total = 0
for sku, qty, price in items:
total += qty * price
if kind == "wholesale":
total = total * 0.85
elif kind == "online":
if code == "WELCOME10":
total = total * 0.9
if total < 50:
total += 8.95
if kind != "wholesale":
customer["points"] += int(total)
print(f"Order for {customer['name']}: {total:.2f}")
return round(total, 2)
alice = {"name": "Alice", "points": 0}
print(process_order("online", [("RC-101", 2, 18.50)], alice, "WELCOME10"))
print(alice)Order for Alice: 42.25
42.25
{'name': 'Alice', 'points': 42}Even this small slice mixes five jobs: adding up the lines, applying a customer-type discount, applying a promotion code, adding shipping, updating loyalty points and printing. The magic numbers 0.85, 0.9, 50 and 8.95 hide business rules that Rosa changes every season. To test the shipping rule you must also build a customer dictionary and watch the print output. And the function changes its argument, the customer dictionary, which surprises anyone who calls it.
Principles that keep change cheap
A handful of short principles capture most of what experienced developers mean by good design. They are rules of thumb, not laws, and they sometimes pull against each other.
- Keep it simple (KISS): prefer the simplest design that meets today's needs. Simple code has fewer places for bugs to hide.
- Don't repeat yourself (DRY): every piece of knowledge, such as the wholesale discount rate, should live in one place. When a rule is copied into three functions, the next change will update only two of them.
- You aren't gonna need it (YAGNI): do not build features or flexibility for changes you merely imagine. Speculative structure costs effort now and often guesses wrong.
- Separation of concerns: keep different reasons for change apart, such as business rules, storage and the user interface.
- Make the change easy, then make the easy change: when a change is hard, first reshape the code so that it becomes easy, then make it. Kent Beck's line sums up most of this course.
Kent Beck also proposed four rules of simple design, in priority order: the code passes its tests, reveals its intention, has no duplication, and has the fewest elements. Look at the order. Correctness comes first, clarity second, removing duplication third, and only then reducing the number of classes and functions. A design with fewer parts is not better if it is harder to understand.
Try it yourself
One home for the shipping rule
The free-shipping rule appears twice in the starter: online orders under 50 pay a shipping fee of 8.95, and the same numbers are copied into the gift-card code. Rosa is about to raise the threshold.
Write shipping_fee(subtotal) that returns 8.95 for a subtotal under 50 and 0.0 otherwise, using the named constants FREE_SHIPPING_FROM and SHIPPING_FEE, and make both online_total and gift_card_total call it. The tests change the constants to check that the rule now lives in one place.
FREE_SHIPPING_FROM = 50
SHIPPING_FEE = 8.95
def online_total(subtotal):
if subtotal < 50:
return round(subtotal + 8.95, 2)
return round(subtotal, 2)
def gift_card_total(amount):
fee = 8.95 if amount < 50 else 0.0
return round(amount + fee, 2)
print(online_total(37.00), gift_card_total(100))Coupling and cohesion in one minute
Two ideas explain why some designs are easy to change and others are not. Lesson 2 covers them in depth, but they are worth naming now. Cohesion is how closely the things inside one piece belong together: a highly cohesive function or class does one job, and all of its parts serve that job. Coupling is how much one piece depends on the details of another: loosely coupled pieces can change independently, while tightly coupled pieces must change together.
The goal is high cohesion and low coupling. process_order has low cohesion, because pricing, shipping, loyalty and printing are unrelated jobs, and it creates tight coupling, because anything that wants the shipping rule must call the whole function. Splitting it into a pricing function, a shipping function, a loyalty function and a printing step raises cohesion, and each caller can depend only on the piece it needs.
Measuring design
Design quality is partly a matter of judgment, but some measurements help you notice problems and track progress. None of them should be treated as a target to game; they are warning lights. Function length is the simplest: a function that does not fit on one screen is usually doing more than one thing. Cyclomatic complexity counts the independent paths through a function, roughly one plus the number of decisions (if, elif, for, while, and, or, except). Above about ten, a function is hard to test thoroughly, because every path needs its own test.
Other useful signals are the number of parameters, the depth of nesting, how many modules import a given module (a sign of how many places a change can ripple to), and how often a file changes alongside another file in version control (a sign of hidden coupling). Tools such as radon, ruff and pylint compute many of these, but you can count decisions yourself with Python's ast module, which parses source code into a tree.
import ast
SOURCE = """
def process_order(kind, items, customer, code=None):
total = 0
for sku, qty, price in items:
total += qty * price
if kind == "wholesale":
total = total * 0.85
elif kind == "online":
if code == "WELCOME10":
total = total * 0.9
if total < 50:
total += 8.95
if kind != "wholesale":
customer["points"] += int(total)
return round(total, 2)
"""
DECISIONS = (ast.If, ast.For, ast.While, ast.BoolOp, ast.ExceptHandler, ast.IfExp)
def complexity(func):
return 1 + sum(isinstance(node, DECISIONS) for node in ast.walk(func))
tree = ast.parse(SOURCE)
for func in tree.body:
lines = func.end_lineno - func.lineno + 1
print(f"{func.name}: {lines} lines, complexity {complexity(func)}")process_order: 14 lines, complexity 7
Seven independent paths in sixteen lines is already a lot, and the full 180-line function scored 31. Lena's rule of thumb for Ridgeline is that a new function should rarely score above 5 and never above 10 without a good reason written in a comment.
Try it yourself
Count the decisions
Write complexity(source) that takes the source code of a single function as a string, parses it with ast.parse, and returns 1 plus the number of ast.If, ast.For, ast.While, ast.BoolOp and ast.ExceptHandler nodes anywhere inside it.
An elif is stored as an ast.If inside the first one, so it counts automatically. The starter counts only if statements at the top level of the function.
import ast
def complexity(source):
func = ast.parse(source).body[0]
return 1 + sum(isinstance(node, ast.If) for node in func.body)
print(complexity("def f(x):\n if x:\n return 1\n return 0"))Technical debt
Ward Cunningham compared shortcuts in code to borrowing money. A quick hack lets you ship sooner, which can be the right business decision, but you pay interest on it every time you work near that code, and the interest grows until you pay back the principal by cleaning it up. Ridgeline's process_order is a loan taken out one small feature at a time: none of the changes was unreasonable, but nobody ever paid the debt down.
The useful lesson is that debt should be taken on deliberately and tracked. When Jordan adds a quick fix before a weekend sale, he now writes a short note in the issue tracker saying what was skipped and why, so the team can schedule the clean-up. Martin Fowler's technical debt quadrant separates debt that is deliberate and prudent (we know this is rough, and we will fix it after launch) from debt that is reckless or inadvertent (we did not know any better). The second kind is the one good design habits prevent.
Worked problem
Finding the reasons to change
Lena asks Jordan to list who might ask for a change to process_order, as a way of seeing how many jobs it does.
Show solution
- Rosa sets wholesale terms and seasonal promotions: discount rules.
- Theo changes carriers and shipping rates: the shipping rule.
- Mei runs the loyalty program: how points are earned.
- Anita needs totals rounded and reported the way the accountant wants: money handling.
- The web shop team changes how confirmations look: output and email.
- Five different people, five different reasons to change, all landing in one function. Each reason should get its own piece of code.
Answer: process_order has at least five reasons to change, so it should be split into at least five cohesive pieces.
Design is a continuous activity
It is tempting to think of design as something done once, at the start, in diagrams. In practice the design of a long-lived program is shaped every day by small decisions: where to put a new function, whether to add a parameter or a new class, whether to copy a block or extract it. Big up-front design often guesses wrong about which changes will come; no design at all leads to process_order. Most teams aim between the two: enough design at the start to choose a sensible structure, then continuous small improvements, called refactoring, as the real requirements appear.
Refactoring means changing the structure of code without changing what it does. It is safe only when tests confirm that the behavior stayed the same, which is why PYTH-230's testing habits come before this course. The rest of this course gives you the vocabulary and the tools: principles for judging a design (lesson 2), choices between functions and objects (lesson 3), the classic design patterns (lessons 4 to 7), architecture for whole applications (lesson 8), techniques for improving legacy code (lesson 9), designing interfaces others will use (lesson 10), and recording decisions (lesson 11). In the capstone you will redesign Ridgeline's order system using all of them.
Common mistakes
MistakeTreating design as a one-time phase done before any code is written.
InsteadDesign continuously: let each change leave the code a little easier to change.
MistakeAdding layers, interfaces and patterns for changes that may never come.
InsteadFollow YAGNI: add flexibility when a real need appears, and keep the code easy to reshape.
MistakeCopying a business rule such as a discount rate into several functions.
InsteadGive each piece of knowledge one home, such as a named constant or a single function.
MistakeJudging design by how clever or short the code is.
InsteadJudge it by how cheap and safe the likely changes are, and how clearly the code reveals its intent.
MistakeTurning a metric such as complexity into a target and gaming it.
InsteadUse metrics as warning lights that prompt a closer look, not as goals.
MistakeRefactoring without tests.
InsteadWrite or check tests first, so you know the behavior stayed the same.
Summary and key terms
- Software design is how code is divided into pieces and how those pieces depend on each other.
- A design is good when the changes people actually need are cheap and safe.
- Rigidity, fragility, immobility and viscosity are the main symptoms of a decaying design; code smells point to where to look.
- KISS, DRY, YAGNI and separation of concerns are short principles that keep change cheap.
- Kent Beck's rules of simple design: passes the tests, reveals intention, no duplication, fewest elements.
- Aim for high cohesion inside each piece and low coupling between pieces.
- Metrics such as function length and cyclomatic complexity are warning lights, not targets.
- Technical debt is the growing cost of shortcuts; take it on deliberately and pay it back.
- software design
- The decisions about how a program is divided into parts and how the parts interact.
- code smell
- A surface sign in code that often indicates a deeper design problem.
- rigidity
- A design in which one change forces many others.
- fragility
- A design in which changes break unrelated parts.
- DRY
- Don't repeat yourself: each piece of knowledge has one authoritative place.
- YAGNI
- You aren't gonna need it: do not build for imagined future needs.
- cohesion
- How closely the parts of one module or class belong together.
- coupling
- How much one module depends on the details of another.
- cyclomatic complexity
- The number of independent paths through a piece of code.
- technical debt
- The extra future cost created by shortcuts in design or code.
- refactoring
- Changing the structure of code without changing its behavior.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Give a one-sentence definition of good design.
Show answer
A design in which the changes people actually need to make are cheap and safe.
A fix to the discount code breaks the loyalty points. Which symptom is this?
Show answer
Fragility: a change broke something in an unrelated place.
Why can YAGNI and DRY sometimes pull in different directions?
Show answer
Removing duplication can mean adding a new abstraction, and YAGNI warns against abstractions that are not yet needed; you weigh which risk is real.
What is the cyclomatic complexity of a function with one for loop and two if statements and nothing else?
Show answer
1 + 3 = 4.
What does "make the change easy, then make the easy change" mean?
Show answer
First refactor the code so the needed change becomes simple, then make that simple change.
When is taking on technical debt reasonable?
Show answer
When it is deliberate, the business benefit is clear, and the clean-up is recorded and scheduled.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.