
Here's something nobody tells beginners. You'll spend far more time reading code than writing it. You read it to fix a bug, to add a feature, to review a teammate's pull request, or to figure out what you wrote three months ago and why. So the real measure of good code isn't how clever it is, or how few lines it uses. It's how quickly another person can understand it and change it safely.
This post is core, and it matters more than ever for AI engineers. AI apps are glue: prompts, tool calls, retries, parsing messy output, switching between providers. That kind of code turns into spaghetti faster than almost any other. And now that AI assistants write a lot of code for us, the skill of reading it critically is worth even more.
Let me say one thing up front, because it's the theme of this whole post: "clean code" rules are guides, not laws. People get dogmatic about them, and dogma produces code that's more complicated than the problem. We'll learn the principles, and we'll also learn when not to use them.
What "clean" means
Clean code is code that is:
Easy to read. A newcomer can follow what it does.
Easy to change. A new requirement touches one place, not twelve.
Hard to break. It's tested, and it doesn't surprise you.
A good test: could a teammate, or you in six months, understand this function in about a minute? If not, the code isn't done yet, even if it works.
Names carry the meaning
The cheapest way to improve code is better names. A name should tell the reader what something is or does, so they don't have to work it out from the code around it.
Unclear | Clearer |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
A few habits:
Functions are verbs, and things are nouns.
build_prompt,parse_answer,Conversation.Booleans read like questions.
is_valid,has_tokens,should_retry.Put units in names.
timeout_seconds,max_tokens,size_mb. It prevents a lot of silly bugs.Longer scope, longer name. A loop counter can be
i. A function used across the project deserves a descriptive name.Follow Python's conventions so your code looks familiar:
snake_casefor variables and functions,PascalCasefor classes,UPPER_CASEfor constants.Don't lie. A function called
get_userthat also deletes expired sessions is a trap.
Functions: small, focused, honest
From Post 6, a function should do one job. If you have to use "and" to describe it ("it cleans the text and calls the model and saves the result"), it's probably three functions. A few more habits that pay off quickly.
Use guard clauses instead of deep nesting. Deeply nested if statements push the real logic far to the right, and make you hold a lot in your head. Compare:
def shipping_cost_nested(order):
if order is not None:
if order["total"] > 0:
if order["country"] == "IN":
if order["total"] >= 500:
return 0
else:
return 40
else:
return 200
else:
raise ValueError("empty order")
else:
raise ValueError("no order")with this version, which does exactly the same thing:
def shipping_cost_flat(order):
if order is None:
raise ValueError("no order")
if order["total"] <= 0:
raise ValueError("empty order")
if order["country"] != "IN":
return 200
if order["total"] >= 500:
return 0
return 40Handle the bad cases first and leave early. Then the main path reads straight down the page, with no indentation staircase. (I checked that both versions agree on a range of orders, and both raise on the invalid ones.)
Avoid flag arguments. A parameter like process(data, True) makes the reader look up what True means, and usually means the function does two different things depending on the flag. Split it into two functions, or at least use a keyword argument with a clear name (lowercase=True).
Keep the number of parameters low. More than three or four is a smell. When several travel together, bundle them into a dataclass or a Pydantic model (Posts 9 and 10).
Be honest about side effects. A function that looks like a calculation shouldn't quietly change something elsewhere. Functions that just take inputs and return outputs, with no hidden effects, are called pure, and they're by far the easiest to test.
Don't mutate what you were given. If a function modifies the list you passed in, callers get nasty surprises. That's the copy trap from Post 5. Return a new value instead.
Comments explain why
A comment that repeats the code is noise:
# add one to the counter
counter += 1A good comment says something the code can't: the reason behind a decision, a trap to avoid, or a link to the issue:
# The API rejects batches over 100 items, so we send them in groups.
BATCH_SIZE = 100A rule of thumb: if you need a comment to explain what a block does, try extracting it into a function with a good name instead. Then reserve comments for the why. Two more habits:
Replace magic numbers with named constants.
if retries > 3becomesif retries > MAX_RETRIES.Keep comments up to date. A wrong comment is worse than none. When the code changes, the comment must change with it.
Docstrings (Post 6) are for public functions: a one-line summary of what it does, and anything surprising. Type hints (Post 10) double as documentation, and unlike comments, a type checker can verify them.
Duplication, and the rule of three
You've heard "DRY: don't repeat yourself." Copy-pasted code is risky, because when you fix a bug in one copy, you forget the others. But there's an opposite mistake, and it's just as common: merging two things that only look similar. The shared function then grows flags and special cases until it's harder to read than the duplication was.
A useful rule is the rule of three: the first time, just write it. The second time, you can copy it, and note the resemblance. By the third time, you understand the pattern, so extract it. A little duplication is cheaper than the wrong abstraction.
Keep logic pure, push the messy stuff to the edges
This is the most valuable structural habit for AI apps. Code that talks to the outside world, like a model call, the network, the disk, or the clock, is slow, costs money, and gives different answers every time. Code that just transforms data is fast and predictable.
So separate them. Put the logic in pure functions: building prompts, parsing replies, validating, formatting. Then keep the actual model call in one thin layer at the edge. Look at how this changes a bot:
import json
def build_prompt(question):
return f"Answer in JSON with key 'answer'. Question: {question}"
def parse_answer(raw):
try:
return json.loads(raw)["answer"]
except (json.JSONDecodeError, KeyError, TypeError):
return raw
class Bot:
def __init__(self, model, log):
self.model = model
self.log = log
def run(self, question):
answer = parse_answer(self.model.generate(build_prompt(question)))
self.log(question, answer)
return answerbuild_prompt and parse_answer are pure, so you can test them with plain assertions and no model:
assert parse_answer('{"answer": "yes"}') == "yes"
assert parse_answer("not json") == "not json"
assert parse_answer('{"other": 1}') == '{"other": 1}'And Bot receives its model and its logger as parameters, so in a test you can hand it a fake:
class FakeModel:
def generate(self, prompt):
return '{"answer": "42"}'
logged = []
bot = Bot(FakeModel(), lambda question, answer: logged.append((question, answer)))
print(bot.run("What is 6 x 7?")) # 42
print(logged) # [('What is 6 x 7?', '42')]Compare that with a single class that builds the prompt, calls a client it creates itself, parses JSON, and writes a log file inside one method. Testing it would mean running the real client and cleaning up files. That's the difference between code that's easy to test and code that isn't, and we'll come back to it under "SOLID" below.
Let tools do the boring part
Arguing about spacing and quote styles is a waste of human attention. Use tools:
A formatter rewrites your code into a consistent style automatically. Two popular choices are
blackand the formatter built intoruff.A linter flags likely bugs and bad habits: unused variables, missing imports, risky patterns.
ruffis a fast, popular one.A type checker, like
mypyorpyright, uses your type hints (Post 10) to find mistakes before you run anything.
Set them up once, run them on every commit with a tool like pre-commit (Post 22), and run them in CI (Post 28). Then code review can focus on the interesting things. (These tools aren't installed in my test environment, so I haven't run them here. Their documentation walks through setup, and it's a ten-minute job.)
Refactoring: change the structure, not the behavior
Refactoring means improving how code is organized without changing what it does. It's not rewriting, and it's not adding features. It's tidying, safely. The safe part is everything.
The rules:
Have tests first. Without them, you can't know whether you broke something. If there aren't any, write tests that capture what the code does now, even if it's imperfect. These are called characterization tests.
Take small steps. Rename something. Run the tests. Extract a function. Run the tests.
Commit after each step (Post 22), so you can always go back.
Never mix refactoring with adding features. Do one, commit, then do the other.
A worked example
Here's a function that works, and that nobody wants to touch. It takes a list of customer reviews and a model that classifies each one:
def process(d, m, f=False):
r = []
for x in d:
if x is not None:
if len(x.strip()) > 0:
if f:
t = x.strip().lower()
else:
t = x.strip()
try:
o = m(t)
if o == "pos" or o == "neg" or o == "neu":
r.append((t, o))
else:
r.append((t, "unknown"))
except Exception:
r.append((t, "error"))
c = {}
for a, b in r:
if b in c:
c[b] = c[b] + 1
else:
c[b] = 1
return r, cLet's list what's wrong, the way a reviewer would:
Names.
d,m,f,r,x,t,o,c,a,b. Nothing explains itself.A flag argument. What does
f=Truemean? You have to read the body to find out.Deep nesting. Four levels of
ifbefore anything happens.Three jobs in one function. Cleaning the input, classifying it, and counting results.
A repeated condition.
o == "pos" or o == "neg" or o == "neu".A hand-written counter, when Python has
Counter(Post 16).A
trywrapped around more than it needs to (Post 11). The only thing that can fail is the model call.
First, the safety net. Let me build a fake model to test with, and a set of awkward inputs:
def fake_model(text):
lowered = text.lower()
if "boom" in lowered:
raise RuntimeError("model failed")
if "good" in lowered:
return "pos"
if "bad" in lowered:
return "neg"
if "ok" in lowered:
return "neu"
return "meh"
sample = [" Good service ", None, " ", "BAD food", "BOOM", "it was ok", "no idea", "", "Great GOOD stuff"]The sample includes a None, blanks, whitespace, a failing input, a valid label, and an invalid label ("meh"). Run the original, and you get:
([('Good service', 'pos'), ('BAD food', 'neg'), ('BOOM', 'error'), ('it was ok', 'neu'),
('no idea', 'unknown'), ('Great GOOD stuff', 'pos')],
{'pos': 2, 'neg': 1, 'error': 1, 'neu': 1, 'unknown': 1})That's our "this is what correct looks like" snapshot. Now refactor in small steps: rename, remove the nesting with early continue, extract the classification, replace the hand-written counter, and split the three jobs. This is where I ended up:
from collections import Counter
VALID_LABELS = {"pos", "neg", "neu"}
def clean_reviews(reviews, lowercase=False):
"""Strip whitespace, drop blanks and None, and optionally lowercase."""
for review in reviews:
if review is None:
continue
text = review.strip()
if not text:
continue
yield text.lower() if lowercase else text
def classify(model, text):
"""Ask the model for a label. Bad answers become 'unknown', failures become 'error'."""
try:
label = model(text)
except Exception:
return "error"
return label if label in VALID_LABELS else "unknown"
def classify_reviews(model, reviews, lowercase=False):
return [(text, classify(model, text)) for text in clean_reviews(reviews, lowercase)]
def count_labels(results):
return dict(Counter(label for _, label in results))
def process_reviews(model, reviews, lowercase=False):
results = classify_reviews(model, reviews, lowercase)
return results, count_labels(results)Every function does one thing, with a name that says what. clean_reviews is a generator (Post 7), and uses guard clauses (continue) instead of nesting. classify wraps only the model call in try. The valid labels live in a named constant instead of a repeated condition. Counter does the counting. And the original function's job is now four small ones that are easy to test separately.
Proving it didn't change
Here's the key step: did we change the behavior? On the sample input, the new version returns exactly the same results, including with the lowercase option on. But a handful of examples isn't proof, so I also ran a differential test: generate lots of random inputs, run both versions, and check they match:
import random
random.seed(11)
words = ["good", "bad", "ok", "boom", "hmm", "Great", "BAD", " ", "", "x y"]
for _ in range(2000):
reviews = [
random.choice(words + [None]) if random.random() < 0.5
else " ".join(random.choices(words[:6], k=random.randint(1, 3)))
for _ in range(random.randint(0, 8))
]
flag = random.random() < 0.5
assert process(reviews, fake_model, flag) == process_reviews(fake_model, reviews, flag)
print("2000 random inputs, identical results")It passed on all 2,000. That's the property-based thinking from Post 13, applied to refactoring: the old function is the specification, and the new one has to agree with it. Once you trust the new version, delete the old one and keep a few of the sample cases as permanent tests.
Notice what we still haven't fixed. The lowercase flag remains a flag argument, and the label strings are still plain strings. We could make them an Enum next. Refactoring is never "finished". You stop when the code is clear enough for the people who work on it, and no further.
Common refactorings
You've just used most of them, and here's the short menu:
Rename a variable, function, or class to say what it is.
Extract function: pull a chunk of code out and give it a name.
Extract variable: give a confusing expression a name.
Replace magic numbers with constants.
Replace nested ifs with guard clauses.
Replace an
if/elifchain with a dictionary or registry (more below).Introduce a parameter object: bundle several parameters into a dataclass.
Delete dead code. Git remembers it (Post 22), so you can always get it back.
SOLID, in plain English
You'll hear five principles named SOLID whenever people talk about object-oriented design. Here's each in plain terms, with the caveat that they're heuristics, not commandments. In a 50-line script, most of them are overkill. They earn their keep as code grows and changes.
S: Single Responsibility
A piece of code should have one reason to change. Our Bot above has one job: coordinate. If the prompt wording changes, you edit build_prompt. If the reply format changes, you edit parse_answer. If logging moves to a database, you change the log function you pass in. Nothing else is touched. A class that builds prompts, calls the API, parses JSON, and writes log files has four reasons to change, and every change risks breaking the other three.
O: Open/Closed
You should be able to add new behavior without editing existing code. The classic violation is an if/elif chain that grows every time you add a case:
def tool_ifelse(name, arg):
if name == "upper":
return arg.upper()
elif name == "reverse":
return arg[::-1]
else:
raise ValueError(f"unknown tool {name}")Every new tool means editing this function. Instead, use the registry decorator from Post 8:
TOOLS = {}
def tool(func):
TOOLS[func.__name__] = func
return func
@tool
def upper(arg):
return arg.upper()
@tool
def reverse(arg):
return arg[::-1]
@tool
def count_words(arg): # new behavior, and no existing code was touched
return len(arg.split())
print(TOOLS["count_words"]("a b c")) # 3Adding count_words meant writing one new function. The dispatch code didn't change. This is exactly how the tool lists in agent frameworks work.
L: Liskov Substitution
A subclass should work anywhere its parent does, without surprises. If your code accepts a Model, any kind of Model should behave like one:
class Model:
def generate(self, prompt):
return "answer"
class OfflineModel(Model):
def generate(self, prompt):
raise RuntimeError("no network") # breaks every caller that expects an answerOfflineModel is a Model on paper, but a function that calls generate and expects an answer will blow up. That's a violation. If a subtype can't honor the contract, it probably shouldn't be a subtype. The fix is usually composition and protocols (Post 9), which describe the behavior a caller needs, not a family tree.
I: Interface Segregation
Don't force code to depend on things it doesn't use. Suppose you have a giant interface with generate, embed, fine_tune, and moderate. A summarizer that only needs generate would force every model it works with to implement all four. Keep interfaces small and focused:
from typing import Protocol
class Generator(Protocol):
def generate(self, prompt: str) -> str: ...
def summarize(model: Generator, text: str) -> str:
return model.generate(text)Now anything with a generate method works, including a three-line fake in a test.
D: Dependency Inversion (and injection)
High-level logic shouldn't decide which low-level tools it uses. It should be handed them. Compare:
class HardWired:
def __init__(self):
self.model = RealClient() # decides its own dependency: hard to test
def run(self, q):
return self.model.generate(q)
class Injected:
def __init__(self, model):
self.model = model # told its dependency: easy to swap
def run(self, q):
return self.model.generate(q)HardWired creates its own real client, so you can't test it without hitting the network. Injected takes whatever model you give it, whether that's the real one, a fake, or a cached wrapper. Passing dependencies in this way is called dependency injection, and it's the single most useful idea here. It's what made the fake models in Post 13 possible.
Design patterns you'll actually use
Design patterns are named solutions to problems that keep coming up. Most of the famous list, such as Abstract Factory and Visitor, you'll rarely meet in Python. A handful show up all the time in AI code, and Python's functions-as-values make them lighter than in other languages.
Strategy: swap the algorithm
When you can do the same job in several ways, make the "way" something you pass in. In Python, a strategy is often just a function. Here are two ways to chunk text (Post 17):
def chunk_by_words(text, size=8):
words = text.split()
return [" ".join(words[i:i + size]) for i in range(0, len(words), size)]
def chunk_by_sentences(text, max_chars=60):
sentences = [s.strip() + "." for s in text.split(".") if s.strip()]
chunks, current = [], ""
for sentence in sentences:
if current and len(current) + 1 + len(sentence) > max_chars:
chunks.append(current)
current = sentence
else:
current = f"{current} {sentence}".strip()
if current:
chunks.append(current)
return chunksA summarizer can take either one as a parameter. Change the strategy, not the summarizer.
Adapter: make different things look the same
Different model providers return differently shaped data. One gives you {"text": ...}, another buries the reply inside {"choices": [{"message": {"content": ...}}]}. (Those shapes are made up here, but real providers differ in just this way.) An adapter wraps each provider so that your code sees one consistent interface:
class ProviderAAdapter:
def __init__(self, client):
self.client = client
def generate(self, prompt):
return self.client.call(prompt)["text"]
class ProviderBAdapter:
def __init__(self, client):
self.client = client
def generate(self, prompt):
response = self.client.complete([{"role": "user", "content": prompt}])
return response["choices"][0]["message"]["content"]The messy differences live in two small classes. Everything else just calls generate(prompt). Switching providers later means writing one new adapter, not hunting through your whole codebase.
Wrapper (the decorator pattern): add behavior around something
Remember the @retry decorator from Post 8? There's an object-oriented cousin. A wrapper takes a model and returns something that looks like a model, but adds behavior, such as retries or caching:
class CachedModel:
def __init__(self, model):
self.model = model
self.cache = {}
self.hits = 0
def generate(self, prompt):
key = hashlib.sha256(prompt.encode("utf-8")).hexdigest()
if key in self.cache:
self.hits += 1
return self.cache[key]
answer = self.model.generate(prompt)
self.cache[key] = answer
return answerBecause a CachedModel has the same generate method as the thing it wraps, you can stack wrappers freely: a cache around a retrier around an adapter around a client. Each layer does one thing.
Registry (a simple factory): build by name
When config says model: provider_b, you need to turn that string into an object. A registry maps names to builders, and the decorator from Post 8 fills it in, as in the tools example above. Adding a provider means registering one more builder, and nothing else changes.
Patterns to be wary of
The Singleton (exactly one global instance) is usually a disguise for global mutable state, which makes testing hard. In Python, a module-level object already does the job, and injecting dependencies is cleaner. And remember, patterns are a vocabulary for talking about solutions. They are not a checklist. Don't build an abstract factory for a 50-line script.
Smells, and the over-engineering trap
A code smell is a sign that something deserves a closer look. It doesn't prove there's a bug. Watch for:
A function over about 30 lines, or one that does several jobs.
Deep nesting, more than two or three levels.
Long parameter lists and flag arguments.
Copy-pasted blocks.
Magic numbers and magic strings. Use constants, an
Enum, orLiteral(Post 10). WithEnum, typos fail loudly:Role("robot")raises'robot' is not a valid Role, where a plain string would sail through.A "god class" that knows and does everything.
Swallowed exceptions, like
except: pass(Post 11).Hidden global state.
Deep inheritance trees (Post 9). Prefer composition.
Cleverness: a dense one-liner that takes five minutes to decode.
The opposite failure is just as real: over-engineering. It looks like an interface with one implementation, a factory that builds one thing, five layers of indirection, and configuration for flexibility nobody asked for. The principle against it is YAGNI, "you aren't gonna need it." Build the simple thing, and refactor when real pain shows up. Abstractions should be earned by actual duplication or actual change, not predicted.
A good guide: write the simplest code that works, then clean it up when it starts to hurt. The rule of three, and your tests, tell you when.
Reading code that an AI wrote
AI assistants will write a lot of your code, and you're responsible for every line that ships. Hold it to the same standard as anyone else's code:
Read all of it. If you can't explain a line, don't ship it. Ask the assistant to explain it, then check the answer.
Run it. Assistants sometimes invent functions or options that don't exist, or use outdated library versions. Running the code, and reading the official docs, catches that.
Look for over-engineering. Assistants love adding classes, layers, and "flexibility". Ask whether a ten-line function would do.
Look for swallowed errors. A blanket
try/exceptthat returns a default makes failures invisible (Post 11).Check the dependencies. Did it quietly add a library? Is it real, and maintained (Post 12)?
Ask for tests, then read the tests, and check that they'd fail if the code were wrong.
Refactor it to your own standards, using the steps in this post.
AI is also good at helping with this post's skills: ask it to review your function for readability, suggest better names, or point out duplication. Treat its suggestions like a colleague's: useful, and still your decision.
Mini project: a provider-agnostic summarizer
Let's build one small system that uses most of the ideas: injection, adapters, wrappers, a registry, and strategies. It summarizes long text with a model, and works with any provider through one interface. Everything runs on fake providers, so there's no API key or network. Create llm_client.py:
import hashlib
import time
from dataclasses import dataclass
from typing import Callable, Protocol
class TextGenerator(Protocol):
def generate(self, prompt: str) -> str: ...
# ---- two pretend provider SDKs, with different shapes -------------------
class ProviderA:
"""A pretend SDK that returns {"text": ...}."""
def __init__(self):
self.calls = 0
def call(self, prompt):
self.calls += 1
return {"text": f"[A] {prompt[:40]}"}
class ProviderB:
"""A pretend SDK that returns a nested structure."""
def __init__(self):
self.calls = 0
def complete(self, messages):
self.calls += 1
content = messages[-1]["content"]
return {"choices": [{"message": {"content": f"[B] {content[:40]}"}}]}
# ---- adapters: make both look like a TextGenerator -----------------------
class ProviderAAdapter:
def __init__(self, client):
self.client = client
def generate(self, prompt):
return self.client.call(prompt)["text"]
class ProviderBAdapter:
def __init__(self, client):
self.client = client
def generate(self, prompt):
response = self.client.complete([{"role": "user", "content": prompt}])
return response["choices"][0]["message"]["content"]
# ---- wrappers: add behavior without changing the interface ---------------
class RetryingModel:
def __init__(self, model, attempts=3, delay_seconds=0.0):
self.model = model
self.attempts = attempts
self.delay_seconds = delay_seconds
def generate(self, prompt):
for attempt in range(1, self.attempts + 1):
try:
return self.model.generate(prompt)
except TimeoutError:
if attempt == self.attempts:
raise
time.sleep(self.delay_seconds * attempt)
class CachedModel:
def __init__(self, model):
self.model = model
self.cache = {}
self.hits = 0
def generate(self, prompt):
key = hashlib.sha256(prompt.encode("utf-8")).hexdigest()
if key in self.cache:
self.hits += 1
return self.cache[key]
answer = self.model.generate(prompt)
self.cache[key] = answer
return answer
# ---- a registry: build models by name ------------------------------------
BUILDERS = {}
def register(name):
def decorator(builder):
BUILDERS[name] = builder
return builder
return decorator
@register("provider_a")
def build_provider_a():
return ProviderAAdapter(ProviderA())
@register("provider_b")
def build_provider_b():
return ProviderBAdapter(ProviderB())
def build_model(name, retries=3, cache=True):
if name not in BUILDERS:
raise ValueError(f"Unknown model {name!r}. Known models: {sorted(BUILDERS)}")
model = RetryingModel(BUILDERS[name](), attempts=retries)
return CachedModel(model) if cache else model
# ---- strategies: interchangeable ways to split text ---------------------
def chunk_by_words(text, size=8):
words = text.split()
return [" ".join(words[i:i + size]) for i in range(0, len(words), size)]
def chunk_by_sentences(text, max_chars=60):
sentences = [s.strip() + "." for s in text.split(".") if s.strip()]
chunks, current = [], ""
for sentence in sentences:
if current and len(current) + 1 + len(sentence) > max_chars:
chunks.append(current)
current = sentence
else:
current = f"{current} {sentence}".strip()
if current:
chunks.append(current)
return chunks
# ---- the part that does the real work, free of provider details ---------
@dataclass
class Summarizer:
model: TextGenerator
chunker: Callable[[str], list[str]]
def summarize(self, text):
chunks = self.chunker(text)
partials = [self.model.generate(f"Summarize: {chunk}") for chunk in chunks]
if len(partials) == 1:
return partials[0]
return self.model.generate("Combine: " + " | ".join(partials))
class FlakyModel:
"""Fails with TimeoutError for the first few calls. Handy for tests."""
def __init__(self, failures):
self.failures = failures
self.calls = 0
def generate(self, prompt):
self.calls += 1
if self.calls <= self.failures:
raise TimeoutError("slow")
return "ok: " + prompt
if __name__ == "__main__":
text = (
"Cats sleep a lot. They nap in the sun. Dogs need walks every day. "
"Birds sing in the morning. Fish are quiet pets."
)
print("Chunks by words: ", chunk_by_words(text, size=8))
print("Chunks by sentences:", chunk_by_sentences(text, max_chars=60))
client = ProviderA()
model = CachedModel(RetryingModel(ProviderAAdapter(client)))
summarizer = Summarizer(model, chunk_by_sentences)
print("\nFirst run: ", summarizer.summarize(text))
print("Provider calls:", client.calls, "| cache hits:", model.hits)
summarizer.summarize(text)
print("After a second identical run -> provider calls:", client.calls, "| cache hits:", model.hits)
print("\nSame summarizer, different provider and chunking strategy:")
other = Summarizer(build_model("provider_b"), lambda t: chunk_by_words(t, size=10))
print(other.summarize(text))
flaky = FlakyModel(failures=2)
print("\nRetrying wrapper:", RetryingModel(flaky, attempts=3).generate("hello"), "| calls:", flaky.calls)
try:
RetryingModel(FlakyModel(failures=5), attempts=2).generate("hello")
except TimeoutError:
print("Gave up after 2 attempts, as expected")
try:
build_model("provider_c")
except ValueError as error:
print("\nError:", error)Run it, and you should see:
Chunks by words: ['Cats sleep a lot. They nap in the', 'sun. Dogs need walks every day. Birds sing', 'in the morning. Fish are quiet pets.']
Chunks by sentences: ['Cats sleep a lot. They nap in the sun.', 'Dogs need walks every day. Birds sing in the morning.', 'Fish are quiet pets.']
First run: [A] Combine: [A] Summarize: Cats sleep a lot
Provider calls: 4 | cache hits: 0
After a second identical run -> provider calls: 4 | cache hits: 4
Same summarizer, different provider and chunking strategy:
[B] Combine: [B] Summarize: Cats sleep a lot
Retrying wrapper: ok: hello | calls: 3
Gave up after 2 attempts, as expected
Error: Unknown model 'provider_c'. Known models: ['provider_a', 'provider_b'](The fake providers just echo the first 40 characters of the prompt with a tag, so the "summaries" aren't clever. That's fine. We're testing structure, not language.)
Look at what the design bought us:
Summarizerknows nothing about providers. It needs something withgenerate, and a function that splits text. That's dependency injection plus the strategy pattern.Switching provider took one name. The second summarizer uses
provider_band a different chunking strategy, and no code inSummarizerchanged.The wrappers stack. The cache sits around the retrier, which sits around the adapter, which sits around the client. On the first run, there were 4 provider calls (three chunk summaries plus the final combine). On the second identical run there were still 4, and 4 cache hits, because every prompt was answered from the cache. In real life that's saved money and time.
Testing is easy.
FlakyModellets us check the retry logic in milliseconds: it succeeded on the third call, and gave up when the attempts ran out. The same fake-model approach from Post 13 applies to everything here.Errors help. Asking for an unknown model tells you which ones exist.
Is this over-engineered for what it does? Honestly, for a single provider and a single script, yes. It earns its structure when you support several providers, want caching and retries, and write tests. That's the "start simple, and refactor when it hurts" balance. When you reach that point, you know the shape to move toward.
Make it yours
Add a
LoggingModelwrapper that prints each prompt and response, and stack it with the others.Write tests for
CachedModel,RetryingModel, and both chunkers, using the techniques from Post 13.Add a third provider with yet another response shape, and register it.
Replace the
"user"/"assistant"role strings you've used in earlier posts with anEnum.Add a
max_charssafety check sochunk_by_sentenceshandles a single sentence that's longer than the limit. Decide for yourself what it should do, and write a test.Take the retry wrapper and make it only retry the errors you choose, passed in as a parameter.
Practice
About 20 minutes of real effort on each before asking for help.
Find the oldest function you've written in this series and improve its names. Don't change anything else, and check your tests still pass.
Write a function with four nested
ifstatements, then rewrite it with guard clauses. Keep its behavior exactly the same, and prove it with a handful of test inputs.Take a function over 30 lines from one of your projects. Write characterization tests first, then split it into smaller functions.
Replace an
if/elifchain in your own code with a dictionary or a registry.Write an adapter for an API response shape that you invent. Then write a test that uses it through the
generateinterface.Rewrite a class that creates its own dependency so that it receives it instead. Then test it with a fake.
Review this smell list against a project of yours, and pick the three worst offenders.
Ask an AI assistant to write a small function, then review it using the checklist above. What would you change?
Then ask an AI tutor to review your answers, and don't let it rewrite them.
What to take away
Code is read far more than it's written. Optimize for the reader.
Good names do most of the work. Functions are small and honest, and guard clauses beat deep nesting.
Comments explain why. Wait for the third repetition before abstracting.
Keep logic pure, and push model calls and I/O to the edges, so most of your code is easy to test.
Refactor in small steps with tests as a safety net, and prove you didn't change the behavior.
SOLID is a set of heuristics: one reason to change, extend without editing, honor your contracts, keep interfaces small, and inject dependencies.
Strategy, adapter, wrapper, and registry cover a surprising amount of AI code.
Watch for smells, and also for over-engineering. Start simple, and add structure when it hurts.
Hold AI-written code to the same standard as anyone's. You're responsible for what ships.
Previous: Post 23, Linux, Shell & Docker
Next in the series: Post 25, TypeScript for AI Engineers, because most AI products have a web front end, and many agent SDKs are TypeScript-first.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!