Introduction
Writing Python that works is the easy part. Writing Python that survives production — gets debugged six months later, gets handed to a new developer without four hours of explanation, gets refactored without breaking a hundred callers — is a different skill. It’s also the single biggest gap between junior and senior Python developers.
Eight design principles cover almost all of it. None of them are new. None of them are Python-specific. The reason they matter so much in Python is precisely because Python is so permissive — it lets you ship sloppy code that compiles fine and breaks in production. Discipline at the design level is what holds your codebase together.
📚 Table of contents
- 1. Cohesion and single responsibility
- 2. Encapsulation and abstraction
- 3. Loose coupling and modularity
- 4. Reusability and extensibility
- 5. Portability
- 6. Defensibility
- 7. Maintainability and testability
- 8. Simplicity (and the YAGNI / DRY balance)
- How to apply these in code review
- Common mistakes
- FAQs
1. Cohesion and single responsibility
A function, class, or module should do one thing well. If you describe what a class does and need the word “and”, it’s probably two classes.
# Bad: one class doing too much
class User:
def save_to_db(self): ...
def send_welcome_email(self): ...
def generate_pdf_report(self): ...
# Good: separate responsibilities, compose them
class User: ...
class UserRepository: def save(self, user: User): ...
class EmailService: def send_welcome(self, user: User): ...
class ReportGenerator: def generate_pdf(self, user: User): ...
Tests get easier. Mocking gets easier. Reuse gets easier. The single most consistent indicator of senior-grade code is small, focused units.
2. Encapsulation and abstraction
Hide internal state. Expose behavior through methods, not raw attributes. Bad consumers will abuse what you expose; good design refuses to let them.
# Bad: state can be corrupted
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner
self.balance = balance # anyone can set this to anything
self.transactions = []
# Good: invariants protected behind methods
class BankAccount:
def __init__(self, owner: str, opening_balance: float = 0.0):
if opening_balance < 0:
raise ValueError("opening balance must be non-negative")
self._owner = owner
self._balance = opening_balance
self._transactions: list[Transaction] = []
@property
def balance(self) -> float:
return self._balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("deposit must be positive")
self._balance += amount
self._transactions.append(Transaction("deposit", amount))
def withdraw(self, amount: float) -> None:
if amount <= 0 or amount > self._balance:
raise ValueError("invalid withdrawal")
self._balance -= amount
self._transactions.append(Transaction("withdraw", -amount))
Python doesn’t enforce private attributes (the _ prefix is convention, not
rule). But naming and providing proper methods tells the next developer how to interact with
your class. Linters and type checkers respect the convention.
3. Loose coupling and modularity
Code that depends on concrete implementations breaks every time you swap one out. Code that depends on interfaces survives.
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, user: User, message: str) -> None: ...
class EmailNotifier(Notifier):
def send(self, user, message): ...
class SMSNotifier(Notifier):
def send(self, user, message): ...
class MultiNotifier(Notifier):
def __init__(self, notifiers: list[Notifier]):
self._notifiers = notifiers
def send(self, user, message):
for n in self._notifiers:
n.send(user, message)
class OrderProcessor:
def __init__(self, notifier: Notifier): # depends on interface
self._notifier = notifier
def confirm(self, order):
self._notifier.send(order.user, "Order confirmed.")
Add a Slack notifier next quarter? Implement Notifier, swap it in, no changes to
OrderProcessor. Same testing benefit — mock the notifier in unit tests
without touching email infrastructure.
4. Reusability and extensibility
The open-closed principle: open for extension, closed for modification. You should be able to add functionality without rewriting existing code.
# Bad: monolithic, hard to extend
class ReportGenerator:
def generate(self, data, format):
if format == "text":
...
elif format == "csv":
...
elif format == "html":
...
# adding XML means editing this function
# Good: per-format strategy
class ReportFormatter(ABC):
@abstractmethod
def format(self, data: list) -> str: ...
class TextFormatter(ReportFormatter): ...
class CSVFormatter(ReportFormatter): ...
class HTMLFormatter(ReportFormatter): ...
# XMLFormatter is just a new class — no edits to existing code
class ReportGenerator:
def __init__(self, formatter: ReportFormatter):
self._formatter = formatter
def generate(self, data: list) -> str:
return self._formatter.format(data)
5. Portability
Code that hardcodes paths, hostnames, secrets, or environment-specific values dies the moment it leaves your machine. Keep configuration external:
# Bad
DATABASE_URL = "postgres://user:pass@localhost:5432/mydb"
def connect(): return psycopg.connect(DATABASE_URL)
# Good
import os
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.environ["DATABASE_URL"]
def connect(): return psycopg.connect(DATABASE_URL)
Use pathlib.Path over raw strings for filesystem paths. Use UTC for timestamps.
Stick to platform-neutral APIs where possible.
6. Defensibility
Validate inputs at the boundaries. Use custom exceptions. Fail fast and loud rather than silently doing the wrong thing.
from dataclasses import dataclass
class ValidationError(Exception): ...
class PaymentError(Exception): ...
@dataclass(frozen=True)
class PaymentResult:
transaction_id: str
amount: float
status: str
class PaymentProcessor:
def _validate_amount(self, amount: float) -> None:
if amount <= 0 or amount > 10_000:
raise ValidationError(f"invalid amount: {amount}")
def _validate_card(self, card_number: str, cvv: str) -> None:
clean = card_number.replace(" ", "").replace("-", "")
if not clean.isdigit() or len(clean) != 16:
raise ValidationError("invalid card number")
if not cvv.isdigit() or len(cvv) not in (3, 4):
raise ValidationError("invalid CVV")
def process(self, amount, card_number, cvv) -> PaymentResult:
self._validate_amount(amount)
self._validate_card(card_number, cvv)
try:
tx_id = self._charge(amount, card_number)
except Exception as e:
raise PaymentError(f"charge failed: {e}") from e
return PaymentResult(transaction_id=tx_id, amount=amount, status="ok")
Note the patterns: custom exception types, validation before any side effects, no leaking of
sensitive data into logs, immutable result with frozen=True so callers can’t
mutate it. Defensive without being paranoid.
7. Maintainability and testability
Code that’s easy to test is easy to maintain. Code that’s hard to test has hidden coupling.
- Pure functions where possible — same inputs, same outputs, no side effects.
- Dependency injection — pass dependencies in rather than instantiating inside.
- Small functions (5–20 lines is a useful rule of thumb).
- Type hints everywhere — documentation that doesn’t lie.
- Logging at the right level (DEBUG, INFO, WARNING, ERROR) for production debuggability.
- Tests for the happy path and the edge cases. The bugs live in edge cases.
8. Simplicity (and the YAGNI / DRY balance)
The hardest principle to apply because it’s in tension with the others.
- KISS — Keep It Simple, Stupid. The simplest design that solves the problem is almost always right.
- DRY — Don’t Repeat Yourself. Repeated logic belongs in one place.
- YAGNI — You Aren’t Gonna Need It. Don’t add abstractions or features “for the future” before you know which future shows up.
The tension: DRY can lead to premature abstraction. YAGNI can lead to repeated code. The judgement call is whether the repetition you’re tolerating is actually painful — three nearly-identical functions in three places is fine until the day you fix a bug in two of them and forget the third.
Don’t over-engineer early. A startup MVP doesn’t need every design pattern from this list. A 10-year-old codebase with 200 contributors does. Match the rigor to the lifespan.
How to apply these in code review
The principles are abstract. The application is concrete. When reviewing a PR (your own or someone else’s), ask these eight questions:
- Does each function/class do one thing? (cohesion)
- Are internal details hidden behind methods, not raw attributes? (encapsulation)
- Does this depend on abstractions or concrete implementations? (coupling)
- Can I add a new variant without modifying existing code? (extensibility)
- Are there hardcoded paths/URLs/secrets? (portability)
- Are inputs validated; do failures throw informative errors? (defensibility)
- Is this testable without elaborate setup? (testability)
- Is this the simplest design that solves the actual problem? (simplicity)
❌ Common mistakes
- Applying every principle at maximum rigor to a 200-line MVP. Over-engineering kills momentum.
- Hiding everything behind interfaces “in case” you swap implementations. Add abstractions when you have at least two concrete implementations to abstract over.
- Validating inputs in every internal function. Validate at boundaries (HTTP handlers, public APIs) — trust your own code.
- Custom exception classes for every error. One or two per module is enough; granular hierarchies become noise.
- God classes that “coordinate everything.” The class name has the word “Manager” in it and it has 1,500 lines of methods.
- Premature abstraction. Three similar functions is fine; abstract on the fourth one.
💡 Pro tips
- Use dataclasses (frozen + slots) for value objects. They give you immutability for free.
- Use Protocol classes (PEP 544) for lightweight interfaces — structural typing without inheritance.
- Pair these principles with type checkers (mypy, pyright). Type hints reveal coupling issues you didn’t see.
- Write the test first if you’re not sure about the design. If the test is painful to write, the design is wrong.
- Code review is the best training ground. Read other people’s code and articulate why a design choice does or doesn’t serve these principles.
- Re-read your own code from six months ago. If you can’t follow it, you violated simplicity.
Conclusion
Production Python isn’t about exotic features or clever tricks. It’s about applying boring design discipline at every level — one responsibility per unit, hidden internals, abstractions over implementations, validation at boundaries, simplicity wherever you can get away with it.
The senior Python developers who keep ending up on the most important projects didn’t learn a secret framework. They internalized these eight principles and applied them consistently across every codebase they touched. The principles compound. Six months of deliberate practice and your output is recognisably senior-grade.
Related reading: Python web development roadmap 2026 — build AI agents in Python: landscape guide — LangChain review
Explore More on DevShelf
-
Defensive Python: Edge Cases and Validation
The micro-level complement to these design principles — input guards, Pydantic patterns, and the habits that prevent production bugs.
-
7 Python Anti-Patterns That Kill Your Code
What to avoid alongside what to follow — seven common patterns that look fine in review but break in production.