python-design-patterns

Python Design Patterns

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "python-design-patterns" with this command: npx skills add wshobson/agents/wshobson-agents-python-design-patterns

Python Design Patterns

Write maintainable Python code using fundamental design principles. These patterns help you build systems that are easy to understand, test, and modify.

When to Use This Skill

  • Designing new components or services

  • Refactoring complex or tangled code

  • Deciding whether to create an abstraction

  • Choosing between inheritance and composition

  • Evaluating code complexity and coupling

  • Planning modular architectures

Core Concepts

  1. KISS (Keep It Simple)

Choose the simplest solution that works. Complexity must be justified by concrete requirements.

  1. Single Responsibility (SRP)

Each unit should have one reason to change. Separate concerns into focused components.

  1. Composition Over Inheritance

Build behavior by combining objects, not extending classes.

  1. Rule of Three

Wait until you have three instances before abstracting. Duplication is often better than premature abstraction.

Quick Start

Simple beats clever

Instead of a factory/registry pattern:

FORMATTERS = {"json": JsonFormatter, "csv": CsvFormatter}

def get_formatter(name: str) -> Formatter: return FORMATTERSname

Fundamental Patterns

Pattern 1: KISS - Keep It Simple

Before adding complexity, ask: does a simpler solution work?

Over-engineered: Factory with registration

class OutputFormatterFactory: _formatters: dict[str, type[Formatter]] = {}

@classmethod
def register(cls, name: str):
    def decorator(formatter_cls):
        cls._formatters[name] = formatter_cls
        return formatter_cls
    return decorator

@classmethod
def create(cls, name: str) -> Formatter:
    return cls._formatters[name]()

@OutputFormatterFactory.register("json") class JsonFormatter(Formatter): ...

Simple: Just use a dictionary

FORMATTERS = { "json": JsonFormatter, "csv": CsvFormatter, "xml": XmlFormatter, }

def get_formatter(name: str) -> Formatter: """Get formatter by name.""" if name not in FORMATTERS: raise ValueError(f"Unknown format: {name}") return FORMATTERSname

The factory pattern adds code without adding value here. Save patterns for when they solve real problems.

Pattern 2: Single Responsibility Principle

Each class or function should have one reason to change.

BAD: Handler does everything

class UserHandler: async def create_user(self, request: Request) -> Response: # HTTP parsing data = await request.json()

    # Validation
    if not data.get("email"):
        return Response({"error": "email required"}, status=400)

    # Database access
    user = await db.execute(
        "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *",
        data["email"], data["name"]
    )

    # Response formatting
    return Response({"id": user.id, "email": user.email}, status=201)

GOOD: Separated concerns

class UserService: """Business logic only."""

def __init__(self, repo: UserRepository) -> None:
    self._repo = repo

async def create_user(self, data: CreateUserInput) -> User:
    # Only business rules here
    user = User(email=data.email, name=data.name)
    return await self._repo.save(user)

class UserHandler: """HTTP concerns only."""

def __init__(self, service: UserService) -> None:
    self._service = service

async def create_user(self, request: Request) -> Response:
    data = CreateUserInput(**(await request.json()))
    user = await self._service.create_user(data)
    return Response(user.to_dict(), status=201)

Now HTTP changes don't affect business logic, and vice versa.

Pattern 3: Separation of Concerns

Organize code into distinct layers with clear responsibilities.

┌─────────────────────────────────────────────────────┐ │ API Layer (handlers) │ │ - Parse requests │ │ - Call services │ │ - Format responses │ └─────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Service Layer (business logic) │ │ - Domain rules and validation │ │ - Orchestrate operations │ │ - Pure functions where possible │ └─────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Repository Layer (data access) │ │ - SQL queries │ │ - External API calls │ │ - Cache operations │ └─────────────────────────────────────────────────────┘

Each layer depends only on layers below it:

Repository: Data access

class UserRepository: async def get_by_id(self, user_id: str) -> User | None: row = await self._db.fetchrow( "SELECT * FROM users WHERE id = $1", user_id ) return User(**row) if row else None

Service: Business logic

class UserService: def init(self, repo: UserRepository) -> None: self._repo = repo

async def get_user(self, user_id: str) -> User:
    user = await self._repo.get_by_id(user_id)
    if user is None:
        raise UserNotFoundError(user_id)
    return user

Handler: HTTP concerns

@app.get("/users/{user_id}") async def get_user(user_id: str) -> UserResponse: user = await user_service.get_user(user_id) return UserResponse.from_user(user)

Pattern 4: Composition Over Inheritance

Build behavior by combining objects rather than inheriting.

Inheritance: Rigid and hard to test

class EmailNotificationService(NotificationService): def init(self): super().init() self._smtp = SmtpClient() # Hard to mock

def notify(self, user: User, message: str) -> None:
    self._smtp.send(user.email, message)

Composition: Flexible and testable

class NotificationService: """Send notifications via multiple channels."""

def __init__(
    self,
    email_sender: EmailSender,
    sms_sender: SmsSender | None = None,
    push_sender: PushSender | None = None,
) -> None:
    self._email = email_sender
    self._sms = sms_sender
    self._push = push_sender

async def notify(
    self,
    user: User,
    message: str,
    channels: set[str] | None = None,
) -> None:
    channels = channels or {"email"}

    if "email" in channels:
        await self._email.send(user.email, message)

    if "sms" in channels and self._sms and user.phone:
        await self._sms.send(user.phone, message)

    if "push" in channels and self._push and user.device_token:
        await self._push.send(user.device_token, message)

Easy to test with fakes

service = NotificationService( email_sender=FakeEmailSender(), sms_sender=FakeSmsSender(), )

Advanced Patterns

Pattern 5: Rule of Three

Wait until you have three instances before abstracting.

Two similar functions? Don't abstract yet

def process_orders(orders: list[Order]) -> list[Result]: results = [] for order in orders: validated = validate_order(order) result = process_validated_order(validated) results.append(result) return results

def process_returns(returns: list[Return]) -> list[Result]: results = [] for ret in returns: validated = validate_return(ret) result = process_validated_return(validated) results.append(result) return results

These look similar, but wait! Are they actually the same?

Different validation, different processing, different errors...

Duplication is often better than the wrong abstraction

Only after a third case, consider if there's a real pattern

But even then, sometimes explicit is better than abstract

Pattern 6: Function Size Guidelines

Keep functions focused. Extract when a function:

  • Exceeds 20-50 lines (varies by complexity)

  • Serves multiple distinct purposes

  • Has deeply nested logic (3+ levels)

Too long, multiple concerns mixed

def process_order(order: Order) -> Result: # 50 lines of validation... # 30 lines of inventory check... # 40 lines of payment processing... # 20 lines of notification... pass

Better: Composed from focused functions

def process_order(order: Order) -> Result: """Process a customer order through the complete workflow.""" validate_order(order) reserve_inventory(order) payment_result = charge_payment(order) send_confirmation(order, payment_result) return Result(success=True, order_id=order.id)

Pattern 7: Dependency Injection

Pass dependencies through constructors for testability.

from typing import Protocol

class Logger(Protocol): def info(self, msg: str, **kwargs) -> None: ... def error(self, msg: str, **kwargs) -> None: ...

class Cache(Protocol): async def get(self, key: str) -> str | None: ... async def set(self, key: str, value: str, ttl: int) -> None: ...

class UserService: """Service with injected dependencies."""

def __init__(
    self,
    repository: UserRepository,
    cache: Cache,
    logger: Logger,
) -> None:
    self._repo = repository
    self._cache = cache
    self._logger = logger

async def get_user(self, user_id: str) -> User:
    # Check cache first
    cached = await self._cache.get(f"user:{user_id}")
    if cached:
        self._logger.info("Cache hit", user_id=user_id)
        return User.from_json(cached)

    # Fetch from database
    user = await self._repo.get_by_id(user_id)
    if user:
        await self._cache.set(f"user:{user_id}", user.to_json(), ttl=300)

    return user

Production

service = UserService( repository=PostgresUserRepository(db), cache=RedisCache(redis), logger=StructlogLogger(), )

Testing

service = UserService( repository=InMemoryUserRepository(), cache=FakeCache(), logger=NullLogger(), )

Pattern 8: Avoiding Common Anti-Patterns

Don't expose internal types:

BAD: Leaking ORM model to API

@app.get("/users/{id}") def get_user(id: str) -> UserModel: # SQLAlchemy model return db.query(UserModel).get(id)

GOOD: Use response schemas

@app.get("/users/{id}") def get_user(id: str) -> UserResponse: user = db.query(UserModel).get(id) return UserResponse.from_orm(user)

Don't mix I/O with business logic:

BAD: SQL embedded in business logic

def calculate_discount(user_id: str) -> float: user = db.query("SELECT * FROM users WHERE id = ?", user_id) orders = db.query("SELECT * FROM orders WHERE user_id = ?", user_id) # Business logic mixed with data access

GOOD: Repository pattern

def calculate_discount(user: User, order_history: list[Order]) -> float: # Pure business logic, easily testable if len(order_history) > 10: return 0.15 return 0.0

Best Practices Summary

  • Keep it simple - Choose the simplest solution that works

  • Single responsibility - Each unit has one reason to change

  • Separate concerns - Distinct layers with clear purposes

  • Compose, don't inherit - Combine objects for flexibility

  • Rule of three - Wait before abstracting

  • Keep functions small - 20-50 lines (varies by complexity), one purpose

  • Inject dependencies - Constructor injection for testability

  • Delete before abstracting - Remove dead code, then consider patterns

  • Test each layer - Isolated tests for each concern

  • Explicit over clever - Readable code beats elegant code

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

typescript-advanced-types

TypeScript Advanced Types

Repository Source
31.3K13.9K
wshobson
Coding

python-performance-optimization

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

code-review-excellence

No summary provided by upstream source.

Repository SourceNeeds Review