Software design
Modules, interfaces and dependencies that keep big programs changeable.
Create a free account Try part of it here
Why it matters
Small scripts can be written any way that works. Programs that grow, that teams share, and that must keep changing need design: decisions about how the parts fit together. Good design is what lets you swap one model provider for another, add a new data source, or test a feature without the network. AI systems change fast, so the designs that let parts be replaced pay off quickly.
What you'll be able to do
- Judge a design by coupling and cohesion, and split code into layers
- Depend on interfaces (what something does) rather than concrete classes
- Use the strategy, adapter and repository patterns where they help
- Build a small system whose parts can be swapped and tested in isolation
What it covers
- What design is for
- Depend on what things do, not what they are
- Composition over inheritance
- Three patterns worth knowing
- An adapter around a vendor SDK
- Keep it simple
- When not to use a pattern
What design is for
A lamp with a plug can move to any room. A lamp wired into the wall can't, at least not without an electrician. Code is designed well if it's easy to change, and two words capture most of it:
- Coupling: how much one part depends on the details of another. Low coupling is the plug: you can change a part without breaking others.
- Cohesion: how closely the things inside one part belong together. High cohesion is a toolbox that holds only tools: each module has one clear job.
Aim for low coupling and high cohesion. A module called utils.py holding date helpers, email code and pricing rules has low cohesion. It's a junk drawer. A pricing function that reaches into the database driver directly has high coupling.
Layers
In a restaurant, the waiter talks to guests, the chef decides how dishes are made, and the storeroom holds the food. Most applications split into three layers in the same way, each talking only to the one below:
| layer | job | example |
|---|---|---|
| interface | talk to the outside world | a web route, a command-line parser, a chat UI |
| service (domain) | the business rules | "a refund is allowed within 30 days" |
| data | store and fetch | SQL queries, files, a call to another API |
The rules in the middle shouldn't know whether a request came from a web page or a terminal, or whether data lives in SQLite or a cloud database. That's what lets you change either edge.
Each layer only talks to the one below, so you can change either edge without touching the rules.
Show the answer
Low cohesion: it mixes business rules, presentation and data access
Three jobs from three layers in one place. Changing the email design or the database now risks breaking the points rule.
Depend on what things do, not what they are
Any appliance with the right plug works in a wall socket. The socket doesn't care whether it's a lamp or a toaster. The best tool against coupling is an interface, which works like that socket: a promise about what an object can do, without saying how. In Python you can write one as a Protocol:
from typing import Protocol class Model(Protocol): def complete(self, prompt: str) -> str: ... class EchoModel: def complete(self, prompt: str) -> str: return f"echo: {prompt}" class ShoutModel: def complete(self, prompt: str) -> str: return prompt.upper() def tagline(model: Model, product: str) -> str: return model.complete(f"Write a tagline for {product}") for m in [EchoModel(), ShoutModel()]: print(tagline(m, "the River tent"))
tagline works with anything that has a complete method. Neither model class mentions Model at all. Python's motto for this is duck typing: if it walks like a duck and quacks like a duck, treat it as a duck. The Protocol adds a written contract that type checkers can verify.
Giving a function or class the objects it needs, instead of letting it build them itself, is dependency injection. A cook is handed the ingredients, and doesn't have to run the farm. You met it in the testing lesson. In design it's what makes parts swappable: production passes the real Claude client, tests pass a fake, and next year you can pass a different provider.
In plain words: write code against "something that can do this", and you can swap what that something is.
class Fake: def complete(self, prompt): return "ok" def run(model): return model.complete("hi") print(run(Fake()))
Show the answer
ok
run calls complete on whatever it's given, and Fake's complete returns "ok", so it prints ok. run only needs something with a complete method, so a test can pass in a fake.
Composition over inheritance
A car has an engine. It isn't a kind of engine, and a mechanic can replace the engine without redesigning the car. Inheritance (class Child(Parent)) shares code, but it couples the child to every detail of the parent. Long chains of subclasses get fragile. Usually it's better to compose: give an object the parts it needs as attributes. "A report service has a model and has a storage", rather than "a report service is a kind of model".
Three patterns worth knowing
A design pattern is a named, reusable solution to a common design problem, like "dovetail joint" in woodworking. Three come up all the time.
Strategy. A navigation app lets you pick "fastest" or "no tolls", and the rest of the trip works the same. Pass the part that varies as a function or object. Sorting with key= is a strategy. So is passing a scoring rule into a ranking function, or a retry policy into an API client.
Adapter. A travel plug lets your charger work in another country's socket. An adapter is a wrapper that makes an object you can't change fit the interface your code expects. A vendor's SDK has its own method names, so you wrap it in a small class with your method names. Then the rest of your code never learns the vendor's API, and switching vendors means writing one new adapter.
Repository. Think of a records clerk: you ask for a file, and you don't need to know how the archive is arranged. A repository is one class that owns all storage for a kind of record, with methods such as get(id), add(order) and list_overdue(). The service layer calls these and never sees SQL. Tests can pass an in-memory repository that's a dict inside.
An adapter around a vendor SDK
Your code wants send(to, message). The SMS vendor's client has a different method with extra arguments. The adapter translates.
class VendorSmsClient: # imagine this comes from a library you can't edit def transmit(self, number, body, priority): print(f"[vendor] to={number} priority={priority} body={body!r}") class SmsAdapter: MAX_LEN = 160 def __init__(self, client): self.client = client def send(self, to, message): self.client.transmit(to, message[: self.MAX_LEN], priority="normal") class ConsoleNotifier: def send(self, to, message): print(f"[console] {to}: {message}") def notify_all(notifiers, to, message): for n in notifiers: n.send(to, message) notify_all([SmsAdapter(VendorSmsClient()), ConsoleNotifier()], "+15550100", "Your order shipped")
notify_all knows one method, send, and works with both. The vendor's quirks (the method name, the priority argument, the 160-character limit) live in one small class. If you switch SMS vendors, only the adapter changes.
Show the answer
Code against your own small interface, with one adapter per provider
With an adapter per provider, switching means writing (or choosing) one new adapter. The rest of the app never knew which SDK it was using.
Keep it simple
Design can be overdone. Nobody builds a six-car garage for one car. Two warnings balance everything above:
- YAGNI, "you aren't gonna need it": don't build flexibility for changes nobody has asked for. An interface with one implementation and no tests using a fake may be pure overhead.
- KISS, "keep it simple": the best design is the simplest one that handles today's needs and doesn't block tomorrow's.
A good rule: add an interface when you have a second implementation, including a test fake. The test fake usually comes first.
Show the answer
Write a plain CSV reader now, and add an abstraction when a second format really arrives
Abstractions built for imagined needs usually guess wrong. With two real cases, the right shape is easier to see.
When not to use a pattern
Learning design patterns tends to make people want to use them. Someone with a new hammer starts seeing nails everywhere. Resist that. A pattern is a name for a solution to a specific problem, not a goal in itself.
An interface with one implementation, added "in case we need another", is a cost today for a benefit that may never come. Every layer is one more thing to read. Add the abstraction when the second real need appears. That's often sooner than you'd expect, and it's easy to add once your code is tested.
The opposite mistake is harder to undo. If business rules call the database or the web framework directly, then testing them needs a database, and changing frameworks means rewriting the rules. Keeping rules separate from those edges is worth doing from day one, because it costs almost nothing up front.
Inheritance deserves extra caution. A subclass depends on the details of its parent, so changing the parent can break it from far away. Passing the behavior in as an object couples far less.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
What does "low coupling" mean?