Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Python design patterns including KISS, Separation of Concerns, Single Responsibility, and composition over inheritance. Use when making architecture decisions, refactoring code structure, or evaluating when abstractions are appropriate.
.claude/skills/dicklesworthstone-python-design-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 61% | 0% |
| case-18 | ✓→✓ | = Same ✓ | 112% | 0% |
| case-23 | ✓→✓ | = Same ✓ | 123% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 139% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 72% | 0% |
Write maintainable Python code using fundamental design principles. These patterns help you build systems that are easy to understand, test, and modify.
Choose the simplest solution that works. Complexity must be justified by concrete requirements.
Each unit should have one reason to change. Separate concerns into focused components.
Build behavior by combining objects, not extending classes.
Wait until you have three instances before abstracting. Duplication is often better than premature abstraction.
python# Simple beats clever # Instead of a factory/registry pattern: FORMATTERS = {"json": JsonFormatter, "csv": CsvFormatter} def get_formatter(name: str) -> Formatter: return FORMATTERS[name]()
Before adding complexity, ask: does a simpler solution work?
python# 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 FORMATTERS[name]()
The factory pattern adds code without adding value here. Save patterns for when they solve real problems.
Each class or function should have one reason to change.
python# 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.
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:
python# 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)
Build behavior by combining objects rather than inheriting.
python# 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(), )
Wait until you have three instances before abstracting.
python# 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
Keep functions focused. Extract when a function:
python# 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)
Pass dependencies through constructors for testability.
pythonfrom 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(), )
Don't expose internal types:
python# 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:
python# 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
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-18 | pass→pass | 15,037 | 15,695 | +4% | 1 | 1 | 0% | 2,794 | 5,912 | +112% | 0 | 0 | — |
case-23 | pass→pass | 13,294 | 12,467 | -6% | 1 | 1 | 0% | 2,328 | 5,188 | +123% | 0 | 0 | — |
case-01 | fail→pass | 17,612 | 12,112 | -31% | 1 | 1 | 0% | 3,130 | 5,040 | +61% | 0 | 0 | — |
case-02 | pass→pass | 14,023 | 15,610 | +11% | 1 | 1 | 0% | 2,623 | 6,282 | +139% | 0 | 0 | — |
case-03 | pass→pass | 17,158 | 16,414 | -4% | 1 | 1 | 0% | 3,228 | 5,558 | +72% | 0 | 0 | — |
case-04 | pass→pass | 12,868 | 8,803 | -32% | 1 | 1 | 0% | 2,059 | 4,444 | +116% | 0 | 0 | — |
case-05 | pass→pass | 16,509 | 10,156 | -38% | 1 | 1 | 0% | 2,583 | 4,875 | +89% | 0 | 0 | — |
case-06 | pass→pass | 15,374 | 10,423 | -32% | 1 | 1 | 0% | 2,243 | 5,045 | +125% | 0 | 0 | — |
case-07 | pass→pass | 15,437 | 12,851 | -17% | 1 | 1 | 0% | 2,295 | 5,395 | +135% | 0 | 0 | — |
case-08 | pass→pass | 13,344 | 12,249 | -8% | 1 | 1 | 0% | 1,967 | 5,309 | +170% | 0 | 0 | — |
case-09 | pass→pass | 13,010 | 10,787 | -17% | 1 | 1 | 0% | 2,274 | 4,847 | +113% | 0 | 0 | — |
case-10 | pass→pass | 15,454 | 14,512 | -6% | 1 | 1 | 0% | 2,669 | 5,525 | +107% | 0 | 0 | — |
case-11 | pass→pass | 12,669 | 12,461 | -2% | 1 | 1 | 0% | 2,710 | 5,558 | +105% | 0 | 0 | — |
case-12 | pass→pass | 14,195 | 9,216 | -35% | 1 | 1 | 0% | 2,537 | 4,542 | +79% | 0 | 0 | — |
case-13 | pass→pass | 12,012 | 10,504 | -13% | 1 | 1 | 0% | 2,255 | 4,954 | +120% | 0 | 0 | — |
case-14 | pass→pass | 16,349 | 14,534 | -11% | 1 | 1 | 0% | 2,685 | 5,620 | +109% | 0 | 0 | — |
case-15 | pass→pass | 11,445 | 7,711 | -33% | 1 | 1 | 0% | 1,598 | 4,275 | +168% | 0 | 0 | — |
case-16 | pass→pass | 11,315 | 2,518 | -78% | 1 | 1 | 0% | 1,564 | 3,382 | +116% | 0 | 0 | — |
case-17 | pass→pass | 9,311 | 7,715 | -17% | 1 | 1 | 0% | 1,348 | 3,784 | +181% | 0 | 0 | — |
case-19 | pass→pass | 12,663 | 8,071 | -36% | 1 | 1 | 0% | 1,964 | 4,431 | +126% | 0 | 0 | — |
case-20 | pass→pass | 10,683 | 11,649 | +9% | 1 | 1 | 0% | 1,956 | 5,173 | +164% | 0 | 0 | — |
case-21 | pass→pass | 10,734 | 7,938 | -26% | 1 | 1 | 0% | 1,823 | 4,531 | +149% | 0 | 0 | — |
case-22 | pass→pass | 2,751 | 2,666 | -3% | 1 | 1 | 0% | 501 | 3,484 | +595% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +4 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.