docs(standards): align layering docs with the port seam; split runtime vs wiring diagrams
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user