Quickstart
In about ten minutes — and without Docker — you'll have a running REST service whose handlers have no idea what's storing the data. Later you point them at Postgres by changing wiring, not code.
What you will build¶
A minimal REST service for a User aggregate:
| Method | Path | Action |
|---|---|---|
POST |
/users |
Create a user |
GET |
/users/{id} |
Get one user |
GET |
/users |
List users |
DELETE |
/users/{id} |
Delete a user |
Storage is in-memory — no Docker, no migrations. The complete, runnable file
is examples/quickstart/app.py;
the steps below build it up.
Step 1 — Create the project¶
uv init forze-quickstart
cd forze-quickstart
uv add 'forze[fastapi]'
Everything below goes into a single main.py, starting with the imports — the
essentials come from the forze and forze_kits top-level packages:
from uuid import UUID
from fastapi import FastAPI
from pydantic import computed_field
from forze import (
BaseDTO,
Document,
DocumentSpec,
DocumentWriteTypes,
ReadDocument,
build_runtime,
)
from forze_fastapi import runtime_lifespan
from forze_fastapi.exceptions import register_exception_handlers
from forze_kits import Paginated, build_document_registry, document_facade
from forze_kits.aggregates.document import DocumentIdDTO, ListRequestDTO
from forze_mock import MockDepsModule
Step 2 — Define the domain models¶
An aggregate needs a domain model, a create command, and a read
model. Document gives you id, rev, and timestamps for free.
class User(Document):
name: str
email: str | None = None
class CreateUserCmd(BaseDTO):
name: str
email: str | None = None
class ReadUser(ReadDocument):
name: str
email: str | None = None
@computed_field
@property
def email_provided(self) -> bool:
return self.email is not None
Why three types?
- Domain model — the business entity, with behaviour and invariants.
- Create command — the frozen input for
POST. - Read model — the frozen projection returned from
GET(here it adds a computedemail_provided).
Update commands come later; this quickstart skips them on purpose.
Step 3 — Declare a specification¶
The specification is the logical name —
"users" — that ties the models to their operations and, later, to adapters.
user_spec = DocumentSpec(
name="users",
read=ReadUser,
write=DocumentWriteTypes(domain=User, create_cmd=CreateUserCmd),
)
Step 4 — Build the operation registry¶
build_document_registry assembles the standard CRUD operations; freeze()
makes the registry immutable and shareable. The request/response DTOs are derived
from the spec, so you don't restate them — pass an explicit DocumentDTOs only to
override or to disable an operation.
# DTOs are derived from the spec; pass an explicit DocumentDTOs only to override.
registry = build_document_registry(user_spec).freeze()
Step 5 — Wire the runtime¶
MockDepsModule provides in-memory adapters for every contract. build_runtime
assembles an ExecutionRuntime around it. Then
document_facade(...) returns a small factory: call users() to get a fully-typed
DocumentFacade bound to the runtime's
current scope context — fresh each call, never cached across requests.
runtime = build_runtime(MockDepsModule())
# A per-call, fully-typed facade factory bound to the runtime's current context.
users = document_facade(runtime, registry, user_spec)
Step 6 — Attach the routes¶
runtime_lifespan runs the runtime inside the app's lifespan. Each route calls
users() for the facade and runs an operation — the handlers never touch HTTP:
app = FastAPI(title="Users API", lifespan=runtime_lifespan(runtime))
register_exception_handlers(app) # CoreException → HTTP (e.g. not_found → 404)
@app.post("/users")
async def create_user(cmd: CreateUserCmd) -> ReadUser:
return await users().create(cmd)
@app.get("/users/{user_id}")
async def get_user(user_id: UUID) -> ReadUser:
return await users().get(DocumentIdDTO(id=user_id))
@app.get("/users")
async def list_users(page: int = 1, size: int = 10) -> Paginated[ReadUser]:
return await users().list(ListRequestDTO(page=page, size=size))
@app.delete("/users/{user_id}", status_code=204)
async def delete_user(user_id: UUID) -> None:
await users().kill(DocumentIdDTO(id=user_id))
How context resolution works
When a facade operation runs, its registered handler asks the execution context
for the port wired to the "users" specification. The route never learns whether
that port comes from Postgres, Mongo, or an in-memory fake. The
Wiring page explains the full resolution flow.
register_exception_handlers maps a CoreException to a response, so a missing
user comes back as a 404. (Routes are hand-wired here to show the moving
parts — generated routes can
attach the CRUD endpoints from the registry instead.)
Step 7 — Run it¶
uv run uvicorn main:app --reload
Open http://localhost:8000/docs for the interactive explorer, or try it from the shell:
# Create — note the id in the response
curl -s -X POST http://127.0.0.1:8000/users \
-H 'Content-Type: application/json' \
-d '{"name": "Ada", "email": "ada@example.com"}'
curl -s http://127.0.0.1:8000/users # list
curl -s http://127.0.0.1:8000/users/<id> # get one
curl -s -X DELETE http://127.0.0.1:8000/users/<id> # delete
What you just did¶
You built a complete service without a single line of HTTP or storage code in your domain:
- A
Useraggregate with its command and read models — pure Python, no infrastructure. - A specification and a frozen operation registry — the named operations the service exposes.
- An
ExecutionRuntimewired to in-memory adapters, opened for the app's lifetime. - Routes that resolve operations from the context and return read models.
The only thing tying this to "in-memory" is MockDepsModule in Step 5. Swap it
for PostgresDepsModule + RedisDepsModule and the domain, spec, registry, and
routes don't change — that's the whole point. The
PostgreSQL integration shows the swap.
Where to go next¶
-
Understand the layers, contracts, and runtime behind what you just built.
-
Swap the in-memory adapters for real infrastructure.