Files
roboco/docs/standards/architecture/design_principles.md
T
2025-12-27 21:53:38 +01:00

26 KiB

Design Principles

Foundational design principles for building maintainable, scalable software in the RoboCo system.


Table of Contents

  1. SOLID Principles
  2. DRY - Don't Repeat Yourself
  3. KISS - Keep It Simple, Stupid
  4. YAGNI - You Aren't Gonna Need It
  5. Separation of Concerns
  6. Composition Over Inheritance
  7. Fail Fast
  8. Law of Demeter
  9. Dependency Injection
  10. Immutability

SOLID Principles

ARCH-001: Single Responsibility Principle (SRP)

Severity: WARNING Principle: A class should have one, and only one, reason to change.

Each module, class, or function should do one thing well.

# Bad - Multiple responsibilities
class TaskManager:
    def create_task(self, data: TaskCreate) -> Task:
        # Creates task
        ...

    def send_notification(self, task: Task, recipient: str) -> None:
        # Sends notification
        ...

    def generate_report(self, tasks: list[Task]) -> Report:
        # Generates report
        ...

# Good - Single responsibility per class
class TaskService:
    def create(self, data: TaskCreate) -> Task:
        ...

class NotificationService:
    def send(self, notification: Notification) -> None:
        ...

class ReportService:
    def generate(self, tasks: list[Task]) -> Report:
        ...

ARCH-002: Open/Closed Principle (OCP)

Severity: WARNING Principle: Software entities should be open for extension, but closed for modification.

Design systems that can be extended without modifying existing code.

# Bad - Need to modify class for new types
class TaskProcessor:
    def process(self, task: Task) -> None:
        if task.type == "bug":
            self._process_bug(task)
        elif task.type == "feature":
            self._process_feature(task)
        elif task.type == "refactor":  # Added later - modifies existing code
            self._process_refactor(task)

# Good - Extend via new classes
from abc import ABC, abstractmethod

class TaskProcessor(ABC):
    @abstractmethod
    def process(self, task: Task) -> None:
        ...

class BugProcessor(TaskProcessor):
    def process(self, task: Task) -> None:
        ...

class FeatureProcessor(TaskProcessor):
    def process(self, task: Task) -> None:
        ...

class RefactorProcessor(TaskProcessor):  # Added without modifying existing code
    def process(self, task: Task) -> None:
        ...

# Registry pattern for extension
PROCESSORS: dict[str, type[TaskProcessor]] = {
    "bug": BugProcessor,
    "feature": FeatureProcessor,
    "refactor": RefactorProcessor,
}

def get_processor(task_type: str) -> TaskProcessor:
    return PROCESSORS[task_type]()

ARCH-003: Liskov Substitution Principle (LSP)

Severity: ERROR Principle: Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.

Derived classes must be substitutable for their base classes.

# Bad - Subclass violates base class contract
class Bird:
    def fly(self) -> None:
        print("Flying")

class Penguin(Bird):
    def fly(self) -> None:
        raise NotImplementedError("Penguins can't fly!")  # Violates LSP

# Good - Proper abstraction
from abc import ABC, abstractmethod

class Bird(ABC):
    @abstractmethod
    def move(self) -> None:
        ...

class FlyingBird(Bird):
    def move(self) -> None:
        self.fly()

    def fly(self) -> None:
        print("Flying")

class SwimmingBird(Bird):
    def move(self) -> None:
        self.swim()

    def swim(self) -> None:
        print("Swimming")

ARCH-004: Interface Segregation Principle (ISP)

Severity: WARNING Principle: Many client-specific interfaces are better than one general-purpose interface.

Don't force clients to depend on methods they don't use.

# Bad - Fat interface
class Worker(ABC):
    @abstractmethod
    def work(self) -> None: ...

    @abstractmethod
    def eat(self) -> None: ...

    @abstractmethod
    def sleep(self) -> None: ...

class Robot(Worker):
    def work(self) -> None:
        ...

    def eat(self) -> None:
        raise NotImplementedError()  # Robots don't eat

    def sleep(self) -> None:
        raise NotImplementedError()  # Robots don't sleep

# Good - Segregated interfaces
class Workable(ABC):
    @abstractmethod
    def work(self) -> None: ...

class Feedable(ABC):
    @abstractmethod
    def eat(self) -> None: ...

class Sleepable(ABC):
    @abstractmethod
    def sleep(self) -> None: ...

class Human(Workable, Feedable, Sleepable):
    def work(self) -> None: ...
    def eat(self) -> None: ...
    def sleep(self) -> None: ...

class Robot(Workable):
    def work(self) -> None: ...

ARCH-005: Dependency Inversion Principle (DIP)

Severity: ERROR Principle: Depend on abstractions, not concretions.

High-level modules should not depend on low-level modules.

# Bad - High-level depends on low-level
class PostgreSQLDatabase:
    def query(self, sql: str) -> list[dict]:
        ...

class TaskRepository:
    def __init__(self) -> None:
        self.db = PostgreSQLDatabase()  # Tight coupling

    def get_task(self, task_id: str) -> Task:
        return self.db.query(f"SELECT * FROM tasks WHERE id = '{task_id}'")

# Good - Depend on abstraction
from abc import ABC, abstractmethod

class Database(ABC):
    @abstractmethod
    async def query(self, sql: str, params: dict) -> list[dict]:
        ...

class PostgreSQLDatabase(Database):
    async def query(self, sql: str, params: dict) -> list[dict]:
        ...

class TaskRepository:
    def __init__(self, db: Database) -> None:
        self.db = db  # Depends on abstraction

    async def get_task(self, task_id: str) -> Task:
        result = await self.db.query(
            "SELECT * FROM tasks WHERE id = :id",
            {"id": task_id}
        )
        return Task(**result[0])

DRY - Don't Repeat Yourself

ARCH-010: No Code Duplication

Severity: WARNING Principle: Every piece of knowledge must have a single, unambiguous, authoritative representation.

Eliminate duplication of logic, data, and knowledge.

# Bad - Duplicated validation logic
class UserService:
    def create_user(self, email: str) -> User:
        if not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email):
            raise ValueError("Invalid email")
        ...

class InviteService:
    def send_invite(self, email: str) -> None:
        if not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email):  # Duplicated!
            raise ValueError("Invalid email")
        ...

# Good - Single source of truth
EMAIL_PATTERN = re.compile(r"^[\w\.-]+@[\w\.-]+\.\w+$")

def validate_email(email: str) -> str:
    """Validate and return email or raise ValueError."""
    if not EMAIL_PATTERN.match(email):
        raise ValueError(f"Invalid email: {email}")
    return email.lower()

class UserService:
    def create_user(self, email: str) -> User:
        validated_email = validate_email(email)
        ...

class InviteService:
    def send_invite(self, email: str) -> None:
        validated_email = validate_email(email)
        ...

ARCH-011: Extract Common Patterns

Severity: WARNING

When you see the same pattern three times, extract it.

# Bad - Repeated error handling pattern
async def get_user(user_id: str) -> User:
    try:
        result = await db.query("SELECT * FROM users WHERE id = :id", {"id": user_id})
        if not result:
            raise NotFoundError(f"User {user_id} not found")
        return User(**result[0])
    except DatabaseError as e:
        logger.error("Database error", error=str(e))
        raise

async def get_task(task_id: str) -> Task:
    try:
        result = await db.query("SELECT * FROM tasks WHERE id = :id", {"id": task_id})
        if not result:
            raise NotFoundError(f"Task {task_id} not found")
        return Task(**result[0])
    except DatabaseError as e:
        logger.error("Database error", error=str(e))
        raise

# Good - Extract common pattern
T = TypeVar('T')

async def get_by_id(
    table: str,
    id_value: str,
    model: type[T],
    entity_name: str,
) -> T:
    """Generic get-by-id with error handling."""
    try:
        result = await db.query(
            f"SELECT * FROM {table} WHERE id = :id",
            {"id": id_value}
        )
        if not result:
            raise NotFoundError(f"{entity_name} {id_value} not found")
        return model(**result[0])
    except DatabaseError as e:
        logger.error("Database error", table=table, error=str(e))
        raise

async def get_user(user_id: str) -> User:
    return await get_by_id("users", user_id, User, "User")

async def get_task(task_id: str) -> Task:
    return await get_by_id("tasks", task_id, Task, "Task")

ARCH-012: But Avoid False DRY

Severity: INFO

Not all similar code is duplicate. Don't abstract too early.

# False DRY - These look similar but serve different purposes
def format_user_name(user: User) -> str:
    return f"{user.first_name} {user.last_name}"

def format_agent_name(agent: Agent) -> str:
    return f"{agent.role}: {agent.slug}"

# Don't force these into a single function just because they both "format names"
# They have different semantics and will evolve independently

KISS - Keep It Simple, Stupid

ARCH-020: Prefer Simple Solutions

Severity: WARNING Principle: The simplest solution that works is often the best.

Avoid unnecessary complexity.

# Bad - Over-engineered solution
class TaskStatusStrategyFactory:
    _strategies: dict[str, type[TaskStatusStrategy]] = {}

    @classmethod
    def register(cls, status: str) -> Callable:
        def decorator(strategy_class: type[TaskStatusStrategy]) -> type[TaskStatusStrategy]:
            cls._strategies[status] = strategy_class
            return strategy_class
        return decorator

    @classmethod
    def create(cls, task: Task) -> TaskStatusStrategy:
        return cls._strategies[task.status]()

@TaskStatusStrategyFactory.register("pending")
class PendingStatusStrategy(TaskStatusStrategy):
    def can_transition_to(self, new_status: str) -> bool:
        return new_status in ["claimed", "cancelled"]

# Good - Simple and clear
VALID_TRANSITIONS = {
    "pending": {"claimed", "cancelled"},
    "claimed": {"in_progress", "pending"},
    "in_progress": {"completed", "blocked", "paused"},
    # ... etc
}

def can_transition(current: str, new: str) -> bool:
    return new in VALID_TRANSITIONS.get(current, set())

ARCH-021: Avoid Premature Abstraction

Severity: WARNING

Don't abstract before you have concrete requirements.

# Bad - Premature abstraction
class AbstractDataProcessor(ABC):
    @abstractmethod
    def preprocess(self, data: Any) -> Any: ...

    @abstractmethod
    def process(self, data: Any) -> Any: ...

    @abstractmethod
    def postprocess(self, data: Any) -> Any: ...

    def run(self, data: Any) -> Any:
        data = self.preprocess(data)
        data = self.process(data)
        return self.postprocess(data)

# When you only have one implementation!
class TaskDataProcessor(AbstractDataProcessor):
    def preprocess(self, data: Any) -> Any:
        return data  # Does nothing

    def process(self, data: Any) -> Any:
        return transform_task(data)

    def postprocess(self, data: Any) -> Any:
        return data  # Does nothing

# Good - Start simple, abstract when needed
def process_task_data(data: dict) -> Task:
    return transform_task(data)

# Later, when you ACTUALLY need abstraction:
# Then create the base class with proven patterns

ARCH-022: Readable Over Clever

Severity: WARNING

Code is read more often than written. Optimize for readability.

# Bad - Clever but unreadable
result = reduce(
    lambda acc, x: {**acc, x[0]: x[1]},
    filter(lambda t: t[1] > 0, map(lambda k: (k, data.get(k, 0)), keys)),
    {}
)

# Good - Clear and readable
result = {}
for key in keys:
    value = data.get(key, 0)
    if value > 0:
        result[key] = value

YAGNI - You Aren't Gonna Need It

ARCH-030: Don't Build Speculatively

Severity: WARNING Principle: Only implement features when you actually need them.

# Bad - Building for hypothetical future
class TaskService:
    def __init__(
        self,
        db: Database,
        cache: Cache,
        queue: MessageQueue,
        analytics: AnalyticsService,
        audit_log: AuditLogService,
        rate_limiter: RateLimiter,
        circuit_breaker: CircuitBreaker,
        feature_flags: FeatureFlagService,
    ) -> None:
        # Most of these aren't used yet
        ...

    def create_task(self, data: TaskCreate) -> Task:
        # Just creates a task in the database
        return self.db.create_task(data)

# Good - Only what you need now
class TaskService:
    def __init__(self, db: Database) -> None:
        self.db = db

    async def create_task(self, data: TaskCreate) -> Task:
        return await self.db.create_task(data)

# Add cache, queue, etc. when you actually need them

ARCH-031: Delete Unused Code

Severity: ERROR Tools: vulture

Remove dead code. It's not "just in case" - it's noise.

# Bad - Keeping "just in case" code
class TaskService:
    def create_task(self, data: TaskCreate) -> Task:
        ...

    # def create_task_v2(self, data: TaskCreateV2) -> Task:
    #     """New version - not sure if we'll use this"""
    #     ...

    # def _experimental_feature(self) -> None:
    #     """Might need this later"""
    #     pass

# Good - Clean codebase
class TaskService:
    def create_task(self, data: TaskCreate) -> Task:
        ...

# Use version control for history, not comments

Separation of Concerns

ARCH-040: Layer Architecture

Severity: ERROR

Organize code into distinct layers with clear responsibilities.

┌─────────────────────────────────────────┐
│           API Layer (Routes)             │  ← HTTP handling, validation
├─────────────────────────────────────────┤
│         Service Layer (Business)         │  ← Business logic, orchestration
├─────────────────────────────────────────┤
│       Repository Layer (Data Access)     │  ← Database queries, caching
├─────────────────────────────────────────┤
│           Model Layer (Domain)           │  ← Data structures, entities
└─────────────────────────────────────────┘
# Good - Clear layer separation

# models/task.py - Domain entities
class Task(BaseModel):
    id: UUID
    title: str
    status: TaskStatus

# repositories/task.py - Data access
class TaskRepository:
    async def get_by_id(self, task_id: UUID) -> Task | None:
        result = await self.db.query(...)
        return Task(**result) if result else None

# services/task.py - Business logic
class TaskService:
    def __init__(self, repo: TaskRepository, notifier: NotificationService) -> None:
        self.repo = repo
        self.notifier = notifier

    async def complete_task(self, task_id: UUID) -> Task:
        task = await self.repo.get_by_id(task_id)
        if task is None:
            raise TaskNotFoundError(task_id)

        task.status = TaskStatus.COMPLETED
        await self.repo.update(task)
        await self.notifier.notify_completion(task)
        return task

# api/routes/tasks.py - HTTP handling
@router.post("/tasks/{task_id}/complete")
async def complete_task(task_id: UUID, service: TaskService = Depends()) -> TaskResponse:
    task = await service.complete_task(task_id)
    return TaskResponse.from_orm(task)

ARCH-041: No Business Logic in Routes

Severity: ERROR

API routes should only handle HTTP concerns.

# Bad - Business logic in route
@router.post("/tasks")
async def create_task(request: TaskCreate, db: AsyncSession = Depends()) -> TaskResponse:
    # Validation
    if len(request.title) < 3:
        raise HTTPException(400, "Title too short")

    # Business logic (should be in service)
    task = Task(**request.dict())
    task.created_at = datetime.now(UTC)
    task.status = TaskStatus.PENDING

    if request.assigned_to:
        agent = await db.query(Agent).filter_by(id=request.assigned_to).first()
        if agent is None:
            raise HTTPException(400, "Agent not found")
        task.assigned_to = agent.id

    db.add(task)
    await db.commit()

    # Send notification (should be in service)
    await send_notification(task.assigned_to, f"New task: {task.title}")

    return TaskResponse.from_orm(task)

# Good - Route delegates to service
@router.post("/tasks")
async def create_task(
    request: TaskCreate,
    service: TaskService = Depends(),
) -> TaskResponse:
    task = await service.create(request)
    return TaskResponse.from_orm(task)

ARCH-042: No Database Queries in Routes

Severity: ERROR

Database access should be in repository or service layer.

# Bad - Direct DB access in route
@router.get("/tasks")
async def list_tasks(
    status: TaskStatus | None = None,
    db: AsyncSession = Depends(),
) -> list[TaskResponse]:
    query = select(Task)
    if status:
        query = query.where(Task.status == status)
    result = await db.execute(query)
    return [TaskResponse.from_orm(t) for t in result.scalars()]

# Good - Delegate to service/repository
@router.get("/tasks")
async def list_tasks(
    status: TaskStatus | None = None,
    service: TaskService = Depends(),
) -> list[TaskResponse]:
    tasks = await service.list(status=status)
    return [TaskResponse.from_orm(t) for t in tasks]

Composition Over Inheritance

ARCH-050: Prefer Composition

Severity: WARNING Principle: Favor object composition over class inheritance.

# Bad - Deep inheritance hierarchy
class BaseProcessor:
    def process(self, data: Any) -> Any:
        ...

class ValidatingProcessor(BaseProcessor):
    def process(self, data: Any) -> Any:
        self.validate(data)
        return super().process(data)

class LoggingValidatingProcessor(ValidatingProcessor):
    def process(self, data: Any) -> Any:
        self.log_start(data)
        result = super().process(data)
        self.log_end(result)
        return result

class CachingLoggingValidatingProcessor(LoggingValidatingProcessor):
    ...  # Getting ridiculous

# Good - Composition with mixins or decorators
class TaskProcessor:
    def __init__(
        self,
        validator: Validator | None = None,
        logger: Logger | None = None,
        cache: Cache | None = None,
    ) -> None:
        self.validator = validator
        self.logger = logger
        self.cache = cache

    def process(self, data: Any) -> Any:
        if self.validator:
            self.validator.validate(data)
        if self.logger:
            self.logger.log_start(data)

        result = self._do_process(data)

        if self.cache:
            self.cache.set(data.id, result)
        if self.logger:
            self.logger.log_end(result)

        return result

ARCH-051: Use Protocols Over ABC

Severity: INFO Python: Use Protocol for structural typing when possible.

# Good - Protocol-based typing
from typing import Protocol

class Repository(Protocol):
    async def get(self, id: str) -> dict | None: ...
    async def save(self, entity: dict) -> None: ...

class TaskService:
    def __init__(self, repo: Repository) -> None:
        self.repo = repo

# Any class with get/save methods works, no inheritance needed
class InMemoryRepo:
    async def get(self, id: str) -> dict | None:
        return self.data.get(id)

    async def save(self, entity: dict) -> None:
        self.data[entity["id"]] = entity

# Works with TaskService without explicit inheritance!

Fail Fast

ARCH-060: Validate Early

Severity: ERROR Principle: Detect and report errors as early as possible.

# Bad - Late failure
def process_order(order: dict) -> Receipt:
    # ... lots of processing ...

    # Fails late after doing work
    if order.get("customer_id") is None:
        raise ValueError("Missing customer_id")

    # More processing that depends on customer_id
    ...

# Good - Fail fast
def process_order(order: dict) -> Receipt:
    # Validate immediately
    if order.get("customer_id") is None:
        raise ValueError("Missing customer_id")
    if order.get("items") is None or len(order["items"]) == 0:
        raise ValueError("Order must have items")

    # Now process with confidence
    ...

ARCH-061: Use Guard Clauses

Severity: WARNING

Return early for invalid states instead of deep nesting.

# Bad - Deep nesting
def process_task(task: Task | None, agent: Agent | None) -> Result:
    if task is not None:
        if task.status == TaskStatus.PENDING:
            if agent is not None:
                if agent.can_claim(task):
                    return do_process(task, agent)
                else:
                    return Result(error="Agent cannot claim")
            else:
                return Result(error="No agent")
        else:
            return Result(error="Task not pending")
    else:
        return Result(error="No task")

# Good - Guard clauses
def process_task(task: Task | None, agent: Agent | None) -> Result:
    if task is None:
        return Result(error="No task")
    if task.status != TaskStatus.PENDING:
        return Result(error="Task not pending")
    if agent is None:
        return Result(error="No agent")
    if not agent.can_claim(task):
        return Result(error="Agent cannot claim")

    return do_process(task, agent)

Law of Demeter

ARCH-070: Don't Talk to Strangers

Severity: WARNING Principle: Only talk to immediate friends, not friends of friends.

# Bad - Chained method calls
def get_customer_city(order: Order) -> str:
    return order.customer.address.city.name

# If any of these are None, it fails
# Also tightly coupled to internal structure

# Good - Ask, don't dig
class Order:
    def get_customer_city(self) -> str:
        return self.customer.get_city_name()

class Customer:
    def get_city_name(self) -> str:
        if self.address and self.address.city:
            return self.address.city.name
        return "Unknown"

Dependency Injection

ARCH-080: Inject Dependencies

Severity: ERROR Principle: Dependencies should be provided, not created internally.

# Bad - Creates own dependencies
class TaskService:
    def __init__(self) -> None:
        self.db = PostgresDatabase()  # Hard to test
        self.cache = RedisCache()     # Hard to swap
        self.notifier = EmailNotifier()

# Good - Inject dependencies
class TaskService:
    def __init__(
        self,
        db: Database,
        cache: Cache,
        notifier: Notifier,
    ) -> None:
        self.db = db
        self.cache = cache
        self.notifier = notifier

# FastAPI dependency injection
def get_task_service(
    db: Database = Depends(get_database),
    cache: Cache = Depends(get_cache),
    notifier: Notifier = Depends(get_notifier),
) -> TaskService:
    return TaskService(db, cache, notifier)

Immutability

ARCH-090: Prefer Immutable Data

Severity: WARNING Principle: Immutable objects are easier to reason about and safer in concurrent code.

# Bad - Mutable state
class Task:
    def __init__(self, title: str) -> None:
        self.title = title
        self.tags = []  # Mutable!

task = Task("Fix bug")
task.tags.append("urgent")
task.title = "Changed!"  # Mutation

# Good - Immutable with dataclasses
from dataclasses import dataclass

@dataclass(frozen=True)
class Task:
    title: str
    tags: tuple[str, ...] = ()

    def with_tag(self, tag: str) -> "Task":
        """Return new Task with additional tag."""
        return Task(
            title=self.title,
            tags=self.tags + (tag,)
        )

task = Task("Fix bug")
task_with_tag = task.with_tag("urgent")  # Returns new instance

ARCH-091: Avoid Side Effects in Functions

Severity: WARNING

Pure functions are easier to test and reason about.

# Bad - Side effects
def process_tasks(tasks: list[Task]) -> None:
    for task in tasks:
        task.processed = True  # Mutates input!
        global_counter += 1    # Global state!
        send_notification()     # Side effect!

# Good - Pure function
def process_tasks(tasks: list[Task]) -> list[ProcessedTask]:
    return [
        ProcessedTask(
            task=task,
            processed_at=datetime.now(UTC)
        )
        for task in tasks
    ]

# Handle side effects separately
processed = process_tasks(tasks)
for p in processed:
    await notifier.send(p)

Quick Reference

Principle Severity

Principle Severity Impact
Liskov Substitution ERROR Breaks polymorphism
Dependency Inversion ERROR Prevents testing
No DB in Routes ERROR Violates layering
Validate Early ERROR Wastes resources
Inject Dependencies ERROR Untestable code
Single Responsibility WARNING Maintenance burden
Open/Closed WARNING Modification risk
DRY WARNING Bug propagation
KISS WARNING Complexity cost
YAGNI WARNING Wasted effort
Composition WARNING Rigid hierarchy
Immutability WARNING Concurrency bugs

Anti-Pattern Detection

Anti-Pattern Signs Fix
God Class Class > 500 lines Split by responsibility
Feature Envy Uses other class's data heavily Move method to that class
Shotgun Surgery One change requires many edits Consolidate related code
Primitive Obsession Many primitives for one concept Create value object
Long Parameter List > 5 parameters Use parameter object
Data Clumps Same data together often Create class for data
Speculative Generality "We might need this" Delete until needed