Design patterns¶
Beyond the hexagonal layout above, pydocs-mcp leans on a small set of named patterns that together explain why the codebase looks the way it does. Each one resolves a specific tradeoff and lives behind a recognizable file shape — once you spot the pattern, adding a new backend / step / service usually reduces to copying one of these.
Architectural patterns¶
Pattern |
Where in the code |
What it buys |
|---|---|---|
Hexagonal / Ports & Adapters |
|
Application code never imports a concrete |
Repository pattern |
One class per persisted entity: |
Each entity’s SQL lives in exactly one place. New columns mean editing one file. |
Unit of Work + Composite UoW |
|
Multi-store writes (chunks → vectors → multi-vectors → mapping table) commit or roll back atomically. Application services depend on |
Pipeline pattern (sklearn-shaped) |
|
YAML presets compose by name. Parallel branches, fusion, re-rankers all land as new steps without touching existing ones. |
Strategy pattern |
Chunkers ( |
Swap behavior at the boundary that needs it. Each strategy is one file. |
Composition root |
|
Wiring decisions live in exactly three files. Every other module is testable in isolation. |
Registry + decorator |
|
Extensions become YAML-addressable with one decorator. The shipped |
Substitution boundary |
|
The package works with or without the Rust extension; tests don’t have to fork by build mode. |
Null Object pattern |
|
|
Filter tree → Adapter (hexagonal seam) |
Retrieval emits backend-neutral |
No retrieval step ever imports |
Code-level idioms¶
Frozen + slots dataclasses for value objects and pipeline steps.
@dataclass(frozen=True, slots=True)is the default; mutation happens viadataclasses.replace, never in-place. This makes parallel pipeline branches safe by construction — seeretrieval/steps/parallel.py.Scratch hygiene under parallelism.
RetrieverState.scratchis the documented escape hatch for per-step coordination. Steps that may run inside aParallelStepbranch (today:TopKFilterStep,PreFilterStep,LateInteractionScorerStep) build a freshdict(state.scratch)and return viareplace(state, scratch=new_scratch)— never mutate the input’s scratch in place. The rule keeps branches from leaking into each other.Single source of truth for defaults. A module-level
_DEFAULT_X = value(e.g._DEFAULT_TOP_K = 100inlate_interaction_scorer.py) is the canonical source; field defaults,to_dictcomparisons, andfrom_dictfallbacks all reference the constant. Bumping the default touches one line, not three.Lazy imports for optional extras. Heavy / optional deps (
fast_plaid,torch,pylate) are imported strictly inside the methods that use them. A module-level_FastPlaidClsslot caches the resolution; tests monkeypatch it to exercise the import-missing branch without uninstalling the extra. Result: the default install never pays the import cost of code paths it doesn’t use.Async +
asyncio.to_threadfor CPU/I/O off the loop. MCP handlers areasync def. Blocking SQLite calls, mmap loads, and Rust PyO3 inference all get offloaded viaawait asyncio.to_thread(...). Nevertime.sleepin async code; useasyncio.sleep.One responsibility per file. One retrieval step per file under
retrieval/steps/; one ingestion stage per file underextraction/pipeline/stages/; one repository per persisted entity. Files stay short enough to hold in working memory while editing.Comments explain why, not what. Code is self-documenting for the what; comments call out non-obvious tradeoffs, workarounds, or hidden invariants. References to internal task IDs / planning artifacts die with the code that explained them — they don’t accumulate in the source tree.
Why this set, in one line each¶
Hexagonal — keep services testable; swap storage without rewriting.
Repository / UoW / Composite UoW — atomic multi-store writes without coupling the writer to which backends are present.
Pipeline / Strategy / Registry — YAML-tunable behavior without source edits; the same step composes across BM25, dense, late-interaction.
Composition root — wiring lives in three files, everything else takes closures.
Substitution boundary — Rust optional, never required.
Null Object — Protocols stay non-optional; consumers never branch on whether a backend is wired.
Filter tree → Adapter — late-interaction’s SQLite + fast-plaid coupling reuses the same seam BM25 + dense already used. No new abstraction, just one more adapter behind a contract.
The full contributor-facing rule set — naming conventions, async patterns, SSOT defaults, the MCP-API-vs-YAML rule, and the README jargon audit — lives in CLAUDE.md.