| name | python-architecture |
| description | Python architecture patterns including KISS, SRP, separation of concerns, composition over inheritance, dependency injection, project structure, module organization, and public API design. Use when designing new components, organizing projects, planning directory layouts, refactoring complex code, or deciding between inheritance and composition. |
Python Architecture
Build maintainable Python systems with clear design principles and well-organized project structure. Good architecture makes code easy to understand, test, and change.
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
- Starting a new Python project from scratch
- Reorganizing an existing codebase for clarity
- Defining module public APIs with
__all__
- Deciding between flat and nested directory structures
- Determining test file placement strategies
Core Concepts
KISS — Choose the simplest solution that works. Complexity must be justified.
SRP — Each unit has one reason to change. Separate concerns into focused components.
Composition — Build behavior by combining objects, not extending classes.
Rule of Three — Wait until you have three instances before abstracting.
Flat Hierarchies — Prefer shallow directory structures. Add depth only for genuine sub-domains.
Explicit Interfaces — Define what's public with __all__. Everything else is internal.
Design Principles
Pattern 1: KISS — Keep It Simple
Before adding complexity, ask: does a simpler solution work?
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): ...
FORMATTERS = {
"json": JsonFormatter,
"csv": CsvFormatter,
"xml": XmlFormatter,
}
def get_formatter(name: str) -> Formatter:
if name not in FORMATTERS:
raise ValueError(f"Unknown format: {name}")
return FORMATTERS[name]()
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.
class UserHandler:
async def create_user(self, request: Request) -> Response:
data = await request.json()
if not data.get("email"):
return Response({"error": "email required"}, status=400)
user = await db.execute("INSERT INTO users ...", data["email"], data["name"])
return Response({"id": user.id, "email": user.email}, status=201)
class UserService:
"""Business logic only."""
def __init__(self, repo: UserRepository) -> None:
self._repo = repo
async def create_user(self, data: CreateUserInput) -> User:
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 — Layered Architecture
Organize code into distinct layers. Each layer depends only on layers below it.
┌─────────────────────────────────────────────────────┐
│ 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 ops │
└─────────────────────────────────────────────────────┘
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
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
@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)
Directory layout for layered architecture:
myapp/
├── api/ # HTTP handlers, request/response schemas
│ ├── routes/
│ └── middleware/
├── services/ # Business logic
├── repositories/ # Data access
├── models/ # Domain entities
├── schemas/ # API schemas (Pydantic)
└── config/ # Configuration
Pattern 4: Composition Over Inheritance
Build behavior by combining objects rather than inheriting.
class EmailNotificationService(NotificationService):
def __init__(self):
super().__init__()
self._smtp = SmtpClient()
def notify(self, user: User, message: str) -> None:
self._smtp.send(user.email, message)
class NotificationService:
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)
service = NotificationService(
email_sender=FakeEmailSender(),
sms_sender=FakeSmsSender(),
)
Pattern 5: Rule of Three
Wait until you have three instances before abstracting.
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
Pattern 6: Function Size Guidelines
Keep functions focused. Extract when a function:
- Exceeds ~50 lines
- Serves multiple distinct purposes
- Has deeply nested logic (3+ levels)
def process_order(order: Order) -> Result:
pass
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 Cache(Protocol):
async def get(self, key: str) -> str | None: ...
async def set(self, key: str, value: str, ttl: int) -> None: ...
class Logger(Protocol):
def info(self, msg: str, **kwargs) -> None: ...
def error(self, msg: str, **kwargs) -> None: ...
class UserService:
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:
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)
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
service = UserService(
repository=PostgresUserRepository(db),
cache=RedisCache(redis),
logger=StructlogLogger(),
)
service = UserService(
repository=InMemoryUserRepository(),
cache=FakeCache(),
logger=NullLogger(),
)
Project Organization
Pattern 8: One Concept Per File
Each file should focus on a single concept. Consider splitting when a file:
- Handles multiple unrelated responsibilities
- Grows beyond ~300-500 lines
- Contains classes that change for different reasons
Pattern 9: Explicit Public APIs with __all__
Define the public interface for every package. Unlisted members are internal implementation details.
from .user_service import UserService
from .order_service import OrderService
from .exceptions import ServiceError, ValidationError
__all__ = [
"UserService",
"OrderService",
"ServiceError",
"ValidationError",
]
from .core import MainClass, HelperClass
from .exceptions import PackageError, ConfigError
from .config import Settings
__all__ = ["MainClass", "HelperClass", "PackageError", "ConfigError", "Settings"]
__version__ = "1.0.0"
from mypackage import MainClass, Settings
Pattern 10: Flat Directory Structure
Prefer minimal nesting. Deep hierarchies make imports verbose and navigation difficult.
# Preferred: Flat structure
project/
├── api/
│ ├── routes.py
│ └── middleware.py
├── services/
│ ├── user_service.py
│ └── order_service.py
├── models/
│ ├── user.py
│ └── order.py
└── utils/
└── validation.py
# Avoid: Deep nesting
project/core/internal/services/impl/user/
Add sub-packages only when there's a genuine sub-domain requiring isolation.
Pattern 11: Test File Organization
Choose one approach and apply it consistently.
Option A: Colocated Tests — Tests live next to the code they verify.
src/
├── user_service.py
├── test_user_service.py
├── order_service.py
└── test_order_service.py
Option B: Parallel Test Directory — Standard for larger projects.
src/
├── services/
│ ├── user_service.py
│ └── order_service.py
tests/
├── services/
│ ├── test_user_service.py
│ └── test_order_service.py
Pattern 12: Domain-Driven Structure
For complex applications, organize by business domain rather than technical layer.
ecommerce/
├── users/
│ ├── models.py
│ ├── services.py
│ ├── repository.py
│ └── api.py
├── orders/
│ ├── models.py
│ ├── services.py
│ ├── repository.py
│ └── api.py
└── shared/
├── database.py
└── exceptions.py
Pattern 13: File and Module Naming
user_repository.py
usr_repo.py
from myproject.services import UserService
from ..services import UserService
Best Practices Summary
Design
- Keep it simple — Choose the simplest solution that works
- Single responsibility — Each unit has one reason to change
- Separate concerns — Distinct layers, dependencies flow downward
- Compose, don't inherit — Combine objects for flexibility and testability
- Rule of three — Wait before abstracting; duplication beats wrong abstraction
- Keep functions small — ~50 lines max, one purpose
- Inject dependencies — Constructor injection for testability
- Explicit over clever — Readable code beats elegant code
Project Structure
9. One concept per file — Split at ~300-500 lines
10. Define __all__ explicitly — Make public interfaces clear
11. Prefer flat structures — Add depth only for genuine sub-domains
12. Use absolute imports — More reliable and refactor-safe
13. Be consistent — Apply naming and organization uniformly
14. Match names to content — File names should describe their purpose