Component Boundaries
How components stay isolated while still working together
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.
(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:
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.
Budget subscribes to TaskCompleted events. It receives a payload with task_id and metadata, not the Task object.
Budget registers a hook on task completion. It receives primitive data (task_id, owner_id) and returns Allow/Veto.
All components reference the same Identity kernel through owner_id. This is the only "shared" concept.
What's Forbidden
- Importing models —
from todos.domain.models import Taskin 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:
@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:
@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 ))
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:
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
- 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