The Isolation Principle

Components in PSP are fully isolated. They don't share database tables, don't import each other's models, and don't make direct function calls across boundaries.

This isn't about microservices or deployment units—it's about dependency management. When Todos changes its internal structure, Budget shouldn't need to change too.

Component Isolation
%%{init: {"flowchart": {"useMaxWidth": false}}}%% graph LR TODOS["Todos
(tasks, containers)"] BUDGET["Budget
(plans, ledger)"] IDENTITY["Identity
(person, group)"] TODOS -.->|owner_id| IDENTITY BUDGET -.->|owner_id| IDENTITY TODOS x--x|"no imports"| BUDGET style TODOS fill:#ecfeff,stroke:#0891b2 style BUDGET fill:#fef3c7,stroke:#d97706 style IDENTITY fill:#f3e8ff,stroke:#9333ea

What's Allowed

Components can reference each other in limited ways:

Store IDs

Budget can store task_id as a UUID. It doesn't know what a Task is—just that this ID refers to something in Todos.

Subscribe to Events

Budget subscribes to TaskCompleted events. It receives a payload with task_id and metadata, not the Task object.

Register Hooks

Budget registers a hook on task completion. It receives primitive data (task_id, owner_id) and returns Allow/Veto.

Share owner_id

All components reference the same Identity kernel through owner_id. This is the only "shared" concept.

What's Forbidden

Never Do This
  • Importing modelsfrom todos.domain.models import Task in Budget is forbidden
  • Shared tables — No foreign keys between component tables; use UUIDs
  • Direct queries — Budget can't call task_repo.get(id); it doesn't have access to task_repo
  • Passing domain objects — Events carry primitive data (dicts, UUIDs), not entity instances

ID-Based References

When Budget needs to track which tasks have costs, it stores the task ID—nothing more:

budget/domain/models.py
@dataclass
class TaskCost:
    id: UUID
    owner_id: UUID
    task_id: UUID      # Just the ID, not a Task object
    token_type_id: UUID
    amount: int

Budget doesn't know if the task exists, what its title is, or whether it's completed. If it needs that information, it listens to events or queries Todos via HTTP (from a UI layer, not domain layer).

Local Projections

When a component needs data from another component, it builds a local projection—a denormalized copy of just the data it needs:

budget/application/projections.py
@dataclass
class TaskSummary:
    """Local copy of task data Budget cares about."""
    task_id: UUID
    title: str
    completed_at: datetime | None

@event_bus.subscribe(TaskCompleted)
def update_projection(event):
    projection_store.upsert(TaskSummary(
        task_id=event.task_id,
        title=event.title,
        completed_at=event.completed_at
    ))
Why Projections?

Projections trade storage for independence. Budget's projection might be stale by milliseconds, but Budget can query it without coupling to Todos' internals.

Boundary Enforcement

These boundaries aren't just conventions—they can be tested:

tests/test_boundaries.py
def test_no_cross_component_imports():
    """Budget must not import from Todos."""
    budget_files = Path("src/psp/components/budget").rglob("*.py")
    for file in budget_files:
        content = file.read_text()
        assert "from psp.components.todos" not in content
        assert "import psp.components.todos" not in content
Rules
  • Components MUST NOT import each other's domain models
  • Cross-component references MUST be UUIDs only
  • Events MUST carry primitive data, not domain objects
  • If you need another component's data, build a local projection