Building Your First Component
Step-by-step guide to creating a PSP component
Folder Structure
Every component follows the same structure:
notes/ ├── __init__.py # exports NoteModule ├── module.py # component registration │ ├── domain/ │ ├── __init__.py │ ├── models.py # entities with invariants │ ├── events.py # domain events │ └── errors.py # domain-specific errors │ ├── application/ │ ├── __init__.py │ ├── ports.py # repository interfaces │ └── use_cases/ │ ├── __init__.py │ ├── create_note.py │ ├── update_note.py │ └── delete_note.py │ └── adapters/ ├── http/ │ ├── __init__.py │ ├── router.py # FastAPI routes │ └── schemas.py # Pydantic models └── persistence/ ├── __init__.py └── repo_memory.py # in-memory implementation
Step 1: Domain Models
Start with entities. They have no framework dependencies:
from dataclasses import dataclass, field from datetime import datetime from uuid import UUID from .errors import EmptyContentError @dataclass class Note: id: UUID owner_id: UUID title: str content: str created_at: datetime updated_at: datetime def __post_init__(self): self._validate() def _validate(self): if not self.content.strip(): raise EmptyContentError() def update(self, title: str, content: str, now: datetime): self.title = title self.content = content self.updated_at = now self._validate()
class NoteError(Exception): pass class EmptyContentError(NoteError): def __init__(self): super().__init__("Note content cannot be empty") class NoteNotFoundError(NoteError): def __init__(self, note_id: UUID): super().__init__(f"Note not found: {note_id}")
Step 2: Domain Events
Define events for things that happen:
from dataclasses import dataclass from datetime import datetime from uuid import UUID @dataclass(frozen=True) class NoteCreated: note_id: UUID owner_id: UUID title: str created_at: datetime @dataclass(frozen=True) class NoteUpdated: note_id: UUID owner_id: UUID updated_at: datetime @dataclass(frozen=True) class NoteDeleted: note_id: UUID owner_id: UUID
Step 3: Ports (Interfaces)
Define what the application layer needs:
from typing import Protocol from uuid import UUID from psp.platform.query import QuerySpec from ..domain.models import Note class NoteRepository(Protocol): def get(self, note_id: UUID) -> Note | None: ... def save(self, note: Note) -> None: ... def delete(self, note_id: UUID) -> None: ... def query(self, spec: QuerySpec) -> list[Note]: ...
Step 4: Use Cases
Business operations with injected dependencies:
from dataclasses import dataclass from uuid import UUID, uuid4 from psp.platform.clock import Clock from psp.platform.eventbus import EventBus from ..ports import NoteRepository from ...domain.models import Note from ...domain.events import NoteCreated @dataclass class CreateNoteInput: owner_id: UUID title: str content: str class CreateNote: def __init__( self, repo: NoteRepository, clock: Clock, event_bus: EventBus ): self.repo = repo self.clock = clock self.event_bus = event_bus def execute(self, input: CreateNoteInput) -> Note: now = self.clock.now() note = Note( id=uuid4(), owner_id=input.owner_id, title=input.title, content=input.content, created_at=now, updated_at=now ) self.repo.save(note) self.event_bus.publish(NoteCreated( note_id=note.id, owner_id=note.owner_id, title=note.title, created_at=now )) return note
Step 5: Repository Implementation
Start with in-memory for development:
from uuid import UUID from psp.platform.query import QuerySpec, apply_filters from ...domain.models import Note class InMemoryNoteRepository: def __init__(self): self._notes: dict[UUID, Note] = {} def get(self, note_id: UUID) -> Note | None: return self._notes.get(note_id) def save(self, note: Note) -> None: self._notes[note.id] = note def delete(self, note_id: UUID) -> None: self._notes.pop(note_id, None) def query(self, spec: QuerySpec) -> list[Note]: notes = list(self._notes.values()) return apply_filters(notes, spec)
Step 6: HTTP Router
Thin layer that calls use cases:
from fastapi import APIRouter, Depends, HTTPException from uuid import UUID from .schemas import CreateNoteRequest, NoteResponse from .dependencies import get_create_note router = APIRouter(prefix="/notes", tags=["notes"]) @router.post("/", response_model=NoteResponse) def create_note( request: CreateNoteRequest, create_note: CreateNote = Depends(get_create_note) ): note = create_note.execute(CreateNoteInput( owner_id=request.owner_id, title=request.title, content=request.content )) return NoteResponse.from_domain(note)
Step 7: Module Registration
Wire everything together:
from psp.runtime.module import Module from .adapters.http.router import router from .adapters.persistence.repo_memory import InMemoryNoteRepository class NoteModule(Module): def __init__(self): self.repo = InMemoryNoteRepository() @property def name(self) -> str: return "notes" @property def routers(self): return [router] @property def capabilities(self): return {"note_repo": self.repo}
Step 8: Register in Main
Add to the application:
from psp.components.notes import NoteModule MODULES = [ # ... existing modules NoteModule(), ]
Checklist
- Domain has no framework imports
- All entities have
owner_id - Use cases inject dependencies (no globals)
- Routers are thin (parse → call → return)
- Events emitted after state committed