📒 problems 📖 glossary
Mock interview
46:30
ready
of 46:30
Lesson 99 · Engineering craft

Software design & clean code

This is the capstone of the phase, so it is worth stating the thesis plainly before anything else. Almost no professional code is written once and left alone. It is read, extended, debugged, and changed for years — usually by people who were not in the room when it was written, including future you, who has forgotten every assumption you held in your head today. A line you write this afternoon may be read a hundred times and edited a dozen times over its life. That ratio is the single most important fact in this lesson, because it tells you what to optimize for.

The naive measure of code is "does it work." But that bar is far too low: clever-but-tangled code works too, right up until someone has to change it. The real measure is how cheaply can a human safely change it. Break that into its two words. Cheaply means a small change requires a small, local edit — not a week of archaeology to understand what you are touching. Safely means you can make that change with confidence you did not silently break something three modules away. Everything below — naming, coupling, SOLID, patterns, refactoring, technical debt, review — is in service of that one sentence. Whenever a guideline below feels abstract, translate it back into "does this make the next change cheaper and safer?" That question is the whole subject.

What "maintainable" actually means

"Maintainable" is the word people throw around without defining. It is really three concrete, measurable properties. Learn to see each one as a thing you can point at in real code.

The three properties, defined
  • Readability. Code is read far more than it is written, so you optimize for the reader, not the typist. Readable code uses clear names, small functions, and obvious control flow, so the next person grasps what it does and why without reverse-engineering it line by line. A good test: can a teammate skim a function and predict what it does before reading the body?
  • Low coupling. Coupling is how much one piece of code depends on the internals of another. Low coupling means modules talk to each other through narrow, stable interfaces and know as little as possible about each other's guts. The payoff: when you change one thing, you do not have to touch ten others, because the others never reached into the part you changed.
  • High cohesion. Cohesion is how related the things inside one module are. High cohesion means everything in a module is about one well-defined job — the pieces all change together for the same reason. The opposite is a grab-bag "utils" file where unrelated functions pile up because nobody knew where else to put them.

Those two structural words — coupling and cohesion — deserve a concrete picture, because they are the backbone of every other idea in this lesson. Here is a tangled module: one function reaches across responsibilities and into another module's database directly.

# TANGLED: one function does parsing, business rules, DB access, and email.
# It is low-cohesion (four unrelated jobs) and high-coupling
# (it reaches straight into the orders table and the SMTP server).
def handle_signup(raw):
    parts = raw.split(",")                # parsing
    email = parts[0].strip().lower()
    if "@" not in email:             # validation rule
        return "bad email"
    db.execute(                          # direct DB access
        "INSERT INTO users(email) VALUES (?)", [email])
    smtp.send(email, "Welcome!")         # direct email send
    return "ok"

Four different forces can demand a change to that one function: a new input format, a new validation rule, a database migration, a different email provider. Each force makes you re-read and re-test all four jobs, and a typo in the SQL can break signups while you were only trying to tweak the welcome text. Now the focused version — each job is its own cohesive piece, and handle_signup only coordinates:

# FOCUSED: each piece has one job; the coordinator just wires them.
def parse_signup(raw):
    return raw.split(",")[0].strip().lower()

def is_valid_email(email):
    return "@" in email

def handle_signup(raw, users, mailer):
    email = parse_signup(raw)
    if not is_valid_email(email):
        return "bad email"
    users.add(email)         # a repository hides the SQL
    mailer.welcome(email)    # a mailer hides the provider
    return "ok"

A database migration now touches only the users repository; a new email provider touches only the mailer; a format change touches only parse_signup. Each change stays local — that is low coupling and high cohesion working together. They are the whole game in four words: they are exactly what lets you reason about one part of a system without holding the entire system in your head at once.

SOLID — as heuristics, not commandments

SOLID is five principles for keeping object-oriented code flexible, coined by Robert Martin. Treat them as smells-to-watch-for and tools to reach for, not rules to obey religiously — dogmatic SOLID produces its own kind of over-engineered mess, with a dozen tiny interfaces for code that never needed them. The value is in recognizing the problem each one names. Two of the five pay off far more than the rest, so we give those a full before/after.

Single Responsibility (the highest-value one)

A class or module should have one reason to change — meaning one stakeholder, one axis of change, one job. If several unrelated forces can each demand an edit, you have several responsibilities crammed together, and every change risks disturbing the others. This is just "high cohesion" stated as a design rule. Before: a User class that holds data, formats a report, and persists itself.

# BEFORE: three reasons to change live in one class.
class User:
    def __init__(self, name): self.name = name
    def to_html(self):   ...   # presentation changes
    def save(self):      ...   # storage changes
# AFTER: one job each. A presentation change cannot break storage.
class User:
    def __init__(self, name): self.name = name

class UserView:        # presentation lives here
    def to_html(self, user): ...

class UserRepository:  # storage lives here
    def save(self, user):    ...

After the split, a change to how users are rendered touches only UserView, and a change to the database touches only UserRepository. Each change stays local and testable in isolation.

Dependency Inversion (the other highest-value one)

Depend on abstractions, not concretes. High-level policy code should not nail itself to a specific low-level implementation; both should meet at an interface in the middle. Concretely: code against a PaymentGateway interface, not StripeClient directly. Before, the order service is welded to Stripe:

# BEFORE: OrderService is hard-wired to a concrete class.
class OrderService:
    def __init__(self):
        self.gateway = StripeClient(api_key=...)   # welded to Stripe
    def checkout(self, amount):
        self.gateway.charge(amount)
# AFTER: depend on an abstraction; inject the concrete one.
class PaymentGateway:           # the abstraction (interface)
    def charge(self, amount): ...

class OrderService:
    def __init__(self, gateway: PaymentGateway):
        self.gateway = gateway        # injected, not constructed
    def checkout(self, amount):
        self.gateway.charge(amount)

# Production wires in the real one; tests wire in a fake.
service = OrderService(StripeGateway(api_key=...))
test    = OrderService(FakeGateway())   # records charges, never bills a card

Now you can swap Stripe for another provider, or for a fake in tests, without rewriting a single line of OrderService. That ability to inject a fake is also what makes the code testable — you will see why that matters in the refactoring section, where tests are the safety net. Passing the dependency in from outside rather than building it inside is called dependency injection, and it is the everyday mechanic that makes dependency inversion real.

The remaining three, one clear line and a mini example each:

  • Open/Closed — code should be open to extension but closed to modification: add new behavior by adding new code, not by editing battle-tested old code. Example: to support a new shape's area, add a Triangle class implementing an area() method rather than adding another branch to a giant if shape == ... in the existing calculator.
  • Liskov Substitution — a subtype must be usable anywhere its base type is, with no nasty surprises. Example: if Square subclasses Rectangle but secretly forces width to equal height, code that sets width and expects height unchanged breaks — so that "is-a" relationship lies and violates Liskov.
  • Interface Segregation — many small, focused interfaces beat one fat one, so clients are not forced to depend on methods they never call. Example: a read-only report viewer should depend on a Readable interface, not on a giant Document interface that also demands save() and delete().

For the interview-shaped version of all this — applying these principles live in an object-oriented / low-level design round — see OOD / LLD.

Design patterns that earn their keep

A design pattern is a named, reusable solution to a recurring design problem. Patterns are valuable because they give a name to a shape you would otherwise reinvent, and they give teammates shared vocabulary ("let's make that a strategy"). The trap is treating them as vocabulary to sprinkle around to look sophisticated. Learn each one by the problem it solves, and reach for it only when you actually have that problem. Four that genuinely pull their weight, taught by their problem:

Strategy — interchangeable algorithms

The problem: you have several interchangeable ways to do one thing (sort orders, pricing rules, retry policies, shipping calculators) chosen at runtime, and they have piled up into a sprawling if/elif chain that you must edit every time a new option appears. The fix: wrap each algorithm behind a common interface, then pass in whichever one you want.

# BEFORE: an if/elif chain that grows every time a method is added.
def cost(order, method):
    if method == "standard": return 5.0
    elif method == "express": return 12.0
    elif method == "drone":   return 25.0 + order.weight
    ...                          # edit this function forever

# AFTER: each strategy is interchangeable behind one method.
class Standard: def cost(self, o): return 5.0
class Express:  def cost(self, o): return 12.0
class Drone:    def cost(self, o): return 25.0 + o.weight

def cost(order, strategy):
    return strategy.cost(order)   # add a new option without editing this

Adding "freight" now means adding one small class, not editing a function everyone depends on — that is the open/closed principle paying off in practice.

Adapter — wrap a mismatched API

The problem: a third-party or legacy library does the right thing but exposes the wrong shape — its method names and arguments do not match the interface your code expects. The fix: write a thin adapter that implements your interface and translates the calls, so the ugly mapping lives in exactly one place.

# Your code expects gateway.charge(amount).
# The vendor SDK only offers makePayment(cents, currency).
class LegacyPayAdapter:          # implements YOUR interface
    def __init__(self, sdk): self.sdk = sdk
    def charge(self, amount):       # translate to theirs
        self.sdk.makePayment(int(amount * 100), "USD")

The rest of your code keeps calling charge() and never learns about cents or currency strings. Swap the vendor later and only the adapter changes.

Observer — notify many on change

The problem: when one thing changes, several others need to react — a UI repaints, a log gets written, a cache invalidates — and you do not want the changing object to know about each listener by name. The fix: the subject keeps a list of subscribers and notifies them all when its state changes; listeners come and go without the subject caring.

class Order:
    def __init__(self): self._subs = []
    def subscribe(self, fn): self._subs.append(fn)
    def ship(self):
        ...
        for fn in self._subs:   # notify everyone who cares
            fn(self)

order.subscribe(send_email)   # add listeners without touching Order
order.subscribe(update_dashboard)

This is the engine behind UI event handlers and pub/sub systems: the source of an event is decoupled from everyone who reacts to it.

Factory — centralize creation

The problem: deciding which concrete object to build is non-trivial or depends on runtime data, and that decision is duplicated across many callers. The fix: put the "how to build it" logic in one factory function or class; callers just say what they need.

def make_gateway(name):        # one place decides the concrete type
    if name == "stripe": return StripeGateway(api_key=...)
    if name == "paypal": return PayPalGateway(token=...)
    raise ValueError(name)

gateway = make_gateway(config.provider)  # callers stay ignorant of construction
Do not force patterns

The defining anti-pattern is reaching for a pattern where a plain function would do. A single strategy that will never have a second implementation is just a function wrapped in ceremony; a factory for an object you only ever build one way is pure overhead. The pattern should fall out of the problem once you have it, not be imposed in anticipation of a problem you may never get. When in doubt, write the simple thing; refactor to the pattern the moment a second case proves you need it.

Refactoring is a habit, not a project

Refactoring means changing the shape of code without changing its behavior — same inputs, same outputs, cleaner internals. That definition is exact and important: renaming a variable, extracting a function, or splitting a class is refactoring; adding a feature or fixing a bug is not. Keeping the two activities separate is what makes both safe, because a pure refactor has a clear correctness test: the behavior must not move.

Which is exactly why tests are the safety net. The only way to confidently change shape while preserving behavior is to have a suite that fails the instant behavior drifts. You refactor in tiny steps and re-run the tests after each one; a green bar means you only changed the shape. Without that net, "refactoring" is really just "rewriting and hoping." This is also why testable, dependency-injected code from earlier matters so much — it is what lets the net exist. For how to build that net, see Testing.

Here is a small, concrete refactor — extracting a confusing condition into a named helper, behavior unchanged:

# BEFORE: the reader must decode the condition.
if user.age >= 18 and user.country in ALLOWED and not user.banned:
    grant_access(user)

# AFTER: a name explains the intent; the condition is reused and testable.
def is_eligible(user):
    return user.age >= 18 and user.country in ALLOWED and not user.banned

if is_eligible(user):
    grant_access(user)

The boy-scout rule captures the habit in one line: leave the code a little cleaner than you found it. Rename one confusing variable, extract one overgrown function, delete one dead branch every time you pass through a file for some other reason. The cumulative effect is enormous, and it requires no permission or planning. A codebase decays only when everybody assumes cleanup is somebody else's separate ticket — and that ticket never comes.

Technical debt — a real tool, not just a mess

Technical debt is the implied future cost of choosing a quick solution now over a better one. The financial metaphor is precise and worth taking seriously. The principal is the shortcut you took; the interest is the extra effort every future change pays because of it — slower edits, more bugs, more time spent understanding the hack. Like financial debt, it is not inherently bad. Sometimes borrowing is exactly the right move.

Two kinds of debt
  • Deliberate debt. A conscious trade: "we will hardcode this config to ship the demo Friday, and clean it up next sprint." You know you are borrowing and roughly what it costs. This can be entirely correct — shipping to learn from real users is often worth more than a perfect internal structure no customer sees.
  • Accidental debt. Debt nobody chose — sloppiness, a misunderstanding of the domain, or a design that was fine until requirements changed underneath it. No one named it, so no one is tracking the interest, and it silently compounds.

The danger is letting interest compound unpaid until every change becomes slow and scary, and the team spends more energy fighting the codebase than building in it. The senior move is to treat debt like a loan you manage rather than a mess you ignore: take it on consciously, record what you cut and why (a comment, a ticket, a note in the PR), and schedule the payback before the interest swamps you. Naming debt out loud is most of the battle — undocumented debt is the kind that kills teams.

Naming & the cost of cleverness

Naming is one of the hardest and highest-leverage things you do, because a good name eliminates the need for a comment and lets a reader understand code at a glance. Prefer names that reveal intent over names that are short. Compare:

# CRYPTIC: every reader must reverse-engineer the meaning.
def f(d, t):
    return [x for x in d if x.ts > t]

# CLEAR: the names tell you what it does without a comment.
def events_after(events, cutoff):
    return [e for e in events if e.timestamp > cutoff]

The two functions do the identical thing; one costs the reader ten seconds of decoding on every visit, the other costs nothing. Resist cleverness in the same spirit. A dense one-liner that compresses three ideas into an unreadable expression is a liability, not a flex — you save four lines today and tax every future reader forever. As Martin Fowler put it: "Any fool can write code that a computer can understand. Good programmers write code that humans can understand." Boring, obvious code is a feature, not a failure of imagination.

Be a good code-review author

The other side of Code review round is making your own work easy to review — a craft skill in its own right, and one that directly affects how fast your changes ship. Two habits do most of the work:

  • Keep PRs small. A focused 50-line change gets a careful, genuine review; a 2,000-line dump gets a rubber stamp because no human can hold it all in their head. Small PRs also revert cleanly, bisect cleanly, and merge before they rot against everyone else's changes. If a change is unavoidably large, split it into a stack of reviewable steps.
  • Give context in the description. Tell the reviewer what problem this solves, why you chose this approach, what alternatives you considered and rejected, and how you tested it. Pre-empt the obvious questions inline with a quick comment on the surprising line. The reviewer's attention is the scarce resource; the more you spend respecting it, the faster and better the review you get back.
That closes the Engineering craft phase. You have gone from "pass the interview" to the actual job: shipping safely, operating real systems, and writing code your teammates — and future you — can change cheaply and safely. The last thing that separates good engineers from great ones is not technical at all; it is how you work with people. Head into the behavioral section next to turn everything you have built here into stories you can tell.
Go deeper (optional): A Philosophy of Software Design (John Ousterhout) on deep modules and managing complexity; Martin Fowler's Refactoring for the named, step-by-step catalog of safe transformations.