Clean Architecture in Python: Patterns, Principles, and Pythonic Design
Section 2 of 13

Pythonic Foundations: Writing Code That Reads Like English

There's a particular kind of pain that every experienced developer knows. You're three sprints deep on a new project, you open a module you wrote six months ago, and you spend the first twenty minutes just figuring out what you were trying to do. The code works—you know it works because it's running in production—but reading it is like reading your own handwriting after a long day. Technically legible. Practically exhausting.

This section is about preventing that pain. Not through aesthetic rules or style pedantry, but through deliberate choices that reduce cognitive load for the next reader. Sometimes that reader is a colleague. More often, it's you.

The habits covered here aren't prerequisites to "real" architecture work. They are architecture work. Every naming decision you make is a design decision. Every comment you skip is a contract you've left unsigned. The codebase that survives contact with a growing team is the one where every line communicates intent clearly enough that no one has to ask.

PEP 8 as a Social Contract

Let's start with the one everyone knows and the reason almost no one fully internalizes: PEP 8 is not a style preference. It's a social contract.

Think about what happens on a team without a shared style guide. One engineer uses tabs; another uses spaces. One writes myFunction; another writes my_function. One puts the opening brace on the same line; another puts it on the next. Every new file is a minor negotiation. Every code review devolves into formatting debates that could've been resolved by a .flake8 config committed on day one.

PEP 8 exists to end that negotiation. Not because four spaces are cosmically superior to two, or because snake_case is objectively better than camelCase—it isn't, in any absolute sense. It's because the Python community agreed, and that agreement means every Python developer in the world can read your code without first reverse-engineering your personal formatting philosophy.

The PEP 8 naming conventions carry semantic weight, and it's worth internalizing them:

  • MyClass — CamelCase signals: this is a type
  • my_function() — snake_case signals: this is callable
  • MY_CONSTANT — uppercase signals: this doesn't change
  • _private — underscore prefix signals: not part of the public interface
  • __dunder__ — double underscores signal: Python magic, handle with care

When you violate these conventions, you're not just being inconsistent. You're adding a tiny cognitive tax to every reader who hits that name. They pause. They double-check. They wonder if the deviation was intentional. That pause is the enemy of flow.

The line length rule (79 characters for code, 72 for docstrings) tends to generate the most resistance, particularly from developers who've switched to wide monitors. But the rule isn't really about the monitor. It's about two files side by side, or a diff view, or reading code on a laptop on a train. It's about the fact that humans read text in chunks, and very long lines make those chunks harder to identify. Keep lines short. Your future self on a smaller screen will thank you.

One more thing about PEP 8 that often gets missed: it's a baseline, not a ceiling. PEP 8 doesn't tell you what to name your variables or how to structure your modules. It handles formatting so you can spend your mental energy on the harder problems.

Naming as Design

Here is a claim worth arguing about at your next team retrospective: naming is the single most important design skill a developer can have.

Not algorithms. Not data structures. Not architecture patterns. Naming.

Why? Because a name is the primary interface between your intent and the next reader's understanding. A function named process_data could do literally anything. A function named normalize_invoice_line_items tells you exactly what's happening, what kind of data is involved, and approximately what transformation to expect.

Let's look at some concrete contrasts:

# What does this do?
def calc(x, y, z):
    return x * (1 + y) * (1 - z)

# Now you know:
def calculate_discounted_price(
    base_price: float,
    tax_rate: float,
    discount_rate: float,
) -> float:
    return base_price * (1 + tax_rate) * (1 - discount_rate)

The second version is longer. It takes an extra two seconds to type. It saves you sixty seconds every time someone reads it—including you.

The rules for good names are simple to state and hard to practice:

Variables should be nouns. They hold things. count, user_id, pending_orders. If you're naming a boolean, make it read like a question: is_active, has_permission, can_retry. Not active, not flag, definitely not b.

Functions should be verbs. They do things. calculate_total, fetch_user, send_notification. If you can't find a good verb, that's often a signal that the function is doing too much or that its purpose isn't clear in your own head yet.

Classes should be nouns, and ideally concrete ones. InvoiceProcessor, UserRepository, OrderValidator. Avoid Manager, Handler, Helper, and Utility—these are almost always signs that you haven't figured out what the class actually is. A UserManager manages users, yes, but what does that mean? Does it create them? Update them? Delete them? All of the above? The name reveals nothing.

Module names should be short and specific. invoice.py, validators.py, repositories.py—not utils.py, not helpers.py, not misc.py. When you have a utils.py in your project, it's a junk drawer. Junk drawers grow until they become the scariest file in the repo.

Here's a practical habit worth building: if you can't name a thing in under ten seconds, that's a code smell. The difficulty naming it usually means the thing itself isn't well-defined. You're trying to find a word for something that doesn't quite have a clean identity yet. Stop and think about what it is, not what to call it.

Function Length and the Single Level of Abstraction

Ask ten developers how long a function should be and you'll get ten different answers, ranging from "fits on a screen" to "as long as it needs to be." Both are wrong in different ways.

The useful rule isn't about line count. It's about abstraction level.

A function should operate at a single level of abstraction. If a function is orchestrating high-level steps—"fetch the order, validate it, calculate the total, send the confirmation"—it should not also be parsing datetime strings or formatting currency values. Those are lower-level concerns. They belong in their own functions, called from the orchestrator.

Here's the anti-pattern:

def process_order(order_id: str) -> None:
    # Fetch order from database
    conn = psycopg2.connect(DATABASE_URL)
    cursor = conn.cursor()
    cursor.execute("SELECT * FROM orders WHERE id = %s", (order_id,))
    row = cursor.fetchone()
    
    # Validate
    if row is None:
        raise ValueError(f"Order {order_id} not found")
    if row['status'] != 'pending':
        raise ValueError("Can only process pending orders")
    
    # Calculate total
    total = 0
    for item in row['items']:
        price = item['quantity'] * item['unit_price']
        if item['discount']:
            price = price * (1 - item['discount'] / 100)
        total += price
    
    # Send confirmation
    smtp = smtplib.SMTP(SMTP_HOST)
    smtp.sendmail(...)

This function has at least four different abstraction levels crammed into one body. The reader has to context-switch between database cursor management, business validation, arithmetic, and SMTP configuration. Every time they want to understand the process_order logic, they have to wade through all of it.

Here's the same intent, decomposed properly:

def process_order(order_id: str) -> None:
    order = fetch_order(order_id)
    validate_order(order)
    total = calculate_order_total(order)
    send_order_confirmation(order, total)

Now process_order reads like a story. The reader can understand what happens at a high level in four lines. If they need to understand how the total is calculated, they can dive into calculate_order_total. If they don't, they don't have to.

graph TD
    A[process_order] --> B[fetch_order]
    A --> C[validate_order]
    A --> D[calculate_order_total]
    A --> E[send_order_confirmation]
    D --> F[apply_item_discount]
    D --> G[sum_line_items]

The Single Level of Abstraction principle is named and described clearly in Robert Martin's Clean Code, but the underlying intuition is older than the book: well-structured prose works the same way. Topic sentences give you the high-level point. Paragraphs develop one idea at a time. You don't mix strategic vision with implementation details in the same sentence.

A useful heuristic: if you find yourself writing a comment that says # Step 3: do X, that step should probably be its own function named do_x. The comment is apologizing for the structure. Fix the structure instead.

Comments That Explain Why, Not What

Every developer has written a comment like this:

# Increment i by 1
i += 1

This is a comment that insults the reader. The reader can see that i is being incremented. They don't need that explained. What they might need to know is why i needs to be incremented here, in this context, at this point in the algorithm.

The rule is simple: comments should explain intent, not mechanics. The code explains what's happening. The comments explain why.

Good comments look like this:

# Retry up to 3 times because the payment gateway occasionally returns
# spurious 503s during peak hours. Confirmed with vendor on 2023-11-14.
for attempt in range(3):
    result = charge_card(payment_details)
    if result.success:
        break

# We use a separate read replica here because the primary DB hits
# 80% CPU during reporting queries. See ADR-012 for the decision.
conn = get_read_replica_connection()

Notice what these comments contain: business context, operational knowledge, references to decisions made elsewhere. This is information that cannot be recovered from reading the code itself. A future reader—or future you—who doesn't have this context will be making decisions blind.

Dead code commented out deserves a special mention because it's endemic in codebases of all sizes:

# def old_calculate_total(items):
#     total = 0
#     for item in items:
#         total += item.price
#     return total

Delete it. That's what version control is for. Commented-out code creates noise, confuses readers ("is this intentional? Should I restore this?"), and rots in place because it never gets updated when the surrounding code changes. If you're worried you'll need it later, git log has your back.

The one place where "what" comments are appropriate is in docstrings, and even there the emphasis should be on behavior and contracts rather than implementation steps:

def normalize_phone_number(phone: str) -> str:
    """
    Return the phone number in E.164 format (+15551234567).
    
    Strips all non-digit characters, then prepends the country code if
    not already present. Assumes US (+1) if no country code is detected.
    
    Raises ValueError if the resulting number has fewer than 10 or more
    than 15 digits after normalization.
    """

This docstring tells you what the function promises (output format, assumptions, error conditions). It doesn't tell you which regex it uses internally. That's the right split.

Pythonic Idioms as Readability Tools

Here's where writing Pythonic code diverges most sharply from writing Python code that happens to compile. Python gives you a rich set of idioms that not only reduce boilerplate but communicate intent more clearly than the verbose alternatives. Using them isn't showing off. It's speaking the language fluently.

List comprehensions and generator expressions

The canonical case: you want to transform a collection. The non-Pythonic way:

squared = []
for n in numbers:
    if n % 2 == 0:
        squared.append(n ** 2)

The Pythonic way:

squared = [n ** 2 for n in numbers if n % 2 == 0]

The list comprehension is not just shorter. It's structurally different. It's a declarative expression of what you want, not an imperative sequence of how to build it. An experienced Python reader parses it faster than the loop version.

When the result is immediately fed into a function (like sum(), any(), max()), use a generator expression instead—drop the square brackets:

# List comprehension builds the whole list in memory
total = sum([order.total for order in orders])

# Generator expression computes lazily
total = sum(order.total for order in orders)

The generator version is more memory-efficient and reads just as clearly. For large datasets, this matters a lot. For small ones, it still signals to the reader that you don't need the intermediate list.

enumerate and zip

If you've ever written for i in range(len(items)), you were translating from C. Python has better:

# Non-Pythonic
for i in range(len(items)):
    print(f"{i}: {items[i]}")

# Pythonic
for i, item in enumerate(items):
    print(f"{i}: {item}")

zip is the multi-collection equivalent:

for product, price in zip(products, prices):
    print(f"{product}: ${price:.2f}")

Both idioms eliminate index arithmetic, which is a common source of off-by-one errors. Using them is a safety improvement, not just a style choice.

Tuple unpacking

Python's assignment can unpack sequences directly:

# Verbose
point = get_coordinates()
x = point[0]
y = point[1]

# Pythonic
x, y = get_coordinates()

# Extended unpacking
first, *rest = items
*most, last = items
head, *middle, tail = items

Extended unpacking (*rest) is particularly useful when you know the structure of a sequence but don't want to hard-code indices. It reads almost like English: "the first item and the rest."

defaultdict and Counter

These aren't just convenience classes. They communicate intent. When you use defaultdict(list), you're telling the reader: "I'm building a grouping—keys may or may not exist yet, and that's fine." When you use Counter, you're saying: "I'm counting occurrences of things."

from collections import defaultdict, Counter

# Grouping orders by customer
orders_by_customer = defaultdict(list)
for order in orders:
    orders_by_customer[order.customer_id].append(order)

# Counting product views
view_counts = Counter(event.product_id for event in view_events)
top_products = view_counts.most_common(10)

The alternative—checking if key in dict, initializing, then appending—buries the grouping logic under error-handling boilerplate. defaultdict lets the logic speak.

Context managers

Context managers are the Pythonic solution to "I need to ensure cleanup happens even if something goes wrong." File handling is the textbook example, but the pattern extends everywhere:

# Files
with open("data.csv") as f:
    data = f.read()

# Database connections
with get_db_connection() as conn:
    results = conn.execute(query)

# Locks
with threading.Lock():
    shared_counter += 1

The with statement isn't just syntactic sugar. It's a declaration: "whatever happens inside this block, the resource will be properly released." That's a contract expressed in two characters. Writing explicit try/finally blocks for the same purpose is not wrong—it's just noisier.

You can write your own context managers using contextlib.contextmanager, which is worth knowing for cases like temporarily changing configuration, capturing output in tests, or timing code blocks.

Magic Methods and Operator Overloading: Power With Responsibility

Python's dunder (double-underscore) methods let you define how your objects respond to built-in operations—comparison, arithmetic, iteration, truthiness, string representation. Used well, they make your objects feel like first-class citizens of the language. Used carelessly, they create subtle bugs that are a nightmare to debug.

The useful ones to know:

class Money:
    def __init__(self, amount: float, currency: str):
        self.amount = amount
        self.currency = currency
    
    def __repr__(self) -> str:
        # For developers: unambiguous, evaluable representation
        return f"Money({self.amount!r}, {self.currency!r})"
    
    def __str__(self) -> str:
        # For end users: readable representation
        return f"{self.currency} {self.amount:.2f}"
    
    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies")
        return Money(self.amount + other.amount, self.currency)
    
    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount and self.currency == other.currency
    
    def __bool__(self) -> bool:
        return self.amount != 0

A few things to notice here. __repr__ should always return something that uniquely identifies the object—ideally something you could paste back into Python. __str__ is for human consumption. If you only define one, define __repr__.

Returning NotImplemented (not raising it—returning it) from __eq__ is the correct pattern when the comparison doesn't make sense. Python will then try the comparison in reverse, giving the other object a chance to handle it.

The danger zone is arithmetic operators. Overloading + for Money makes sense—addition of monetary values is a natural, well-defined operation. Overloading * for Money * Money makes no sense and will confuse everyone who encounters it. The rule: if the operation you're overloading isn't obviously natural for your type, don't do it.

__len__, __iter__, and __contains__ are worth knowing for collection-like objects. If your class wraps a collection and you implement these, your object gets len(), for loops, and in tests for free—and it feels native.

'Explicit is Better than Implicit' in Practice

The Zen of Python contains nineteen principles. The one that does the most heavy lifting in day-to-day Python development is:

Explicit is better than implicit.

This shows up in architectural decisions more often than you'd expect.

Explicit return values. A function should return values explicitly, not mutate arguments and return nothing. Mutation as a side effect can be reasonable in some contexts (sorting in place, for example), but it should be obvious from the name and signature that mutation is happening.

Explicit imports. Prefer from mymodule import SpecificThing over from mymodule import *. Wildcard imports pollute the namespace and make it impossible to tell at a glance where a name came from. When you're reading code six months later, from config import * is maddening.

Explicit error handling. Catching bare except: or even except Exception: without re-raising or logging swallows errors silently. Be explicit about what can go wrong:

# Implicit (dangerous)
try:
    result = dangerous_operation()
except:
    pass

# Explicit (survivable)
try:
    result = dangerous_operation()
except NetworkError as e:
    logger.error("Network failure during operation: %s", e)
    raise

Explicit None checks. Python's truthiness rules are convenient but sometimes dangerous. if user: is falsy when user is None, but it's also falsy when user is an empty string, zero, or an empty list. If you mean if user is not None:, say so.

Function signatures over global state. If a function needs data, pass it as an argument. Don't read from module-level globals or configuration objects directly inside functions. This makes dependencies explicit—visible at the call site—and makes functions testable without environment setup.

Two parallel code examples showing implicit vs explicit Python patterns, with annotations showing where each approach causes problems

The "explicit" principle is sometimes in tension with Python's duck typing and convention-over-configuration philosophy. You don't want to write Java-style verbose type declarations for every single variable. The judgment call is: will a future reader—unfamiliar with the context—understand this immediately, or will they have to trace several files to figure out what's going on? If it's the latter, make it more explicit.

Linters and Formatters as Architecture Enforcement

Here's an opinion worth defending: automated code style enforcement is not about aesthetics, it's about preserving cognitive bandwidth for real problems.

Every minute a developer spends thinking about indentation, import order, or line length is a minute not spent thinking about the actual design problem. Eliminate those decisions. Use tools.

The current Python toolchain for this is excellent:

Black is an opinionated code formatter. It makes no configuration decisions available to you—by design. You run it, it formats your code, end of discussion. Some developers resist it because it occasionally produces formatting they wouldn't have chosen manually. That's the point. Black makes a consistent choice so your team doesn't have to.

Ruff is a fast linter (written in Rust) that replaces Flake8, isort, and many pycodestyle checks in a single tool. It catches style violations, common bugs, import organization issues, and a growing set of security patterns. It's fast enough to run on save in your editor without any noticeable lag.

isort (often folded into Ruff these days) organizes imports into the PEP 8-prescribed sections: stdlib, third-party, local—each alphabetized, each separated by a blank line. This sounds trivial. It matters because knowing where to find an import is the first step in understanding where a dependency comes from.

The right place to enforce these tools is in your CI pipeline and as pre-commit hooks. If formatting is enforced automatically on every commit, it never becomes a code review discussion. The configuration lives in pyproject.toml, committed to the repository, so every developer on the team—and every CI run—uses exactly the same rules.

# pyproject.toml
[tool.black]
line-length = 88
target-version = ["py311"]

[tool.ruff]
line-length = 88
select = ["E", "W", "F", "I", "N", "UP"]

[tool.ruff.isort]
known-first-party = ["mypackage"]

A note on mypy (the type checker): it belongs in this conversation too, but it sits at a slightly different level. Black and Ruff enforce style. Mypy enforces structural contracts—it verifies that your type annotations are consistent with how the code actually behaves. We'll get deep into the type system in Section 6. For now, the point is that the type checker belongs in CI next to the formatter and linter, not as an afterthought.

One practical lesson: the hardest part of introducing these tools to an existing codebase isn't the tooling itself. It's the first Black run that reformats 400 files and turns your git blame into a wall of "style: auto-format" commits. The solution is to run Black on new files immediately and schedule a one-time "apply Black to everything" commit that everyone agrees to rebase from. Do it on a Friday afternoon when nobody's merging anything. It hurts once and then it's over.

Putting It Together: A Before/After

The best way to see how these habits compound is to look at a realistic piece of code before and after applying them.

Before:

import json, requests, datetime

def get_stuff(uid, t):
    # get user
    r = requests.get('http://api.example.com/users/' + str(uid))
    d = json.loads(r.text)
    # check
    if d['active'] == True:
        ts = datetime.datetime.now()
        if (ts - datetime.datetime.fromisoformat(d['last_login'])).days > t:
            return False
        return True
    else:
        return False

After:

import json
from datetime import datetime, timedelta

import requests

USERS_API_BASE = "https://api.example.com/users"

def is_user_recently_active(user_id: int, active_within_days: int) -> bool:
    """
    Return True if the user is enabled and has logged in within the given
    number of days. Returns False for disabled accounts regardless of
    login recency.
    
    Raises requests.HTTPError if the API returns a non-2xx response.
    """
    user_data = _fetch_user(user_id)
    
    if not user_data["active"]:
        return False
    
    return _logged_in_recently(user_data["last_login"], active_within_days)


def _fetch_user(user_id: int) -> dict:
    response = requests.get(f"{USERS_API_BASE}/{user_id}")
    response.raise_for_status()
    return response.json()


def _logged_in_recently(last_login_iso: str, within_days: int) -> bool:
    last_login = datetime.fromisoformat(last_login_iso)
    cutoff = datetime.now() - timedelta(days=within_days)
    return last_login >= cutoff

The after version is roughly three times longer. It's also roughly ten times easier to read, test, and modify. The function name reveals intent. The docstring documents contracts and exceptions. The implementation is decomposed into single-purpose helpers. The imports are organized. The string formatting is idiomatic. The comparison with True is gone (if not user_data["active"] is more natural than if user_data["active"] == False).

Every one of these changes is a small decision. Individually, none of them is revolutionary. Together, they compound into code that a developer can open cold, understand in thirty seconds, and modify with confidence.

That's the goal. Not elegance for its own sake. Not adherence to rules because rules are good. Reduced cognitive load, sustainable pace, and the quiet satisfaction of returning to your own code six months later and finding it readable.

Everything else we'll cover in this course—patterns, principles, architecture layers—builds on this foundation. Patterns applied to messy code produce messy patterns. The habits in this section are what make everything else work.