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")