Interaction Patterns
Choosing the right mechanism for cross-component communication
Components in PSP don't import each other. Instead, they interact through four patterns—each suited for different scenarios. Choosing the wrong pattern leads to tight coupling, inconsistency, or poor performance.
Decision Table
| Pattern | Use When | Timing | Example |
|---|---|---|---|
| Hooks | Synchronous interception, veto/patch, policy-like checks | In-request | Budget vetoes task completion when over limit |
| Events | Async side effects, fan-out, eventual consistency | After commit | Recurrence triggers on TaskCompleted |
| Policies | Authorization, permission centralization | In-request | OwnerOnly policy guards delete operation |
| Queries by owner_id | Discovery, reporting, data export | On demand | GDPR export collects all user data |
Hooks: Synchronous Interception
Use hooks when you need to block, modify, or observe an operation as it happens:
- Veto — Block an operation (e.g., "can't complete task, you're over budget")
- Patch — Modify input before it's processed (e.g., auto-add a tag)
- Observe — Run side effects after success (e.g., log to audit)
class BudgetCheckHook: def before_complete_task(self, task, context): if self.is_over_budget(task.owner_id): return Veto("Over budget this week") return Allow()
Hooks are for request-time decisions. The calling component waits for all hooks to run. Use when you need immediate feedback or enforcement.
Events: Asynchronous Reactions
Use events when the caller doesn't need to wait for the reaction:
- Side effects — Send notification, update search index
- Fan-out — Multiple components react to one event
- Audit trail — Record what happened for compliance
@event_bus.subscribe(TaskCompleted) def on_task_completed(event: TaskCompleted): if task_has_recurrence(event.task_id): create_next_occurrence(event.task_id)
Events are for eventual consistency. The caller succeeds immediately; subscribers react later. Use when you need loose coupling and fan-out.
Policies: Centralized Authorization
Use policies when you need consistent permission checks across use cases:
class DeleteTask: def execute(self, task_id, actor): task = self.repo.get(task_id) self.policy.require(actor, "delete", task) # raises if denied self.repo.delete(task_id)
Policies keep permission logic out of use cases. An OwnerOnlyPolicy checks if the actor's ID matches the resource's owner_id.
Queries: Cross-Component Discovery
Use owner_id queries when you need to aggregate data across components:
def export_user_data(owner_id: UUID): return { "tasks": task_repo.query_by_owner(owner_id), "budget": budget_repo.query_by_owner(owner_id), "facets": facet_store.list_by_owner(owner_id), }
This is the only way to "join" data across components—query each independently with the same owner_id.
Anti-Patterns
- Hooks for async work — Hooks block the request. Don't send emails or call external APIs in hooks; emit an event instead.
- Events for validation — Events are eventual. If you need to block an invalid operation, use a hook.
- Cross-component queries in hot paths — Don't query Budget from Todos on every task fetch. Cache or use local projections.
- Importing models across components — Store IDs only. If Budget needs task info, store
task_idand subscribe to events.
Quick Decision Flowchart
response?"] Q2["Can block the
operation?"] Q3["Authorization
check?"] HOOKS["Use Hooks"] EVENTS["Use Events"] POLICY["Use Policy"] QUERY["Use Query"] Q1 -->|Yes| Q2 Q1 -->|No| EVENTS Q2 -->|Yes, veto/patch| HOOKS Q2 -->|No, just observe| Q3 Q3 -->|Yes| POLICY Q3 -->|No, aggregation| QUERY style HOOKS fill:#ecfeff,stroke:#0891b2 style EVENTS fill:#fef3c7,stroke:#d97706 style POLICY fill:#f3e8ff,stroke:#9333ea style QUERY fill:#dcfce7,stroke:#16a34a