Imported from tstapler/dotfiles (
.claude/skills/code-architecture-best-practices/SKILL.md). Install upstream withnpx skills add tstapler/dotfiles --skill code-architecture-best-practices. Copyright stays with the author.
Architecture Best Practices
Apply these principles when designing systems, reviewing structure, or making architectural decisions.
Reuse Check (Do This First)
Before writing new logic, search the codebase (grep/ast-grep) for an existing function or
pattern that already does something similar — reuse it or extract a shared abstraction rather
than reimplementing it per call site. This is the single highest-value check in this skill: the
canonical failure mode it catches is the same bug-prone logic (e.g. a check-then-mutate ordering
rule) getting copy-pasted across sibling functions, each with its own chance to get it wrong,
instead of being centralized once. Do this check before design-pattern selection below, not
after — a pattern applied to duplicated logic just formalizes the duplication.
If the file you're about to edit is a known churn/complexity hotspot, run code-hotspot-analysis's
inline spot-check first — a file that already changes often and is already complex is exactly
where uncaught duplication compounds fastest.
Core Principles (Language-Agnostic)
SOLID
- Single Responsibility: One reason to change per class/module
- Open/Closed: Open for extension, closed for modification (use interfaces/protocols)
- Liskov Substitution: Subtypes must be substitutable for base types
- Interface Segregation: Many focused interfaces > one fat interface
- Dependency Inversion: Depend on abstractions, not concretions
Clean Architecture Layers
┌─────────────────────────────────┐
│ Frameworks & Drivers (Web, DB) │ ← outermost, most volatile
├─────────────────────────────────┤
│ Interface Adapters (Controllers│
│ Presenters, Gateways) │
├─────────────────────────────────┤
│ Application Use Cases │ ← orchestrates domain
├─────────────────────────────────┤
│ Domain Entities & Rules │ ← innermost, most stable
└─────────────────────────────────┘
Rule: Dependencies point inward only. Domain knows nothing about frameworks.
Hexagonal Architecture (Ports & Adapters)
- Ports: Interfaces defined by the domain (what the app needs)
- Adapters: Implementations of ports (HTTP, DB, messaging)
- Core: Business logic with no framework dependencies
- Enables swapping infrastructure without touching domain logic
Domain-Driven Design Essentials
| Concept | Purpose | Rule |
|---|---|---|
| Entity | Has identity, mutable | Identified by ID, not attributes |
| Value Object | Immutable, no identity | Equality by value; replace, don't mutate |
| Aggregate | Consistency boundary | Only modify through Aggregate Root |
| Repository | Collection abstraction | One per Aggregate Root; hides persistence |
| Domain Service | Logic not owned by entity | Stateless; operates on multiple entities |
| Application Service | Use case orchestration | No business logic; coordinates domain objects |
Key Design Rules
- Tell, Don't Ask: Objects should do things, not expose state to be checked externally
- Law of Demeter: Don't chain more than one
.(avoida.b().c().d()) - Composition over Inheritance: Favor has-a over is-a
- Command-Query Separation: Methods either change state (command) or return data (query) — not both
- Ubiquitous Language: Code names must match domain expert vocabulary exactly
Python-Specific Architecture
Layer Structure
src/
├── domain/ # Entities, Value Objects, Domain Services, Repository interfaces
│ ├── models.py # Pydantic/dataclass domain models
│ └── services.py # Pure domain logic
├── application/ # Use cases, Application Services, DTOs
│ └── use_cases.py
├── infrastructure/ # Repository implementations, DB, HTTP clients
│ ├── repositories.py
│ └── clients.py
└── interfaces/ # CLI (Typer), API (FastAPI), etc.
└── cli.py
Repository Pattern
from abc import ABC, abstractmethod
from typing import Protocol
# Domain defines the interface (port)
class UserRepository(Protocol):
def find_by_id(self, user_id: str) -> User | None: ...
def save(self, user: User) -> None: ...
# Infrastructure implements it (adapter)
class PostgresUserRepository:
def find_by_id(self, user_id: str) -> User | None:
...
def save(self, user: User) -> None:
...
Dependency Injection
# Application service receives dependencies — never imports them
class OrderService:
def __init__(self, orders: OrderRepository, payments: PaymentService) -> None:
self._orders = orders
self._payments = payments
Value Objects
from dataclasses import dataclass
@dataclass(frozen=True) # frozen=True makes it immutable
class Money:
amount: Decimal
currency: str
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
Avoid
- ❌ Business logic in CLI/API handlers
- ❌ Importing ORM models into domain layer
- ❌ God classes that do everything
- ❌ Mutable global state
Java / Spring Boot-Specific Architecture
Package Structure (by feature, not layer)
com.example.
├── order/
│ ├── domain/ # Order, OrderItem (entities/VOs)
│ ├── application/ # OrderService, CreateOrderCommand
│ ├── infrastructure/ # OrderJpaRepository, OrderMapper
│ └── api/ # OrderController, OrderRequest/Response DTOs
├── payment/
│ └── ...
└── shared/ # Shared kernel: common Value Objects, exceptions
Spring Layering Rules
| Layer | Annotation | Responsibility |
|---|---|---|
| API | @RestController |
HTTP in/out, request validation, DTO mapping |
| Application | @Service |
Use case orchestration, transaction boundary |
| Domain | (no Spring) | Pure business logic, entities, rules |
| Infrastructure | @Repository / @Component |
DB, external HTTP, messaging |
Dependency Direction
Controller → ApplicationService → DomainService/Entity
↓
Repository (interface)
↑
RepositoryImpl (infrastructure)
- Controllers depend on Application Services
- Application Services depend on Repository interfaces (domain layer)
- Infrastructure implements those interfaces
- Domain layer has zero Spring dependencies
Key Spring Boot Patterns
// Application Service — owns transaction boundary
@Service
@Transactional
public class OrderApplicationService {
private final OrderRepository orderRepository; // interface, not JPA impl
private final PaymentService paymentService;
public OrderId createOrder(CreateOrderCommand cmd) {
var order = Order.create(cmd.customerId(), cmd.items());
paymentService.reserve(order.total());
return orderRepository.save(order).id();
}
}
// Repository interface in domain layer
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
}
// JPA implementation in infrastructure layer
@Repository
class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpa; // Spring Data JPA
...
}
Spring Boot Avoid
- ❌ Business logic in
@RestController - ❌
@Autowiredfield injection (use constructor injection) - ❌ Exposing JPA entities directly in API responses
- ❌
@Transactionalon domain objects - ❌ Cross-feature direct class dependencies (use interfaces or events)
Domain Events
Use events to decouple aggregates and trigger side effects without coupling:
# Python
from dataclasses import dataclass, field
from datetime import datetime, UTC
@dataclass(frozen=True)
class DomainEvent:
occurred_at: datetime = field(default_factory=lambda: datetime.now(UTC))
@dataclass(frozen=True)
class OrderPlaced(DomainEvent):
order_id: str
customer_id: str
total: float
# Simple in-process event bus
class EventBus:
def __init__(self) -> None:
self._handlers: dict[type, list] = {}
def subscribe(self, event_type: type, handler) -> None:
self._handlers.setdefault(event_type, []).append(handler)
def publish(self, event: DomainEvent) -> None:
for handler in self._handlers.get(type(event), []):
handler(event)
// Java/Spring Boot: use ApplicationEventPublisher
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public void placeOrder(PlaceOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepository.save(order);
eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
}
}
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
notificationService.sendConfirmation(event.orderId());
}
Protocol vs ABC (Python)
| Use Protocol | Use ABC |
|---|---|
| External/3rd-party implementations | Need to enforce explicit inheritance |
| Duck-typing flexibility | Want to share default behavior |
| Testing (easier to mock) | Framework extension points |
| Ports/interfaces in hexagonal arch | Strategy hierarchies with shared logic |
# Protocol: structural subtyping — no inheritance needed
class BookStorage(Protocol):
def find_by_id(self, book_id: str) -> Book | None: ...
def save(self, book: Book) -> None: ...
# ABC: nominal subtyping + shared implementation
class MergeStrategy(ABC):
@abstractmethod
def can_handle(self, base, local, remote) -> bool: ...
@abstractmethod
def apply(self, base, local, remote) -> list[str]: ...
# Shared logic subclasses inherit
def is_safe_merge(self, base, result) -> bool:
return len(result) >= len(base) * 0.5
Cross-Cutting Concerns
Error Handling Strategy
- Domain errors: typed exceptions or Result types (not generic RuntimeException)
- Application layer: translates domain errors to user-facing messages
- Infrastructure layer: wraps external errors, never leaks them to domain
Testing Boundaries
| Layer | Test Type | Strategy |
|---|---|---|
| Domain | Unit | Pure functions, no mocks needed |
| Application | Unit | Mock repositories/services |
| Infrastructure | Integration | Real DB (Testcontainers / pytest-docker) |
| API | Integration | Full stack or MockMvc/TestClient |
Configuration
- Externalize all config (no hardcoded URLs, credentials, env-specific values)
- Domain layer never reads config — inject values via constructors
- Use typed config objects, not raw string lookups scattered through code
Type-Driven Design
Apply techniques from the type-driven-design skill alongside structural patterns. The two work together: patterns describe how components relate; type-driven design describes how to make each component's invariants compiler-enforced.
Key integration points:
- Value Object (PoEAA) → implement as a smart constructor type:
Money,Email,DateRange - Repository → use phantom/newtype IDs:
Repository[User, UserID]prevents cross-entity mixups - Domain Model → replace primitive fields with proven types:
status: OrderStatus(sum type), notstatus: string - Service Layer boundary → parse raw input into domain types at the entry point; pass proven types down
Signs the architecture needs type-driven improvements: validation logic repeated across service methods, null/None checks deep inside domain logic, runtime panics from invalid state combinations, string or int parameters that must satisfy undocumented constraints.
Decision Guide
| Situation | Pattern |
|---|---|
| Multiple implementations of same concept | Repository / Strategy pattern |
| Complex object creation | Factory / Builder |
| Cross-cutting concerns (logging, auth) | Decorator / Middleware |
| Notify other parts of system about events | Domain Events |
| Simplify complex subsystem | Facade (named service) |
| Decouple caller from implementation | Dependency Injection |
| Primitive used where domain type needed | Type-Driven Design (newtype / value object) |
| Invalid states reachable at runtime | Type-Driven Design (sum types / smart constructors) |
| Validation repeated across functions | Type-Driven Design (parse at boundary) |
Related Skills
| Skill | When to apply |
|---|---|
type-driven-design |
Make domain invariants compiler-enforced via newtypes and sum types |
design-patterns |
Apply GoF/PoEAA patterns within the architectural layer structure |
python-development |
Python-specific standards: uv, pytest, Pydantic, async, hexagonal layout |
code-spring-boot |
Spring Boot-specific layering, testing, and dependency injection patterns |
code-refactoring |
Restructure existing code toward clean architecture boundaries |
code-review |
Verify architectural decisions meet these principles before merging |
code-hotspot-analysis |
Check whether a file is already a churn/complexity hotspot before a non-trivial edit |