docs(standards): align layering docs with the port seam; split runtime vs wiring diagrams

This commit is contained in:
2026-10-12 13:15:35 +05:30
parent 8a10c4768c
commit 7d1694148a
25 changed files with 230 additions and 159 deletions

View File

@@ -662,12 +662,14 @@
<span class="normal"><a href="#__codelineno-0-3">3</a></span>
<span class="normal"><a href="#__codelineno-0-4">4</a></span>
<span class="normal"><a href="#__codelineno-0-5">5</a></span>
<span class="normal"><a href="#__codelineno-0-6">6</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-0-1"><a id="__codelineno-0-1" name="__codelineno-0-1"></a>interfaces/* -&gt; domain/* (services, ports, models)
<span class="normal"><a href="#__codelineno-0-6">6</a></span>
<span class="normal"><a href="#__codelineno-0-7">7</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-0-1"><a id="__codelineno-0-1" name="__codelineno-0-1"></a>interfaces/* -&gt; domain/* (services, ports, models)
</span><span id="__span-0-2"><a id="__codelineno-0-2" name="__codelineno-0-2"></a>interfaces/* -&gt; infra/adapters/* (only read-only value objects)
</span><span id="__span-0-3"><a id="__codelineno-0-3" name="__codelineno-0-3"></a>domain/* -&gt; domain/* (same layer)
</span><span id="__span-0-4"><a id="__codelineno-0-4" name="__codelineno-0-4"></a>domain/* -&gt; pipelines/* (call a pipeline via a port)
</span><span id="__span-0-5"><a id="__codelineno-0-5" name="__codelineno-0-5"></a>infra/adapters/* -&gt; domain/ports/* (implement the interface)
</span><span id="__span-0-6"><a id="__codelineno-0-6" name="__codelineno-0-6"></a>main.py/init.py -&gt; everything (composition root only)
</span><span id="__span-0-5"><a id="__codelineno-0-5" name="__codelineno-0-5"></a>domain/*/ports/* -&gt; infra/* (bind adapters + libraries at the seam)
</span><span id="__span-0-6"><a id="__codelineno-0-6" name="__codelineno-0-6"></a>infra/adapters/* -&gt; domain/ports/* (implement the interface)
</span><span id="__span-0-7"><a id="__codelineno-0-7" name="__codelineno-0-7"></a>main.py/init.py -&gt; everything (composition root only)
</span></code></pre></div></td></tr></table></div>
<h2 id="forbidden-imports">Forbidden imports</h2>
<div class="language-text highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-1-1">1</a></span>
@@ -680,27 +682,32 @@
</span></code></pre></div></td></tr></table></div>
<h2 id="why">Why</h2>
<ul>
<li>A pure domain is testable without a server, a database, or an event loop.</li>
<li>The business logic is testable without a server or an event loop.</li>
<li>Adapters can be swapped (in-memory fake, real database) without touching
business logic.</li>
<li>The HTTP edge stays thin and replaceable.</li>
</ul>
<h2 id="the-portadapter-contract">The port/adapter contract</h2>
<p>A <strong>port</strong> is an abstract class or protocol declared in <code>domain/&lt;x&gt;/ports/</code>.
An <strong>adapter</strong> implements it in <code>infra/adapters/</code>. The composition root binds
the two.</p>
<p>The <code>ports/</code> package is the <strong>seam</strong> where the domain meets infrastructure.
Each <code>domain/&lt;resource&gt;/ports/</code> module declares the resource's models/document
shape and exposes <strong>factory functions</strong> that bind a concrete adapter — so a port
module <em>is</em> allowed to import from <code>infra/</code> and from the persistence libraries.
Everything outside <code>ports/</code> in <code>domain/</code> stays framework- and driver-free.</p>
<div class="language-text highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-2-1">1</a></span>
<span class="normal"><a href="#__codelineno-2-2">2</a></span>
<span class="normal"><a href="#__codelineno-2-3">3</a></span>
<span class="normal"><a href="#__codelineno-2-4">4</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-2-1"><a id="__codelineno-2-1" name="__codelineno-2-1"></a>domain/&lt;resource&gt;/ports/db.py -&gt; repository port + document model
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2"></a>domain/&lt;resource&gt;/ports/service.py -&gt; service + factory
<span class="normal"><a href="#__codelineno-2-4">4</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-2-1"><a id="__codelineno-2-1" name="__codelineno-2-1"></a>domain/&lt;resource&gt;/ports/db.py -&gt; document model + create_&lt;resource&gt;_repo()
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2"></a>domain/&lt;resource&gt;/ports/service.py -&gt; service + get_&lt;resource&gt;_service()
</span><span id="__span-2-3"><a id="__codelineno-2-3" name="__codelineno-2-3"></a>infra/db/repos/&lt;vendor&gt;.py -&gt; repository adapter factory
</span><span id="__span-2-4"><a id="__codelineno-2-4" name="__codelineno-2-4"></a>infra/adapters/service.py -&gt; generic CRUD service base
</span></code></pre></div></td></tr></table></div>
<p>The composition root calls the port factories; nothing else constructs an
adapter.</p>
<h2 id="enforcement">Enforcement</h2>
<ul>
<li>Keep every domain module importable with only the standard library and
Pydantic. If a domain import pulls in <code>fastapi</code>, the layering is broken.</li>
<li><code>domain/</code> must never import the web framework (<code>fastapi</code> / <code>starlette</code>) or
<code>interfaces/</code>. Only the <code>ports/</code> seam, <code>infra/</code>, and the composition root
touch drivers.</li>
<li>Reviewers (and CI) reject <code>import fastapi</code> inside <code>domain/</code> or <code>infra/</code>.</li>
</ul>

View File

@@ -651,7 +651,7 @@ registry</strong> and exposes it to the HTTP layer and to scripts.</p>
<li><code>progress_bus</code> — progress/event bus.</li>
<li><code>&lt;vendor&gt;_&lt;kind&gt;</code> — fetchers, clients, stores.</li>
</ul>
<p>The API stores the dict on <code>app.state.resources</code> during lifespan startup.
<p>The API stores the dict on <code>app.state.db_resources</code> during lifespan startup.
Handlers read from it; nothing imports concrete adapters.</p>
<h2 id="shape">Shape</h2>
<div class="language-python highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-0-1"> 1</a></span>
@@ -747,7 +747,7 @@ typing without importing concrete classes:</p>
<ul>
<li><code>init()</code> is <strong>idempotent</strong>.</li>
<li>The composition root is the only place that imports concrete adapters.</li>
<li>Handlers never call <code>init()</code>; they read <code>request.app.state.resources</code>.</li>
<li>Handlers never call <code>init()</code>; they read <code>request.app.state.db_resources</code>.</li>
<li>Scripts call <code>init()</code> / <code>shutdown()</code> around their work.</li>
</ul>

View File

@@ -711,7 +711,7 @@ Handlers are bound to it by name; there is no decorator router.</p>
<span class="normal"><a href="#__codelineno-1-4">4</a></span>
<span class="normal"><a href="#__codelineno-1-5">5</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1"></a><span class="nd">@asynccontextmanager</span>
</span><span id="__span-1-2"><a id="__codelineno-1-2" name="__codelineno-1-2"></a><span class="k">async</span> <span class="k">def</span><span class="w"> </span><span class="nf">lifespan</span><span class="p">(</span><span class="n">app</span><span class="p">):</span>
</span><span id="__span-1-3"><a id="__codelineno-1-3" name="__codelineno-1-3"></a> <span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">resources</span> <span class="o">=</span> <span class="k">await</span> <span class="n">init</span><span class="p">()</span>
</span><span id="__span-1-3"><a id="__codelineno-1-3" name="__codelineno-1-3"></a> <span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">db_resources</span> <span class="o">=</span> <span class="k">await</span> <span class="n">init</span><span class="p">()</span>
</span><span id="__span-1-4"><a id="__codelineno-1-4" name="__codelineno-1-4"></a> <span class="k">yield</span>
</span><span id="__span-1-5"><a id="__codelineno-1-5" name="__codelineno-1-5"></a> <span class="k">await</span> <span class="n">shutdown</span><span class="p">()</span>
</span></code></pre></div></td></tr></table></div>
@@ -729,7 +729,7 @@ can resolve <code>operationId</code> across the whole app:</p>
<div class="language-python highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"><a href="#__codelineno-3-1">1</a></span>
<span class="normal"><a href="#__codelineno-3-2">2</a></span>
<span class="normal"><a href="#__codelineno-3-3">3</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1"></a><span class="k">async</span> <span class="k">def</span><span class="w"> </span><span class="nf">create_item</span><span class="p">(</span><span class="n">request</span><span class="p">:</span> <span class="n">Request</span><span class="p">,</span> <span class="n">body</span><span class="p">:</span> <span class="nb">dict</span><span class="p">):</span>
</span><span id="__span-3-2"><a id="__codelineno-3-2" name="__codelineno-3-2"></a> <span class="n">svc</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">resources</span><span class="p">[</span><span class="s2">&quot;item_svc&quot;</span><span class="p">]</span>
</span><span id="__span-3-2"><a id="__codelineno-3-2" name="__codelineno-3-2"></a> <span class="n">svc</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">db_resources</span><span class="p">[</span><span class="s2">&quot;item_svc&quot;</span><span class="p">]</span>
</span><span id="__span-3-3"><a id="__codelineno-3-3" name="__codelineno-3-3"></a> <span class="k">return</span> <span class="k">await</span> <span class="n">svc</span><span class="o">.</span><span class="n">create</span><span class="p">(</span><span class="n">Item</span><span class="o">.</span><span class="n">model_validate</span><span class="p">(</span><span class="n">body</span><span class="p">))</span>
</span></code></pre></div></td></tr></table></div>
<h2 id="conventions">Conventions</h2>

View File

@@ -729,7 +729,7 @@ swapped for a durable broker. Emit from the orchestrator at each stage:</p>
<span class="normal"><a href="#__codelineno-4-13">13</a></span>
<span class="normal"><a href="#__codelineno-4-14">14</a></span>
<span class="normal"><a href="#__codelineno-4-15">15</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1"></a><span class="k">async</span> <span class="k">def</span><span class="w"> </span><span class="nf">stream_job_events</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="nb">id</span><span class="p">):</span>
</span><span id="__span-4-2"><a id="__codelineno-4-2" name="__codelineno-4-2"></a> <span class="n">bus</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">resources</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;progress_bus&quot;</span><span class="p">)</span>
</span><span id="__span-4-2"><a id="__codelineno-4-2" name="__codelineno-4-2"></a> <span class="n">bus</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">db_resources</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;progress_bus&quot;</span><span class="p">)</span>
</span><span id="__span-4-3"><a id="__codelineno-4-3" name="__codelineno-4-3"></a> <span class="k">if</span> <span class="n">bus</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span><span id="__span-4-4"><a id="__codelineno-4-4" name="__codelineno-4-4"></a> <span class="k">return</span> <span class="p">{</span><span class="s2">&quot;events&quot;</span><span class="p">:</span> <span class="p">[]}</span>
</span><span id="__span-4-5"><a id="__codelineno-4-5" name="__codelineno-4-5"></a>

View File

@@ -734,7 +734,7 @@ injects it into the pipeline so that stage can skip re-raising:</p>
<span class="normal"><a href="#__codelineno-4-12">12</a></span>
<span class="normal"><a href="#__codelineno-4-13">13</a></span>
<span class="normal"><a href="#__codelineno-4-14">14</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1"></a><span class="k">async</span> <span class="k">def</span><span class="w"> </span><span class="nf">resolve_question</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="nb">id</span><span class="p">,</span> <span class="n">body</span><span class="p">):</span>
</span><span id="__span-4-2"><a id="__codelineno-4-2" name="__codelineno-4-2"></a> <span class="n">svc</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">resources</span><span class="p">[</span><span class="s2">&quot;question_svc&quot;</span><span class="p">]</span>
</span><span id="__span-4-2"><a id="__codelineno-4-2" name="__codelineno-4-2"></a> <span class="n">svc</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">db_resources</span><span class="p">[</span><span class="s2">&quot;question_svc&quot;</span><span class="p">]</span>
</span><span id="__span-4-3"><a id="__codelineno-4-3" name="__codelineno-4-3"></a> <span class="n">q</span> <span class="o">=</span> <span class="k">await</span> <span class="n">svc</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="nb">id</span><span class="p">)</span>
</span><span id="__span-4-4"><a id="__codelineno-4-4" name="__codelineno-4-4"></a> <span class="k">await</span> <span class="n">svc</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="nb">id</span><span class="p">,</span> <span class="p">{</span><span class="s2">&quot;chosen&quot;</span><span class="p">:</span> <span class="n">body</span><span class="p">[</span><span class="s2">&quot;chosen&quot;</span><span class="p">],</span> <span class="s2">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;resolved&quot;</span><span class="p">,</span>
</span><span id="__span-4-5"><a id="__codelineno-4-5" name="__codelineno-4-5"></a> <span class="s2">&quot;resolved_at&quot;</span><span class="p">:</span> <span class="n">now</span><span class="p">()})</span>
@@ -742,7 +742,7 @@ injects it into the pipeline so that stage can skip re-raising:</p>
</span><span id="__span-4-7"><a id="__codelineno-4-7" name="__codelineno-4-7"></a> <span class="n">pending</span> <span class="o">=</span> <span class="k">await</span> <span class="n">svc</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="nb">filter</span><span class="o">=</span><span class="p">{</span><span class="s2">&quot;job_id&quot;</span><span class="p">:</span> <span class="n">q</span><span class="o">.</span><span class="n">job_id</span><span class="p">,</span> <span class="s2">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;pending&quot;</span><span class="p">},</span>
</span><span id="__span-4-8"><a id="__codelineno-4-8" name="__codelineno-4-8"></a> <span class="n">limit</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
</span><span id="__span-4-9"><a id="__codelineno-4-9" name="__codelineno-4-9"></a> <span class="k">if</span> <span class="ow">not</span> <span class="n">pending</span><span class="p">:</span>
</span><span id="__span-4-10"><a id="__codelineno-4-10" name="__codelineno-4-10"></a> <span class="n">jobs</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">resources</span><span class="p">[</span><span class="s2">&quot;job_svc&quot;</span><span class="p">]</span>
</span><span id="__span-4-10"><a id="__codelineno-4-10" name="__codelineno-4-10"></a> <span class="n">jobs</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">db_resources</span><span class="p">[</span><span class="s2">&quot;job_svc&quot;</span><span class="p">]</span>
</span><span id="__span-4-11"><a id="__codelineno-4-11" name="__codelineno-4-11"></a> <span class="n">job</span> <span class="o">=</span> <span class="k">await</span> <span class="n">jobs</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">q</span><span class="o">.</span><span class="n">job_id</span><span class="p">)</span>
</span><span id="__span-4-12"><a id="__codelineno-4-12" name="__codelineno-4-12"></a> <span class="k">if</span> <span class="n">job</span> <span class="ow">and</span> <span class="n">job</span><span class="o">.</span><span class="n">status</span> <span class="o">==</span> <span class="s2">&quot;paused&quot;</span><span class="p">:</span>
</span><span id="__span-4-13"><a id="__codelineno-4-13" name="__codelineno-4-13"></a> <span class="k">await</span> <span class="n">jobs</span><span class="o">.</span><span class="n">update_status</span><span class="p">(</span><span class="n">q</span><span class="o">.</span><span class="n">job_id</span><span class="p">,</span> <span class="s2">&quot;pending&quot;</span><span class="p">)</span>

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"module": "composition_root",
"content": {
"path": "composition_root",
"docstring": "# Composition Root\n\n## Summary\n\nAll wiring happens in exactly one module. It builds a **flat resource\nregistry** and exposes it to the HTTP layer and to scripts.\n\n---\n\n## The resource registry\n\n`init.py` returns a plain `dict[str, Any]`. Keys follow two conventions:\n\n- `<resource>_repo` — a repository adapter for a resource.\n- `<resource>_svc` — a service for a resource.\n\nPlus named infrastructure adaptors:\n\n- `http_cache_store` — response cache backend.\n- `progress_bus` — progress/event bus.\n- `<vendor>_<kind>` — fetchers, clients, stores.\n\nThe API stores the dict on `app.state.resources` during lifespan startup.\nHandlers read from it; nothing imports concrete adapters.\n\n## Shape\n\n```python\n_resources: dict = {}\n_initialized = False\n\nasync def init(settings=None) -> dict:\n global _initialized\n if _initialized:\n return _resources\n\n await setup_database(uri=settings.mongo_uri, db_name=settings.mongo_db)\n\n # 1. leaf repositories\n user_repo = create_user_repo()\n\n # 2. dependent repositories (dependencies passed by keyword)\n wallet_repo = create_wallet_repo(user_repo=user_repo)\n\n # 3. services (build via the DI library, injecting the repo)\n _resources[\"user_repo\"] = user_repo\n _resources[\"wallet_repo\"] = wallet_repo\n _resources[\"user_svc\"] = get_user_service(user_repo)\n _resources[\"wallet_svc\"] = get_wallet_service(wallet_repo)\n\n # 4. infrastructure adaptors (optional)\n _resources[\"http_cache_store\"] = HttpCacheStore(...)\n _resources[\"progress_bus\"] = ProgressEventBus()\n\n _initialized = True\n return _resources\n\nasync def shutdown() -> None:\n global _initialized\n await close_stores(_resources)\n await shutdown_database()\n _resources.clear()\n _initialized = False\n```\n\n## Typed accessors\n\nExpose a `get_svc(name)` helper with one `@overload` per key so scripts get\ntyping without importing concrete classes:\n\n```python\n@overload\ndef get_svc(name: Literal[\"user_svc\"]) -> UserService: ...\n@overload\ndef get_svc(name: Literal[\"wallet_svc\"]) -> WalletService: ...\n\ndef get_svc(name: str):\n return _resources[name]\n```\n\nAlso expose `get_db()` (raw database handle) and pure helpers such as\n`compute_fingerprint(payload)` (canonical hash used for deduplication).\n\n## Rules\n\n- `init()` is **idempotent**.\n- The composition root is the only place that imports concrete adapters.\n- Handlers never call `init()`; they read `request.app.state.resources`.\n- Scripts call `init()` / `shutdown()` around their work.\n",
"docstring": "# Composition Root\n\n## Summary\n\nAll wiring happens in exactly one module. It builds a **flat resource\nregistry** and exposes it to the HTTP layer and to scripts.\n\n---\n\n## The resource registry\n\n`init.py` returns a plain `dict[str, Any]`. Keys follow two conventions:\n\n- `<resource>_repo` — a repository adapter for a resource.\n- `<resource>_svc` — a service for a resource.\n\nPlus named infrastructure adaptors:\n\n- `http_cache_store` — response cache backend.\n- `progress_bus` — progress/event bus.\n- `<vendor>_<kind>` — fetchers, clients, stores.\n\nThe API stores the dict on `app.state.db_resources` during lifespan startup.\nHandlers read from it; nothing imports concrete adapters.\n\n## Shape\n\n```python\n_resources: dict = {}\n_initialized = False\n\nasync def init(settings=None) -> dict:\n global _initialized\n if _initialized:\n return _resources\n\n await setup_database(uri=settings.mongo_uri, db_name=settings.mongo_db)\n\n # 1. leaf repositories\n user_repo = create_user_repo()\n\n # 2. dependent repositories (dependencies passed by keyword)\n wallet_repo = create_wallet_repo(user_repo=user_repo)\n\n # 3. services (build via the DI library, injecting the repo)\n _resources[\"user_repo\"] = user_repo\n _resources[\"wallet_repo\"] = wallet_repo\n _resources[\"user_svc\"] = get_user_service(user_repo)\n _resources[\"wallet_svc\"] = get_wallet_service(wallet_repo)\n\n # 4. infrastructure adaptors (optional)\n _resources[\"http_cache_store\"] = HttpCacheStore(...)\n _resources[\"progress_bus\"] = ProgressEventBus()\n\n _initialized = True\n return _resources\n\nasync def shutdown() -> None:\n global _initialized\n await close_stores(_resources)\n await shutdown_database()\n _resources.clear()\n _initialized = False\n```\n\n## Typed accessors\n\nExpose a `get_svc(name)` helper with one `@overload` per key so scripts get\ntyping without importing concrete classes:\n\n```python\n@overload\ndef get_svc(name: Literal[\"user_svc\"]) -> UserService: ...\n@overload\ndef get_svc(name: Literal[\"wallet_svc\"]) -> WalletService: ...\n\ndef get_svc(name: str):\n return _resources[name]\n```\n\nAlso expose `get_db()` (raw database handle) and pure helpers such as\n`compute_fingerprint(payload)` (canonical hash used for deduplication).\n\n## Rules\n\n- `init()` is **idempotent**.\n- The composition root is the only place that imports concrete adapters.\n- Handlers never call `init()`; they read `request.app.state.db_resources`.\n- Scripts call `init()` / `shutdown()` around their work.\n",
"objects": {}
}
}

View File

@@ -2,7 +2,7 @@
"module": "human_in_loop",
"content": {
"path": "human_in_loop",
"docstring": "# Human-in-the-Loop\n\n## Summary\n\nSome inputs are ambiguous and must be resolved by a human before processing can\ncontinue. The blueprint models this as a **durable pause**: an exception is\nraised, a question is recorded, the job pauses, and later resumes.\n\n---\n\n## The mechanism\n\n1. During processing, a stage raises a domain exception carrying the context\n needed to ask the question:\n\n ```python\n raise PauseForResolution(\n job_id=jid,\n reference=\"<stable key for this decision>\",\n options=[...],\n context={...},\n )\n ```\n\n2. The orchestrator catches it, records a **question document**, and re-raises\n so the worker marks the job `paused`.\n3. A client lists pending questions and answers one:\n\n ```\n GET /jobs/{id}/questions -> list pending questions\n POST /questions/{qid}/resolve {choice} -> record the choice\n ```\n\n4. When the **last** pending question is resolved, the resolve endpoint flips\n the parent job back to `pending`.\n5. The worker picks it up again, rebuilds a **resolution map** from previously\n answered questions, and seeds it into the pipeline so the same decision is\n not asked twice.\n\n## The question document\n\n```python\nclass Question(BaseDocument):\n job_id: str\n reference: str # stable key: stage|field|value\n status: str # pending | resolved\n options: list[dict]\n context: dict\n chosen: dict | None = None\n resolved_at: str | None = None\n```\n\n## Resume map\n\nThe worker turns answered questions into a lookup keyed by `reference`, then\ninjects it into the pipeline so that stage can skip re-raising:\n\n```python\nresolved = {q.reference: q.chosen for q in await svc.search(\n filter={\"job_id\": jid, \"status\": \"resolved\"})}\n\npipeline = build(root, instances={\"pause\": handler}, config={\"resolved\": resolved})\n```\n\n## Resolve endpoint\n\n```python\nasync def resolve_question(request, id, body):\n svc = request.app.state.resources[\"question_svc\"]\n q = await svc.read(id)\n await svc.update(id, {\"chosen\": body[\"chosen\"], \"status\": \"resolved\",\n \"resolved_at\": now()})\n\n pending = await svc.search(filter={\"job_id\": q.job_id, \"status\": \"pending\"},\n limit=1)\n if not pending:\n jobs = request.app.state.resources[\"job_svc\"]\n job = await jobs.read(q.job_id)\n if job and job.status == \"paused\":\n await jobs.update_status(q.job_id, \"pending\")\n return q\n```\n\n## CLI parity\n\nThe same flow is available through scripts/CLI: `list`, `get`, `create`,\n`questions`, `resolve`, `retry`.\n",
"docstring": "# Human-in-the-Loop\n\n## Summary\n\nSome inputs are ambiguous and must be resolved by a human before processing can\ncontinue. The blueprint models this as a **durable pause**: an exception is\nraised, a question is recorded, the job pauses, and later resumes.\n\n---\n\n## The mechanism\n\n1. During processing, a stage raises a domain exception carrying the context\n needed to ask the question:\n\n ```python\n raise PauseForResolution(\n job_id=jid,\n reference=\"<stable key for this decision>\",\n options=[...],\n context={...},\n )\n ```\n\n2. The orchestrator catches it, records a **question document**, and re-raises\n so the worker marks the job `paused`.\n3. A client lists pending questions and answers one:\n\n ```\n GET /jobs/{id}/questions -> list pending questions\n POST /questions/{qid}/resolve {choice} -> record the choice\n ```\n\n4. When the **last** pending question is resolved, the resolve endpoint flips\n the parent job back to `pending`.\n5. The worker picks it up again, rebuilds a **resolution map** from previously\n answered questions, and seeds it into the pipeline so the same decision is\n not asked twice.\n\n## The question document\n\n```python\nclass Question(BaseDocument):\n job_id: str\n reference: str # stable key: stage|field|value\n status: str # pending | resolved\n options: list[dict]\n context: dict\n chosen: dict | None = None\n resolved_at: str | None = None\n```\n\n## Resume map\n\nThe worker turns answered questions into a lookup keyed by `reference`, then\ninjects it into the pipeline so that stage can skip re-raising:\n\n```python\nresolved = {q.reference: q.chosen for q in await svc.search(\n filter={\"job_id\": jid, \"status\": \"resolved\"})}\n\npipeline = build(root, instances={\"pause\": handler}, config={\"resolved\": resolved})\n```\n\n## Resolve endpoint\n\n```python\nasync def resolve_question(request, id, body):\n svc = request.app.state.db_resources[\"question_svc\"]\n q = await svc.read(id)\n await svc.update(id, {\"chosen\": body[\"chosen\"], \"status\": \"resolved\",\n \"resolved_at\": now()})\n\n pending = await svc.search(filter={\"job_id\": q.job_id, \"status\": \"pending\"},\n limit=1)\n if not pending:\n jobs = request.app.state.db_resources[\"job_svc\"]\n job = await jobs.read(q.job_id)\n if job and job.status == \"paused\":\n await jobs.update_status(q.job_id, \"pending\")\n return q\n```\n\n## CLI parity\n\nThe same flow is available through scripts/CLI: `list`, `get`, `create`,\n`questions`, `resolve`, `retry`.\n",
"objects": {}
}
}

View File

@@ -2,7 +2,7 @@
"module": "layering_rules",
"content": {
"path": "layering_rules",
"docstring": "# Layering Rules\n\n## Summary\n\nThe dependency direction is fixed. Violating it is a design defect.\n\n---\n\n## Allowed imports\n\n```\ninterfaces/* -> domain/* (services, ports, models)\ninterfaces/* -> infra/adapters/* (only read-only value objects)\ndomain/* -> domain/* (same layer)\ndomain/* -> pipelines/* (call a pipeline via a port)\ninfra/adapters/* -> domain/ports/* (implement the interface)\nmain.py/init.py -> everything (composition root only)\n```\n\n## Forbidden imports\n\n```\ndomain/* -> interfaces/* (never)\ndomain/* -> fastapi/starlette (never)\ninfra/adapters/* -> interfaces/* (never)\ninterfaces/* -> infra/db/* (state lives in resources, not imports)\n```\n\n## Why\n\n- A pure domain is testable without a server, a database, or an event loop.\n- Adapters can be swapped (in-memory fake, real database) without touching\n business logic.\n- The HTTP edge stays thin and replaceable.\n\n## The port/adapter contract\n\nA **port** is an abstract class or protocol declared in `domain/<x>/ports/`.\nAn **adapter** implements it in `infra/adapters/`. The composition root binds\nthe two.\n\n```\ndomain/<resource>/ports/db.py -> repository port + document model\ndomain/<resource>/ports/service.py -> service + factory\ninfra/db/repos/<vendor>.py -> repository adapter factory\ninfra/adapters/service.py -> generic CRUD service base\n```\n\n## Enforcement\n\n- Keep every domain module importable with only the standard library and\n Pydantic. If a domain import pulls in `fastapi`, the layering is broken.\n- Reviewers (and CI) reject `import fastapi` inside `domain/` or `infra/`.\n",
"docstring": "# Layering Rules\n\n## Summary\n\nThe dependency direction is fixed. Violating it is a design defect.\n\n---\n\n## Allowed imports\n\n```\ninterfaces/* -> domain/* (services, ports, models)\ninterfaces/* -> infra/adapters/* (only read-only value objects)\ndomain/* -> domain/* (same layer)\ndomain/* -> pipelines/* (call a pipeline via a port)\ndomain/*/ports/* -> infra/* (bind adapters + libraries at the seam)\ninfra/adapters/* -> domain/ports/* (implement the interface)\nmain.py/init.py -> everything (composition root only)\n```\n\n## Forbidden imports\n\n```\ndomain/* -> interfaces/* (never)\ndomain/* -> fastapi/starlette (never)\ninfra/adapters/* -> interfaces/* (never)\ninterfaces/* -> infra/db/* (state lives in resources, not imports)\n```\n\n## Why\n\n- The business logic is testable without a server or an event loop.\n- Adapters can be swapped (in-memory fake, real database) without touching\n business logic.\n- The HTTP edge stays thin and replaceable.\n\n## The port/adapter contract\n\nThe `ports/` package is the **seam** where the domain meets infrastructure.\nEach `domain/<resource>/ports/` module declares the resource's models/document\nshape and exposes **factory functions** that bind a concrete adapter — so a port\nmodule *is* allowed to import from `infra/` and from the persistence libraries.\nEverything outside `ports/` in `domain/` stays framework- and driver-free.\n\n```\ndomain/<resource>/ports/db.py -> document model + create_<resource>_repo()\ndomain/<resource>/ports/service.py -> service + get_<resource>_service()\ninfra/db/repos/<vendor>.py -> repository adapter factory\ninfra/adapters/service.py -> generic CRUD service base\n```\n\nThe composition root calls the port factories; nothing else constructs an\nadapter.\n\n## Enforcement\n\n- `domain/` must never import the web framework (`fastapi` / `starlette`) or\n `interfaces/`. Only the `ports/` seam, `infra/`, and the composition root\n touch drivers.\n- Reviewers (and CI) reject `import fastapi` inside `domain/` or `infra/`.\n",
"objects": {}
}
}

View File

@@ -2,7 +2,7 @@
"module": "spec_first_routing",
"content": {
"path": "spec_first_routing",
"docstring": "# Spec-First Routing\n\n## Summary\n\nThe OpenAPI document is the single source of truth for the HTTP contract.\nHandlers are bound to it by name; there is no decorator router.\n\n---\n\n## The contract\n\n- `openapi.yaml` (or `.json`) declares paths, schemas, parameters, responses,\n and security.\n- A custom `operationId` names the handler function.\n- The framework (`openapi_first`) validates requests/responses against the\n spec and dispatches to the handler whose name equals `operationId`.\n\n## App bootstrap\n\n```python\n# main.py\nfrom openapi_first.app import OpenAPIFirstApp\n\napp = OpenAPIFirstApp(\n openapi_path=\"openapi.yaml\",\n routes_module=interfaces.openapi, # module whose namespace holds handlers\n title=\"<App> API\",\n version=\"0.1.0\",\n lifespan=lifespan,\n)\n```\n\n`lifespan` calls the composition root:\n\n```python\n@asynccontextmanager\nasync def lifespan(app):\n app.state.resources = await init()\n yield\n await shutdown()\n```\n\n## Handler registry\n\n`interfaces/openapi.py` is a flat re-export of every handler, so the framework\ncan resolve `operationId` across the whole app:\n\n```python\nfrom interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n```\n\n## Handler shape\n\nHandlers are thin and synchronous with respect to business logic:\n\n```python\nasync def create_item(request: Request, body: dict):\n svc = request.app.state.resources[\"item_svc\"]\n return await svc.create(Item.model_validate(body))\n```\n\n## Conventions\n\n- `operationId` naming: `list_<resource>`, `create_<resource>`,\n `get_<resource>`, `update_<resource>`, `delete_<resource>`.\n- Read-only endpoints may use a cache decorator keyed on path + query + user.\n- Validation errors -> `400`; not found -> `404`; delete -> `204`.\n- Security is declared once in the spec and enforced by the framework; handlers\n contain no auth code.\n\n## Codegen\n\nThe `openapi_first` library can scaffold models and route stubs from the spec,\nand ships app templates (CRUD app, model app, service). Prefer codegen over\nhand-writing boilerplate.\n",
"docstring": "# Spec-First Routing\n\n## Summary\n\nThe OpenAPI document is the single source of truth for the HTTP contract.\nHandlers are bound to it by name; there is no decorator router.\n\n---\n\n## The contract\n\n- `openapi.yaml` (or `.json`) declares paths, schemas, parameters, responses,\n and security.\n- A custom `operationId` names the handler function.\n- The framework (`openapi_first`) validates requests/responses against the\n spec and dispatches to the handler whose name equals `operationId`.\n\n## App bootstrap\n\n```python\n# main.py\nfrom openapi_first.app import OpenAPIFirstApp\n\napp = OpenAPIFirstApp(\n openapi_path=\"openapi.yaml\",\n routes_module=interfaces.openapi, # module whose namespace holds handlers\n title=\"<App> API\",\n version=\"0.1.0\",\n lifespan=lifespan,\n)\n```\n\n`lifespan` calls the composition root:\n\n```python\n@asynccontextmanager\nasync def lifespan(app):\n app.state.db_resources = await init()\n yield\n await shutdown()\n```\n\n## Handler registry\n\n`interfaces/openapi.py` is a flat re-export of every handler, so the framework\ncan resolve `operationId` across the whole app:\n\n```python\nfrom interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n```\n\n## Handler shape\n\nHandlers are thin and synchronous with respect to business logic:\n\n```python\nasync def create_item(request: Request, body: dict):\n svc = request.app.state.db_resources[\"item_svc\"]\n return await svc.create(Item.model_validate(body))\n```\n\n## Conventions\n\n- `operationId` naming: `list_<resource>`, `create_<resource>`,\n `get_<resource>`, `update_<resource>`, `delete_<resource>`.\n- Read-only endpoints may use a cache decorator keyed on path + query + user.\n- Validation errors -> `400`; not found -> `404`; delete -> `204`.\n- Security is declared once in the spec and enforced by the framework; handlers\n contain no auth code.\n\n## Codegen\n\nThe `openapi_first` library can scaffold models and route stubs from the spec,\nand ships app templates (CRUD app, model app, service). Prefer codegen over\nhand-writing boilerplate.\n",
"objects": {}
}
}

View File

@@ -2,7 +2,7 @@
"module": "worker_and_progress",
"content": {
"path": "worker_and_progress",
"docstring": "# Worker & Progress\n\n## Summary\n\nLong-running work runs in a background worker. Jobs are durable documents that\nmove through an explicit status machine. Progress is streamed to clients.\n\n---\n\n## The status machine\n\n```\npending -> processing -> completed\n \\-> failed (retried up to N times)\n \\-> paused (waiting for human input)\n```\n\n- `pending` — queued, eligible for pickup.\n- `processing` — claimed by a worker.\n- `paused` — blocked on a human-in-the-loop question.\n- `failed` — error; `retry_count` increments until a max, then skipped.\n- `completed` — terminal success.\n\n## The poll loop\n\n```python\nasync def poll(resources):\n backoff = MIN_POLL\n while True:\n jobs = await job_svc.search(filter={\"status\": \"pending\"}, limit=100)\n backoff = MIN_POLL if jobs else min(backoff * 2, MAX_POLL)\n for job in jobs:\n if (job.retry_count or 0) >= MAX_RETRIES:\n continue\n await job_svc.update_status(job.id, \"processing\")\n try:\n await process(job, resources)\n except PauseForResolution:\n await job_svc.update_status(job.id, \"paused\")\n except Exception as e:\n await job_svc.update_status(\n job.id, \"failed\", error=str(e),\n retry_count=(job.retry_count or 0) + 1,\n )\n await asyncio.sleep(backoff)\n```\n\nTunables live in environment variables: `WORKER_POLL_MIN`, `WORKER_POLL_MAX`,\n`WORKER_MAX_RETRIES`.\n\n## Progress events\n\nA worker publishes structured progress events to a **bus** (a port). The HTTP\nedge exposes a streaming endpoint that replays events for a given job id.\n\n```python\nclass ProgressEvent(BaseModel):\n job_id: str\n step: str\n status: str # started | progress | completed | skipped | paused\n message: dict | None = None\n```\n\nThe bus is an in-memory adapter by default; because it is a port, it can be\nswapped for a durable broker. Emit from the orchestrator at each stage:\n\n```python\nawait self.bus.publish(ProgressEvent(job_id=jid, step=\"normalize\",\n status=\"completed\", message={\"count\": n}))\n```\n\n## Streaming endpoint\n\n```python\nasync def stream_job_events(request, id):\n bus = request.app.state.resources.get(\"progress_bus\")\n if bus is None:\n return {\"events\": []}\n\n async def gen():\n sent = 0\n while True:\n events = bus.get_events(id)\n for e in events[sent:]:\n yield f\"data: {e.model_dump_json(exclude_none=True)}\\n\\n\"\n sent += 1\n await asyncio.sleep(0.5)\n\n return StreamingResponse(gen(), media_type=\"text/event-stream\")\n```\n\n> **Known limitation.** The default in-memory bus is per-process. If the worker\n> and the API run as separate processes, events published by the worker are not\n> visible to the API. For production, back the bus with a shared broker. The\n> port boundary makes this a one-adapter change.\n",
"docstring": "# Worker & Progress\n\n## Summary\n\nLong-running work runs in a background worker. Jobs are durable documents that\nmove through an explicit status machine. Progress is streamed to clients.\n\n---\n\n## The status machine\n\n```\npending -> processing -> completed\n \\-> failed (retried up to N times)\n \\-> paused (waiting for human input)\n```\n\n- `pending` — queued, eligible for pickup.\n- `processing` — claimed by a worker.\n- `paused` — blocked on a human-in-the-loop question.\n- `failed` — error; `retry_count` increments until a max, then skipped.\n- `completed` — terminal success.\n\n## The poll loop\n\n```python\nasync def poll(resources):\n backoff = MIN_POLL\n while True:\n jobs = await job_svc.search(filter={\"status\": \"pending\"}, limit=100)\n backoff = MIN_POLL if jobs else min(backoff * 2, MAX_POLL)\n for job in jobs:\n if (job.retry_count or 0) >= MAX_RETRIES:\n continue\n await job_svc.update_status(job.id, \"processing\")\n try:\n await process(job, resources)\n except PauseForResolution:\n await job_svc.update_status(job.id, \"paused\")\n except Exception as e:\n await job_svc.update_status(\n job.id, \"failed\", error=str(e),\n retry_count=(job.retry_count or 0) + 1,\n )\n await asyncio.sleep(backoff)\n```\n\nTunables live in environment variables: `WORKER_POLL_MIN`, `WORKER_POLL_MAX`,\n`WORKER_MAX_RETRIES`.\n\n## Progress events\n\nA worker publishes structured progress events to a **bus** (a port). The HTTP\nedge exposes a streaming endpoint that replays events for a given job id.\n\n```python\nclass ProgressEvent(BaseModel):\n job_id: str\n step: str\n status: str # started | progress | completed | skipped | paused\n message: dict | None = None\n```\n\nThe bus is an in-memory adapter by default; because it is a port, it can be\nswapped for a durable broker. Emit from the orchestrator at each stage:\n\n```python\nawait self.bus.publish(ProgressEvent(job_id=jid, step=\"normalize\",\n status=\"completed\", message={\"count\": n}))\n```\n\n## Streaming endpoint\n\n```python\nasync def stream_job_events(request, id):\n bus = request.app.state.db_resources.get(\"progress_bus\")\n if bus is None:\n return {\"events\": []}\n\n async def gen():\n sent = 0\n while True:\n events = bus.get_events(id)\n for e in events[sent:]:\n yield f\"data: {e.model_dump_json(exclude_none=True)}\\n\\n\"\n sent += 1\n await asyncio.sleep(0.5)\n\n return StreamingResponse(gen(), media_type=\"text/event-stream\")\n```\n\n> **Known limitation.** The default in-memory bus is per-process. If the worker\n> and the API run as separate processes, events published by the worker are not\n> visible to the API. For production, back the bus with a shared broker. The\n> port boundary makes this a one-adapter change.\n",
"objects": {}
}
}

View File

@@ -13,6 +13,7 @@ interfaces/* -> domain/* (services, ports, models)
interfaces/* -> infra/adapters/* (only read-only value objects)
domain/* -> domain/* (same layer)
domain/* -> pipelines/* (call a pipeline via a port)
domain/*/ports/* -> infra/* (bind adapters + libraries at the seam)
infra/adapters/* -> domain/ports/* (implement the interface)
main.py/init.py -> everything (composition root only)
```
@@ -28,26 +29,32 @@ interfaces/* -> infra/db/* (state lives in resources, not imports
## Why
- A pure domain is testable without a server, a database, or an event loop.
- The business logic is testable without a server or an event loop.
- Adapters can be swapped (in-memory fake, real database) without touching
business logic.
- The HTTP edge stays thin and replaceable.
## The port/adapter contract
A **port** is an abstract class or protocol declared in `domain/<x>/ports/`.
An **adapter** implements it in `infra/adapters/`. The composition root binds
the two.
The `ports/` package is the **seam** where the domain meets infrastructure.
Each `domain/<resource>/ports/` module declares the resource's models/document
shape and exposes **factory functions** that bind a concrete adapter — so a port
module *is* allowed to import from `infra/` and from the persistence libraries.
Everything outside `ports/` in `domain/` stays framework- and driver-free.
```
domain/<resource>/ports/db.py -> repository port + document model
domain/<resource>/ports/service.py -> service + factory
domain/<resource>/ports/db.py -> document model + create_<resource>_repo()
domain/<resource>/ports/service.py -> service + get_<resource>_service()
infra/db/repos/<vendor>.py -> repository adapter factory
infra/adapters/service.py -> generic CRUD service base
```
The composition root calls the port factories; nothing else constructs an
adapter.
## Enforcement
- Keep every domain module importable with only the standard library and
Pydantic. If a domain import pulls in `fastapi`, the layering is broken.
- `domain/` must never import the web framework (`fastapi` / `starlette`) or
`interfaces/`. Only the `ports/` seam, `infra/`, and the composition root
touch drivers.
- Reviewers (and CI) reject `import fastapi` inside `domain/` or `infra/`.

View File

@@ -20,7 +20,7 @@ Plus named infrastructure adaptors:
- `progress_bus` — progress/event bus.
- `<vendor>_<kind>` — fetchers, clients, stores.
The API stores the dict on `app.state.resources` during lifespan startup.
The API stores the dict on `app.state.db_resources` during lifespan startup.
Handlers read from it; nothing imports concrete adapters.
## Shape
@@ -85,5 +85,5 @@ Also expose `get_db()` (raw database handle) and pure helpers such as
- `init()` is **idempotent**.
- The composition root is the only place that imports concrete adapters.
- Handlers never call `init()`; they read `request.app.state.resources`.
- Handlers never call `init()`; they read `request.app.state.db_resources`.
- Scripts call `init()` / `shutdown()` around their work.

View File

@@ -35,7 +35,7 @@ app = OpenAPIFirstApp(
```python
@asynccontextmanager
async def lifespan(app):
app.state.resources = await init()
app.state.db_resources = await init()
yield
await shutdown()
```
@@ -57,7 +57,7 @@ Handlers are thin and synchronous with respect to business logic:
```python
async def create_item(request: Request, body: dict):
svc = request.app.state.resources["item_svc"]
svc = request.app.state.db_resources["item_svc"]
return await svc.create(Item.model_validate(body))
```

View File

@@ -73,7 +73,7 @@ await self.bus.publish(ProgressEvent(job_id=jid, step="normalize",
```python
async def stream_job_events(request, id):
bus = request.app.state.resources.get("progress_bus")
bus = request.app.state.db_resources.get("progress_bus")
if bus is None:
return {"events": []}

View File

@@ -66,7 +66,7 @@ pipeline = build(root, instances={"pause": handler}, config={"resolved": resolve
```python
async def resolve_question(request, id, body):
svc = request.app.state.resources["question_svc"]
svc = request.app.state.db_resources["question_svc"]
q = await svc.read(id)
await svc.update(id, {"chosen": body["chosen"], "status": "resolved",
"resolved_at": now()})
@@ -74,7 +74,7 @@ async def resolve_question(request, id, body):
pending = await svc.search(filter={"job_id": q.job_id, "status": "pending"},
limit=1)
if not pending:
jobs = request.app.state.resources["job_svc"]
jobs = request.app.state.db_resources["job_svc"]
job = await jobs.read(q.job_id)
if job and job.status == "paused":
await jobs.update_status(q.job_id, "pending")

View File

@@ -17,9 +17,9 @@ flowchart LR
RT["<resource>/routes.py<br/>thin handlers"]
end
subgraph CORE["domain/ — pure business core"]
subgraph CORE["domain/ — business core"]
MD["models"]
PT["ports (abstract)"]
PT["ports (adapter binding seam)"]
SV["services"]
end
@@ -44,11 +44,11 @@ flowchart LR
```
The rule holds at every edge: code in an outer ring may depend on a ring closer
to the core, never the reverse. `domain/` never imports a web framework or a
database driver.
to the core, never the reverse. `domain/` never imports a web framework;
only its `ports/` seam reaches into `infra/` to bind adapters.
> **Legend** — solid arrows are compile-time calls; dotted arrows are the
> port/adapter contract, resolved by the composition root at startup.
> port/adapter binding, performed by the composition root at startup.
---
@@ -57,7 +57,7 @@ database driver.
| Ring | May import | Never imports |
|-------------|------------------------------------|----------------------------------|
| `interfaces/` | `domain`, framework glue (glue only) | business logic, `infra` directly |
| `domain/` | nothing but its own ports | web frameworks, drivers, `infra` |
| `domain/` | its own models + its `ports/` seam (which binds `infra`/libraries) | web frameworks, `interfaces/` |
| `infra/` | `domain` ports, drivers, libraries | — |
| `init.py` | everything (the wiring point) | — |
@@ -67,8 +67,9 @@ database driver.
1. **One-way dependencies.** `interfaces -> domain -> infra` is allowed; the
reverse is forbidden.
2. **Ports in the domain, adapters in infra.** A service depends on an abstract
port; the composition root injects a concrete adapter.
2. **Ports in the domain, adapters in infra.** A service depends on a port; the
port's factory binds the concrete adapter, and the composition root calls
those factories.
3. **One composition root.** Nothing constructs its own dependencies. All
wiring lives in one module (`init.py`) exposed as a flat resource registry.
4. **Spec-first.** The HTTP contract is declared in `openapi.yaml`; route

View File

@@ -7,57 +7,75 @@ blueprint's component model — `templates/app/` is a runnable instance of it.
## 🗂️ Component model
The blueprint has two orthogonal views: **runtime flow** (who calls whom) and
**composition-root wiring** (who constructs whom). Keeping them separate makes
the layering visible instead of hiding it behind a hub.
### 🔀 Runtime flow
Who calls whom at request and job time:
```mermaid
%%{init: {"flowchart": {"nodeSpacing": 55, "rankSpacing": 120}}}%%
%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 80}}}%%
flowchart TB
subgraph EDGE["interfaces/ — HTTP edge"]
OAS["openapi.yaml"]
RU["resource routes"]
JU["job routes"]
subgraph REQ["Request path — API process"]
direction LR
CL(["Client<br/>operationId"]) --> RT["interfaces/&lt;resource&gt;/routes.py"]
RT --> SVC["&lt;resource&gt;_svc"]
SVC --> REPO["&lt;resource&gt;_repo"]
end
subgraph CORE["domain/ — business core"]
RI["<resource> · models · ports · services"]
JI["<job/worker> · models · ports · services"]
PI["pipelines/<family>"]
subgraph WRK["Background path — worker process"]
direction LR
W["worker.py"] --> JSVC["job_svc"]
W --> PIPE["pipelines/&lt;family&gt;"]
PIPE --> ISVC["item_svc"]
JSVC --> JREPO["job_repo"]
ISVC --> IREPO["item_repo"]
end
subgraph INFRA["infra/ — adapters"]
DB[("MongoDB repos")]
CA["redis cache"]
ST["stores · progress bus"]
LL["llm clients"]
MONGO[("MongoDB / mongo_ops")]
end
W["worker.py"]
CR["init.py — composition root"]
OAS --> RU
OAS --> JU
RU --> RI
JU --> JI
JI --> PI
PI --> DB
PI --> LL
RI --> CA
RI --> DB
JI --> ST
W --> JI
W --> PI
CR === RU
CR === JU
CR === RI
CR === JI
CR === PI
CR === W
CR === DB
CR === CA
CR === ST
REPO --> MONGO
JREPO --> MONGO
IREPO --> MONGO
```
> **Legend** — solid arrows are direct calls; thick links are wires created by
> the composition root. Every component is injected, never self-constructed.
> **Legend** — solid arrows are runtime calls. Nothing is self-constructed.
### 🧵 Composition-root wiring
Who constructs what at startup:
```mermaid
%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 80}}}%%
flowchart TB
INIT["init.py — composition root"]
SETUP["infra/db/setup.py"]
FACT["domain/*/ports/*<br/>factory seams"]
REPOS["repos<br/>infra/db/repos/mongo.py"]
SVCES["services<br/>CrudsServiceAdaptor"]
STATE["app.state.db_resources"]
HEXA["hexa.build"]
RT["routes.py"]
WK["worker.py"]
INIT --> SETUP
INIT --> FACT
FACT --> REPOS
FACT --> SVCES
REPOS --> SVCES
HEXA -.-> SVCES
INIT --> STATE
STATE --> RT
INIT --> WK
```
> **Legend** — `init.py` is the only place that imports concrete adapters. The
> `ports/` factory seams bind `infra`; the API reads resources from
> `app.state.db_resources`, the worker calls `init()` directly.
---
@@ -74,14 +92,15 @@ call a service, and shape the response. No business rules, no I/O.
### 2. `domain/` — the core
Pure models, **ports** (abstract contracts), and services (use cases). No
framework imports, no drivers.
Models, ports, and services (use cases). No framework imports. The `ports/`
package is the one seam allowed to reach into `infra/`: each port module declares
the document shape and a factory that binds the adapter.
- `<aggregate>/models.py` — dataclasses, enums, document shapes.
- `<aggregate>/ports/db.py` — repo contract; `<aggregate>/ports/service.py` —
service contract.
- Services compose ports and enforce the business rules; the composition root
injects the implementations.
- `<aggregate>/ports/db.py` — document model + `create_<aggregate>_repo()`.
- `<aggregate>/ports/service.py` — service + `get_<aggregate>_service(repo)`.
- Services compose ports and enforce the business rules; the port factories bind
the implementations, and the composition root calls them.
### 3. `infra/` — the adapters
@@ -117,7 +136,7 @@ stops resources on the app lifespan.
sequenceDiagram
participant C as Client
participant R as route (interfaces)
participant S as service (domain)
participant S as service (port factory / adapter)
participant P as repo (infra)
participant DB as MongoDB

View File

@@ -20,7 +20,7 @@ tests/
## 🚀 Integration tests
- Build the app with `TestClient`.
- Replace `app.state.resources` with fake repositories/services so no database
- Replace `app.state.db_resources` with fake repositories/services so no database
is required.
- Patch the auth introspection call (`httpx.AsyncClient.post`) to return a
canned identity.
@@ -29,7 +29,7 @@ tests/
```python
client = TestClient(app)
app.state.resources = {"item_svc": FakeItemService(), ...}
app.state.db_resources = {"item_svc": FakeItemService(), ...}
def test_create_item():
r = client.post("/items", json={"name": "x"})

View File

@@ -1,8 +1,8 @@
# 🧩 Standards — the Aetos App Blueprint
One reusable architecture for every backend application in the Aetos ecosystem:
a spec-first HTTP edge, a pure domain core, a pluggable infrastructure layer,
and a single composition root.
a spec-first HTTP edge, a framework-free domain core, a pluggable infrastructure
layer, and a single composition root.
Fill in **two axes only** and a new app is done: your **resource model**, and
(if needed) your **pipeline family**. The blueprint itself never names a

View File

@@ -553,9 +553,9 @@ live in the reference pages.</p>
RT["&lt;resource&gt;/routes.py&lt;br/&gt;thin handlers"]
end
subgraph CORE["domain/ — pure business core"]
subgraph CORE["domain/ — business core"]
MD["models"]
PT["ports (abstract)"]
PT["ports (adapter binding seam)"]
SV["services"]
end
@@ -578,11 +578,11 @@ live in the reference pages.</p>
RP --&gt; DB
AD --- CR</code></pre>
<p>The rule holds at every edge: code in an outer ring may depend on a ring closer
to the core, never the reverse. <code>domain/</code> never imports a web framework or a
database driver.</p>
to the core, never the reverse. <code>domain/</code> never imports a web framework;
only its <code>ports/</code> seam reaches into <code>infra/</code> to bind adapters.</p>
<blockquote>
<p><strong>Legend</strong> — solid arrows are compile-time calls; dotted arrows are the
port/adapter contract, resolved by the composition root at startup.</p>
port/adapter binding, performed by the composition root at startup.</p>
</blockquote>
<hr />
<h2 id="the-one-way-dependency-rule">🔄 The one-way dependency rule</h2>
@@ -602,8 +602,8 @@ port/adapter contract, resolved by the composition root at startup.</p>
</tr>
<tr>
<td><code>domain/</code></td>
<td>nothing but its own ports</td>
<td>web frameworks, drivers, <code>infra</code></td>
<td>its own models + its <code>ports/</code> seam (which binds <code>infra</code>/libraries)</td>
<td>web frameworks, <code>interfaces/</code></td>
</tr>
<tr>
<td><code>infra/</code></td>
@@ -622,8 +622,9 @@ port/adapter contract, resolved by the composition root at startup.</p>
<ol>
<li><strong>One-way dependencies.</strong> <code>interfaces -&gt; domain -&gt; infra</code> is allowed; the
reverse is forbidden.</li>
<li><strong>Ports in the domain, adapters in infra.</strong> A service depends on an abstract
port; the composition root injects a concrete adapter.</li>
<li><strong>Ports in the domain, adapters in infra.</strong> A service depends on a port; the
port's factory binds the concrete adapter, and the composition root calls
those factories.</li>
<li><strong>One composition root.</strong> Nothing constructs its own dependencies. All
wiring lives in one module (<code>init.py</code>) exposed as a flat resource registry.</li>
<li><strong>Spec-first.</strong> The HTTP contract is declared in <code>openapi.yaml</code>; route

View File

@@ -433,6 +433,30 @@
</span>
</a>
<nav class="md-nav" aria-label="🗂️ Component model">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#runtime-flow" class="md-nav__link">
<span class="md-ellipsis">
🔀 Runtime flow
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#composition-root-wiring" class="md-nav__link">
<span class="md-ellipsis">
🧵 Composition-root wiring
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
@@ -595,55 +619,66 @@
blueprint's component model — <code>templates/app/</code> is a runnable instance of it.</p>
<hr />
<h2 id="component-model">🗂️ Component model</h2>
<pre class="mermaid"><code>%%{init: {"flowchart": {"nodeSpacing": 55, "rankSpacing": 120}}}%%
<p>The blueprint has two orthogonal views: <strong>runtime flow</strong> (who calls whom) and
<strong>composition-root wiring</strong> (who constructs whom). Keeping them separate makes
the layering visible instead of hiding it behind a hub.</p>
<h3 id="runtime-flow">🔀 Runtime flow</h3>
<p>Who calls whom at request and job time:</p>
<pre class="mermaid"><code>%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 80}}}%%
flowchart TB
subgraph EDGE["interfaces/ — HTTP edge"]
OAS["openapi.yaml"]
RU["resource routes"]
JU["job routes"]
subgraph REQ["Request path — API process"]
direction LR
CL(["Client&lt;br/&gt;operationId"]) --&gt; RT["interfaces/&amp;lt;resource&amp;gt;/routes.py"]
RT --&gt; SVC["&amp;lt;resource&amp;gt;_svc"]
SVC --&gt; REPO["&amp;lt;resource&amp;gt;_repo"]
end
subgraph CORE["domain/ — business core"]
RI["&lt;resource&gt; · models · ports · services"]
JI["&lt;job/worker&gt; · models · ports · services"]
PI["pipelines/&lt;family&gt;"]
subgraph WRK["Background path — worker process"]
direction LR
W["worker.py"] --&gt; JSVC["job_svc"]
W --&gt; PIPE["pipelines/&amp;lt;family&amp;gt;"]
PIPE --&gt; ISVC["item_svc"]
JSVC --&gt; JREPO["job_repo"]
ISVC --&gt; IREPO["item_repo"]
end
subgraph INFRA["infra/ — adapters"]
DB[("MongoDB repos")]
CA["redis cache"]
ST["stores · progress bus"]
LL["llm clients"]
MONGO[("MongoDB / mongo_ops")]
end
W["worker.py"]
CR["init.py — composition root"]
OAS --&gt; RU
OAS --&gt; JU
RU --&gt; RI
JU --&gt; JI
JI --&gt; PI
PI --&gt; DB
PI --&gt; LL
RI --&gt; CA
RI --&gt; DB
JI --&gt; ST
W --&gt; JI
W --&gt; PI
CR === RU
CR === JU
CR === RI
CR === JI
CR === PI
CR === W
CR === DB
CR === CA
CR === ST</code></pre>
REPO --&gt; MONGO
JREPO --&gt; MONGO
IREPO --&gt; MONGO</code></pre>
<blockquote>
<p><strong>Legend</strong> — solid arrows are direct calls; thick links are wires created by
the composition root. Every component is injected, never self-constructed.</p>
<p><strong>Legend</strong> — solid arrows are runtime calls. Nothing is self-constructed.</p>
</blockquote>
<h3 id="composition-root-wiring">🧵 Composition-root wiring</h3>
<p>Who constructs what at startup:</p>
<pre class="mermaid"><code>%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 80}}}%%
flowchart TB
INIT["init.py — composition root"]
SETUP["infra/db/setup.py"]
FACT["domain/*/ports/*&lt;br/&gt;factory seams"]
REPOS["repos&lt;br/&gt;infra/db/repos/mongo.py"]
SVCES["services&lt;br/&gt;CrudsServiceAdaptor"]
STATE["app.state.db_resources"]
HEXA["hexa.build"]
RT["routes.py"]
WK["worker.py"]
INIT --&gt; SETUP
INIT --&gt; FACT
FACT --&gt; REPOS
FACT --&gt; SVCES
REPOS --&gt; SVCES
HEXA -.-&gt; SVCES
INIT --&gt; STATE
STATE --&gt; RT
INIT --&gt; WK</code></pre>
<blockquote>
<p><strong>Legend</strong> — <code>init.py</code> is the only place that imports concrete adapters. The
<code>ports/</code> factory seams bind <code>infra</code>; the API reads resources from
<code>app.state.db_resources</code>, the worker calls <code>init()</code> directly.</p>
</blockquote>
<hr />
<h2 id="component-roles">📦 Component roles</h2>
@@ -656,14 +691,15 @@ call a service, and shape the response. No business rules, no I/O.</p>
<li>Routes may mount the <code>cache</code> decorator on read endpoints.</li>
</ul>
<h3 id="2-domain-the-core">2. <code>domain/</code> — the core</h3>
<p>Pure models, <strong>ports</strong> (abstract contracts), and services (use cases). No
framework imports, no drivers.</p>
<p>Models, ports, and services (use cases). No framework imports. The <code>ports/</code>
package is the one seam allowed to reach into <code>infra/</code>: each port module declares
the document shape and a factory that binds the adapter.</p>
<ul>
<li><code>&lt;aggregate&gt;/models.py</code> — dataclasses, enums, document shapes.</li>
<li><code>&lt;aggregate&gt;/ports/db.py</code> — repo contract; <code>&lt;aggregate&gt;/ports/service.py</code> —
service contract.</li>
<li>Services compose ports and enforce the business rules; the composition root
injects the implementations.</li>
<li><code>&lt;aggregate&gt;/ports/db.py</code> — document model + <code>create_&lt;aggregate&gt;_repo()</code>.</li>
<li><code>&lt;aggregate&gt;/ports/service.py</code> — service + <code>get_&lt;aggregate&gt;_service(repo)</code>.</li>
<li>Services compose ports and enforce the business rules; the port factories bind
the implementations, and the composition root calls them.</li>
</ul>
<h3 id="3-infra-the-adapters">3. <code>infra/</code> — the adapters</h3>
<p>Concrete implementations of the domain ports plus cross-cutting infrastructure:</p>
@@ -689,7 +725,7 @@ stops resources on the app lifespan.</p>
<pre class="mermaid"><code>sequenceDiagram
participant C as Client
participant R as route (interfaces)
participant S as service (domain)
participant S as service (port factory / adapter)
participant P as repo (infra)
participant DB as MongoDB

View File

@@ -573,7 +573,7 @@ blueprint app only when every gate below passes.</p>
<h2 id="integration-tests">🚀 Integration tests</h2>
<ul>
<li>Build the app with <code>TestClient</code>.</li>
<li>Replace <code>app.state.resources</code> with fake repositories/services so no database
<li>Replace <code>app.state.db_resources</code> with fake repositories/services so no database
is required.</li>
<li>Patch the auth introspection call (<code>httpx.AsyncClient.post</code>) to return a
canned identity.</li>
@@ -586,7 +586,7 @@ blueprint app only when every gate below passes.</p>
<span class="normal"><a href="#__codelineno-1-4">4</a></span>
<span class="normal"><a href="#__codelineno-1-5">5</a></span>
<span class="normal"><a href="#__codelineno-1-6">6</a></span></pre></div></td><td class="code"><div><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1"></a><span class="n">client</span> <span class="o">=</span> <span class="n">TestClient</span><span class="p">(</span><span class="n">app</span><span class="p">)</span>
</span><span id="__span-1-2"><a id="__codelineno-1-2" name="__codelineno-1-2"></a><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">resources</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&quot;item_svc&quot;</span><span class="p">:</span> <span class="n">FakeItemService</span><span class="p">(),</span> <span class="o">...</span><span class="p">}</span>
</span><span id="__span-1-2"><a id="__codelineno-1-2" name="__codelineno-1-2"></a><span class="n">app</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">db_resources</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&quot;item_svc&quot;</span><span class="p">:</span> <span class="n">FakeItemService</span><span class="p">(),</span> <span class="o">...</span><span class="p">}</span>
</span><span id="__span-1-3"><a id="__codelineno-1-3" name="__codelineno-1-3"></a>
</span><span id="__span-1-4"><a id="__codelineno-1-4" name="__codelineno-1-4"></a><span class="k">def</span><span class="w"> </span><span class="nf">test_create_item</span><span class="p">():</span>
</span><span id="__span-1-5"><a id="__codelineno-1-5" name="__codelineno-1-5"></a> <span class="n">r</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">post</span><span class="p">(</span><span class="s2">&quot;/items&quot;</span><span class="p">,</span> <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="s2">&quot;name&quot;</span><span class="p">:</span> <span class="s2">&quot;x&quot;</span><span class="p">})</span>

View File

@@ -521,8 +521,8 @@
<h1 id="standards-the-aetos-app-blueprint">🧩 Standards — the Aetos App Blueprint</h1>
<p>One reusable architecture for every backend application in the Aetos ecosystem:
a spec-first HTTP edge, a pure domain core, a pluggable infrastructure layer,
and a single composition root.</p>
a spec-first HTTP edge, a framework-free domain core, a pluggable infrastructure
layer, and a single composition root.</p>
<p>Fill in <strong>two axes only</strong> and a new app is done: your <strong>resource model</strong>, and
(if needed) your <strong>pipeline family</strong>. The blueprint itself never names a
business domain — expenses, health metrics, inventory, and analytics are all

File diff suppressed because one or more lines are too long