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)
budget_hook.py
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()
When to Use

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
recurrence_subscriber.py
@event_bus.subscribe(TaskCompleted)
def on_task_completed(event: TaskCompleted):
    if task_has_recurrence(event.task_id):
        create_next_occurrence(event.task_id)
When to Use

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:

delete_task.py
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:

export.py
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

Don't Do This
  • 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_id and subscribe to events.

Quick Decision Flowchart

Choosing a Pattern
%%{init: {"flowchart": {"useMaxWidth": false}}}%% graph TD Q1["Need immediate
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