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

@@ -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