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")
|
||||
|
||||
Reference in New Issue
Block a user