docs(standards): align layering docs with the port seam; split runtime vs wiring diagrams
This commit is contained in:
@@ -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/* -> 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/* -> domain/* (services, ports, models)
|
||||
</span><span id="__span-0-2"><a id="__codelineno-0-2" name="__codelineno-0-2"></a>interfaces/* -> infra/adapters/* (only read-only value objects)
|
||||
</span><span id="__span-0-3"><a id="__codelineno-0-3" name="__codelineno-0-3"></a>domain/* -> domain/* (same layer)
|
||||
</span><span id="__span-0-4"><a id="__codelineno-0-4" name="__codelineno-0-4"></a>domain/* -> 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/* -> 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 -> everything (composition root only)
|
||||
</span><span id="__span-0-5"><a id="__codelineno-0-5" name="__codelineno-0-5"></a>domain/*/ports/* -> 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/* -> 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 -> 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/<x>/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/<resource>/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/<resource>/ports/db.py -> repository port + document model
|
||||
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2"></a>domain/<resource>/ports/service.py -> 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/<resource>/ports/db.py -> document model + create_<resource>_repo()
|
||||
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2"></a>domain/<resource>/ports/service.py -> service + get_<resource>_service()
|
||||
</span><span id="__span-2-3"><a id="__codelineno-2-3" name="__codelineno-2-3"></a>infra/db/repos/<vendor>.py -> repository adapter factory
|
||||
</span><span id="__span-2-4"><a id="__codelineno-2-4" name="__codelineno-2-4"></a>infra/adapters/service.py -> 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>
|
||||
|
||||
|
||||
@@ -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><vendor>_<kind></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>
|
||||
|
||||
|
||||
@@ -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">"item_svc"</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">"item_svc"</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>
|
||||
|
||||
@@ -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">"progress_bus"</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">"progress_bus"</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">"events"</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>
|
||||
|
||||
@@ -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">"question_svc"</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">"question_svc"</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">"chosen"</span><span class="p">:</span> <span class="n">body</span><span class="p">[</span><span class="s2">"chosen"</span><span class="p">],</span> <span class="s2">"status"</span><span class="p">:</span> <span class="s2">"resolved"</span><span class="p">,</span>
|
||||
</span><span id="__span-4-5"><a id="__codelineno-4-5" name="__codelineno-4-5"></a> <span class="s2">"resolved_at"</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">"job_id"</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">"status"</span><span class="p">:</span> <span class="s2">"pending"</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">"job_svc"</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">"job_svc"</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">"paused"</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">"pending"</span><span class="p">)</span>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
```
|
||||
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<resource>/routes.py"]
|
||||
RT --> SVC["<resource>_svc"]
|
||||
SVC --> REPO["<resource>_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/<family>"]
|
||||
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
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -553,9 +553,9 @@ live in the reference pages.</p>
|
||||
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
|
||||
|
||||
@@ -578,11 +578,11 @@ live in the reference pages.</p>
|
||||
RP --> 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 -> domain -> 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
|
||||
|
||||
@@ -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<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</code></pre>
|
||||
REPO --> MONGO
|
||||
JREPO --> MONGO
|
||||
IREPO --> 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/*<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</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><aggregate>/models.py</code> — dataclasses, enums, document shapes.</li>
|
||||
<li><code><aggregate>/ports/db.py</code> — repo contract; <code><aggregate>/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><aggregate>/ports/db.py</code> — document model + <code>create_<aggregate>_repo()</code>.</li>
|
||||
<li><code><aggregate>/ports/service.py</code> — service + <code>get_<aggregate>_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
|
||||
|
||||
|
||||
@@ -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">"item_svc"</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">"item_svc"</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">"/items"</span><span class="p">,</span> <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="s2">"name"</span><span class="p">:</span> <span class="s2">"x"</span><span class="p">})</span>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user