Design Patterns¶
Both applications apply patterns at well-defined seams. The V2 stack inherits several from the Tkinter app — the repository and strategy seams survived the migration unchanged, which is what made porting the domain layer verbatim possible (ADR-002).
V2 patterns (Tauri + React + FastAPI)¶
| Pattern | Where | Rationale |
|---|---|---|
| Ports and adapters | application/ports/ define repository interfaces; infrastructure/db/ implements them |
Services depend on interfaces, so tests substitute fakes without a database |
| Allowlist facade | src-tauri/src/lib.rs |
The enumerated command list is the API surface. There is no generic passthrough, so the renderer cannot reach an unreviewed route |
| Supervisor | src-tauri/src/sidecar/ |
Owns the sidecar's whole lifecycle — port, token, spawn, readiness, shutdown — so no other component needs the credentials |
| DTO / contract boundary | contracts/ (Pydantic), frontend/src/contracts/generated (TypeScript) |
One schema generates both sides, so drift becomes a build failure rather than a runtime bug |
| Strategy | domain/scoring.py |
Scoring rules stay swappable and independently testable |
| Cache with explicit invalidation | frontend/src/hooks/useInvalidate.ts |
TanStack Query holds server state; mutations name the keys they invalidate (ADR-003) |
The renderer has no HTTP client at all. scripts/verify_no_renderer_http.py
enforces that as a build check rather than a convention.
Legacy patterns (Tkinter app)¶
The original app applies eight patterns, documented below with rationale and a minimal example.
MVC (Model-View-Controller)¶
Where: Full application.
Rationale: Separates display concerns (Tkinter widgets) from business logic (services) and data access (repositories). Views never import repositories.
User Action → View → Controller → Service → Repository → DB
← Model ←
View ← Controller (via EventBus or direct return)
Repository¶
Where: repositories/ — one class per entity (ProjectRepository, SessionRepository, etc.).
Rationale: Abstracts all SQL from services and controllers. Enables testing with fakes or an in-memory database without touching production code.
class ProjectRepository(BaseRepository):
def get(self, project_id: int) -> Project:
row = self._db.fetchone("SELECT * FROM project WHERE id = ?", (project_id,))
if row is None:
raise NotFoundError("Project", project_id)
return _row_to_project(row)
Strategy¶
Where: services/scoring_service.py — ScoringStrategy ABC + DefaultScoringStrategy.
Rationale: The scoring algorithm is swappable at construction time. Replace it without modifying any callers.
class ScoringStrategy(ABC):
@abstractmethod
def compute_score(
self, planned: int, completed: int,
total_milestones: int, completed_milestones: int
) -> int: ...
class DefaultScoringStrategy(ScoringStrategy):
def compute_score(self, planned, completed, total_milestones, completed_milestones):
session_score = (completed / planned * 60) if planned > 0 else 0
milestone_score = (completed_milestones / total_milestones * 40) if total_milestones > 0 else 0
return min(100, round(session_score + milestone_score))
# Inject a custom strategy:
scoring_svc = ScoringService(session_repo, milestone_repo, score_repo,
strategy=MyCustomStrategy())
Observer (EventBus)¶
Where: events/event_bus.py — used by all services and controllers.
Rationale: Decouples services from views. A service emits a named event; any interested view (via its controller) refreshes itself.
bus = EventBus.get()
# Subscribe (in a controller):
bus.subscribe(SESSION_COMPLETED, self._on_session_completed)
# Emit (in a service):
bus.emit(SESSION_COMPLETED, session_id=42, project_id=7)
Named event constants are defined in events/event_bus.py:
PROJECT_CREATED, SESSION_COMPLETED, MILESTONE_UPDATED, etc.
Singleton¶
Where: DatabaseConnection and EventBus.
Rationale: One shared database connection prevents locking conflicts and ensures all repositories operate within the same SQLite connection. The event bus must also be shared globally.
# Initialise once at startup:
db = DatabaseConnection.initialise(Path("~/.portfolio_manager/portfolio.db"))
# Access anywhere:
db = DatabaseConnection.get()
Factory (implied)¶
Where: repositories/ row-to-domain conversion functions (_row_to_project, _row_to_session, etc.).
Rationale: Centralises the mapping from raw sqlite3.Row dicts to typed domain dataclasses. One location to update when a schema column changes.
Template Method¶
Where: services/plan_service.py — PlanService.render_html().
Rationale: The HTML rendering pipeline is fixed (Markdown → HTML body → inject Mermaid script → wrap in shell), but the Markdown converter is injected and the Mermaid script is a replaceable constant.
def render_html(self, markdown_text: str) -> str:
self._md.reset()
body_html = self._md.convert(markdown_text) # step 1
return _HTML_TEMPLATE.format( # steps 2–3
mermaid_script=_MERMAID_SCRIPT,
body=body_html,
)
Transaction Context Manager¶
Where: db/connection.py — DatabaseConnection.transaction().
Rationale: Guarantees BEGIN / COMMIT / ROLLBACK discipline for all multi-step writes. Used in every repository write method.
with self._db.transaction():
self._db.execute("UPDATE session SET status = ? WHERE id = ?", ("completed", sid))
self._db.execute("INSERT INTO project_score ...")
# COMMIT on exit; ROLLBACK if any exception is raised