--- title: "Clean Code & Design Patterns: Writing Code Other People (and Future You) Can Read" author: "Pradeep Kumar" published: "2026-11-03T10:25:35.713Z" updated: "2026-11-03T10:25:35.713Z" url: "https://zyvop.com/clean-code-design-patterns-writing-code-other-people-and-future-you-can-read-nydd6" canonical: "https://zyvop.com/clean-code-design-patterns-writing-code-other-people-and-future-you-can-read-nydd6" tags: ["Design Patterns","clean code","Refactoring","AI Engineer","SOLID"] --- # Clean Code & Design Patterns: Writing Code Other People (and Future You) Can Read Write code other people can read and change: naming, small functions, safe refactoring, SOLID without the dogma, and the design patterns that actually show up in AI apps. ![Cover image]() 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 | | --- | --- | | `d`, `data2`, `tmp` | `reviews`, `cleaned_reviews` | | `process(x)` | `classify_review(text)` | | `flag` | `lowercase`, `is_streaming` | | `timeout` | `timeout_seconds` | | `check(user)` | `is_allowed(user)` | | `lst` | `prompts` | 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_case` for variables and functions, `PascalCase` for classes, `UPPER_CASE` for constants. - **Don't lie.** A function called `get_user` that 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: ```python 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: ```python 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 40 ``` Handle 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: ```python # add one to the counter counter += 1 ``` A good comment says something the code _can't_: the reason behind a decision, a trap to avoid, or a link to the issue: ```python # The API rejects batches over 100 items, so we send them in groups. BATCH_SIZE = 100 ``` A 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 > 3` becomes `if 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: ```python 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 answer ``` `build_prompt` and `parse_answer` are pure, so you can test them with plain assertions and no model: ```python 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: ```python 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 `black` and the formatter built into `ruff`. - A **linter** flags likely bugs and bad habits: unused variables, missing imports, risky patterns. `ruff` is a fast, popular one. - A **type checker**, like `mypy` or `pyright`, 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: 1. **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**. 2. **Take small steps.** Rename something. Run the tests. Extract a function. Run the tests. 3. **Commit after each step** (Post 22), so you can always go back. 4. **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: ```python 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, c ``` Let'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=True` mean? You have to read the body to find out. - **Deep nesting.** Four levels of `if` before 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** `try` **wrapped 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: ```python 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: ```typescript ([('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: ```python 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: ```python 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/elif` **chain** 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: ```python 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: ```python 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")) # 3 ``` Adding `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: ```python 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 answer ``` `OfflineModel` _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: ```python 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: ```python 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): ```python 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 ``` A 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: ```python 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: ```python 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 ``` Because 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`, or `Literal` (Post 10). With `Enum`, 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/except` that 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`: ```python 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: ```yaml 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: - `Summarizer` **knows nothing about providers.** It needs something with `generate`, and a function that splits text. That's dependency injection plus the strategy pattern. - **Switching provider took one name.** The second summarizer uses `provider_b` and a different chunking strategy, and no code in `Summarizer` changed. - **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.** `FlakyModel` lets 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 1. Add a `LoggingModel` wrapper that prints each prompt and response, and stack it with the others. 2. Write tests for `CachedModel`, `RetryingModel`, and both chunkers, using the techniques from Post 13. 3. Add a third provider with yet another response shape, and register it. 4. Replace the `"user"`/`"assistant"` role strings you've used in earlier posts with an `Enum`. 5. Add a `max_chars` safety check so `chunk_by_sentences` handles a single sentence that's longer than the limit. Decide for yourself what it should do, and write a test. 6. 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. 1. 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. 2. Write a function with four nested `if` statements, then rewrite it with guard clauses. Keep its behavior exactly the same, and prove it with a handful of test inputs. 3. Take a function over 30 lines from one of your projects. Write characterization tests first, then split it into smaller functions. 4. Replace an `if/elif` chain in your own code with a dictionary or a registry. 5. Write an adapter for an API response shape that you invent. Then write a test that uses it through the `generate` interface. 6. Rewrite a class that creates its own dependency so that it receives it instead. Then test it with a fake. 7. Review this smell list against a project of yours, and pick the three worst offenders. 8. 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.