DS DevShelfHub Projects · AI tools
Articles / How to Write Production Python: 8 Design Principles That Separate Junior From Senior

AI Engineering

Production Python: Eight Design Principles for Code That Lasts

By DevShelfHub

The eight design principles that distinguish production-grade Python from working-but-fragile code — cohesion and single responsibility, encapsulation, loose coupling via interfaces, reusability/extensibility (open-closed), portability with env-driven config, defensibility with custom exceptions and validation at boundaries, maintainability and testability via DI and pure functions, and simplicity via the YAGNI/DRY balance. With before-and-after Python examples and a code-review checklist for applying them.

Production Python: Eight Design Principles for Code That Lasts

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.

Python
# 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.

Python
# 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.

Python
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.

Python
# 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:

Python
# 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.

Python
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:

  1. Does each function/class do one thing? (cohesion)
  2. Are internal details hidden behind methods, not raw attributes? (encapsulation)
  3. Does this depend on abstractions or concrete implementations? (coupling)
  4. Can I add a new variant without modifying existing code? (extensibility)
  5. Are there hardcoded paths/URLs/secrets? (portability)
  6. Are inputs validated; do failures throw informative errors? (defensibility)
  7. Is this testable without elaborate setup? (testability)
  8. 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 2026build AI agents in Python: landscape guideLangChain review

Explore More on DevShelf

How to Write Production Python: 8 Design Principles That Separate Junior From Senior FAQ

Aren't these just SOLID principles?

Closely related. SOLID is an OOP-focused subset. The eight here are broader and apply across functional and procedural code too. The underlying ideas are the same.

Does AI-generated code follow these principles?

Sometimes, but not consistently. AI tends to generate working code that's correct in structure but ad-hoc in design. Reviewing AI output against these principles is one of the highest-leverage code review activities you can do.

Do I need all eight on every project?

No. Match rigor to lifespan and team size. Solo MVP: simplicity and defensibility cover most of it. Production system with multiple teams: all eight, applied carefully.

How do I avoid over-engineering?

Apply YAGNI. Don't add abstractions before you have at least two concrete reasons to. Start simple, refactor toward complexity only when the simplicity hurts.

Is FastAPI / Django / Flask helping or hurting?

Frameworks help structure but don't enforce design. You can write spaghetti in any framework. The principles apply on top of whatever framework you use.

Where should I read more?

Clean Architecture by Robert Martin and The Pragmatic Programmer by Hunt and Thomas are the classic foundations. Architecture Patterns with Python by Percival and Gregory translates these ideas specifically into Python.