Build a guestbook¶
In this tutorial you build the guestbook — the smallest feature that still exercises both of DIZZY's loops — from an empty directory. By the end of these first steps you'll have described the feature and generated typed schemas from it.
sign_guestbook ─▶ record_signature ─▶ guestbook_signed (reactivity loop)
guestbook_signed ─▶ signature_store ─▶ guestbook ─▶ list_signatures (data loop)
A visitor signs the guestbook (a command). A procedure validates it and emits a fact (an event). A projection folds that fact into a model (a read table). A query reads it back out.
Validated tutorial
Every command and file on this page is executed and checked by
just tutorials-check (via byexample),
so it cannot silently drift from the tool. Lines beginning with $ are commands you
run; lines beginning with > continue the command above (a shell heredoc). Don't
copy the $/> markers themselves.
Before you start¶
You need DIZZY installed:
$ dizzy --help | head -n 1
Work in a fresh directory. This tutorial's assets — the feature-file, demo.py, and the
patches it applies to generated files — ship under the
tutorial source;
grab that folder to follow along, or just copy each block by hand as you go:
$ ls -1
demo.py
edits
guestbook.feat.yaml
Step 1 — Describe the feature¶
The feature-file is the single source of truth: it declares every component of the
domain in one readable artifact. Create guestbook.feat.yaml with this content (it also
ships alongside the tutorial, so it's already in your working directory if you grabbed
the folder):
description: Guestbook — visitors sign, signatures are stored and listed
# A write intent: "please record this signature"
commands:
sign_guestbook: A visitor wants to leave a signature
# An immutable fact: "a signature was recorded"
events:
guestbook_signed: A visitor signed the guestbook
# Business logic: handle the command, emit the fact
procedures:
record_signature:
description: Validate the signature and record it as a fact
command: sign_guestbook
emits:
- guestbook_signed
# A read-optimized schema (its classes are authored in def/models/guestbook.yaml)
models:
guestbook:
description: Stored guestbook signatures
adapters:
- sqla
# Build read state: when a signature happens, write it into the guestbook model
projections:
signature_store:
description: Persist each signature into the guestbook model
event: guestbook_signed
model: guestbook
adapter: sqla
# Read state back out
queries:
list_signatures:
description: List all guestbook signatures, newest first
model: guestbook
adapter: sqla
That's the whole design. Each entry names a component and, where it matters, how the
components connect (record_signature handles sign_guestbook and emits
guestbook_signed; signature_store folds guestbook_signed into the guestbook
model). Names are snake_case; LinkML will compile them to PascalCase classes later.
A quick sanity check that the file is in place:
$ head -n 1 guestbook.feat.yaml
description: Guestbook — visitors sign, signatures are stored and listed
Step 2 — Scaffold the schemas¶
dizzy generate definitions reads the feature-file and writes LinkML schema stubs
into def/, plus a libconfig.yaml that assigns a runtime to each element:
$ dizzy generate definitions guestbook.feat.yaml .
Generated def/ stubs and libconfig.yaml. Next steps:
<...>
Look at what it produced:
$ ls -1 def
commands.yaml
events.yaml
models
queries
The scaffolds are intentionally empty where you must decide the shape. Open the
command schema and you'll see its attributes left blank for you to fill:
$ cat def/commands.yaml
id: https://example.org/commands
name: commands
prefixes:
linkml: https://w3id.org/linkml/
default_range: string
imports:
- linkml:types
classes:
sign_guestbook:
description: A visitor wants to leave a signature
attributes: {}
Step 3 — Fill in the schema (patching generated files)¶
This is the heart of the workflow: the generator scaffolds structure, and you author
the field-level detail. The files don't start empty — the scaffold gave each class
everything except the fields, leaving attributes: {} for you. A sign_guestbook
command needs a visitor name and a message, so edit def/commands.yaml:
--- a/def/commands.yaml
+++ b/def/commands.yaml
@@ -8,4 +8,10 @@ imports:
classes:
sign_guestbook:
description: A visitor wants to leave a signature
- attributes: {}
+ attributes:
+ visitor_name:
+ range: string
+ required: true
+ message:
+ range: string
+ required: true
Each change in this tutorial ships as a patch under edits/, so you can apply it
directly (or just make the highlighted edit by hand):
$ git apply edits/commands.yaml.diff
$ cat def/commands.yaml
id: https://example.org/commands
name: commands
prefixes:
linkml: https://w3id.org/linkml/
default_range: string
imports:
- linkml:types
classes:
sign_guestbook:
description: A visitor wants to leave a signature
attributes:
visitor_name:
range: string
required: true
message:
range: string
required: true
Do the same for the event. An event is an immutable fact, so it must carry everything needed to replay it — its own id and a timestamp, not just the user-supplied fields:
--- a/def/events.yaml
+++ b/def/events.yaml
@@ -8,4 +8,16 @@ imports:
classes:
guestbook_signed:
description: A visitor signed the guestbook
- attributes: {}
+ attributes:
+ signature_id:
+ range: string
+ required: true
+ visitor_name:
+ range: string
+ required: true
+ message:
+ range: string
+ required: true
+ signed_at:
+ range: datetime
+ required: true
$ git apply edits/events.yaml.diff
The model is the read-optimized table the projection will write into. Its scaffold
starts at classes: {}; give it a Signature class with an identifier:
--- a/def/models/guestbook.yaml
+++ b/def/models/guestbook.yaml
@@ -6,4 +6,20 @@ prefixes:
default_range: string
imports:
- linkml:types
-classes: {}
+classes:
+ Signature:
+ description: A single stored guestbook signature
+ attributes:
+ id:
+ range: string
+ required: true
+ identifier: true
+ visitor_name:
+ range: string
+ required: true
+ message:
+ range: string
+ required: true
+ signed_at:
+ range: datetime
+ required: true
And the query needs the shape of its input and output — here, an optional limit in
and a list of formatted lines out:
--- a/def/queries/list_signatures.yaml
+++ b/def/queries/list_signatures.yaml
@@ -9,7 +9,14 @@ imports:
classes:
ListSignaturesInput:
description: Input for list_signatures
- attributes: {}
+ attributes:
+ limit:
+ range: integer
+ required: false
ListSignaturesOutput:
description: Output for list_signatures
- attributes: {}
+ attributes:
+ signatures:
+ range: string
+ multivalued: true
+ description: 'Signatures rendered as "name: message", newest first'
Apply both:
$ git apply edits/guestbook.yaml.diff edits/list_signatures.yaml.diff
Re-running dizzy generate definitions now is safe — it never clobbers files you've
edited, so your attributes survive:
$ dizzy generate definitions guestbook.feat.yaml .
Generated def/ stubs and libconfig.yaml. Next steps:
<...>
$ grep -c 'visitor_name' def/commands.yaml
1
Your hand-authored attributes are still there.
Step 4 — Compile the type packages¶
dizzy generate static runs LinkML over def/ to produce gen_def (Pydantic +
SQLAlchemy classes) and gen_int (typed protocols, contexts, and adapters). Both
land under lib/python-uv/ as installable packages:
$ dizzy generate static guestbook.feat.yaml .
<...>
$ ls -1 lib/python-uv
gen_def
gen_int
These are generated, not authored — you never edit them. They're the typed contracts the next step builds against.
Step 5 — Package each element¶
dizzy generate libraries reads libconfig.yaml (every element targets python-uv here)
and emits one redistributable package per element, plus the workspace pyproject.toml
that ties them and the type packages together:
$ dizzy generate libraries guestbook.feat.yaml .
<...>
$ ls -1 lib/python-uv
gen_def
gen_int
procedure
projection
pyproject.toml
query
Each element package carries a real-signature implementation stub in src/<name>.py
that raises NotImplementedError — the typed shape is there, the logic is yours to write:
$ cat lib/python-uv/procedure/record_signature/src/record_signature.py
# Implementation stub — fill in your logic here
<...>
raise NotImplementedError
The stub already has the right typed signature — context and command, both generated
types — and leaves the body to you. You'll see the full original in the next step's diff.
Step 6 — Implement the stubs¶
Three elements carry logic: the procedure turns a command into an event, the
projection folds the event into the model, and the query reads it back. Fill them
in — each diff replaces the NotImplementedError stub with a real body.
The procedure stamps identity and time (so the event is a self-contained fact) and emits it:
--- a/lib/python-uv/procedure/record_signature/src/record_signature.py
+++ b/lib/python-uv/procedure/record_signature/src/record_signature.py
@@ -1,11 +1,27 @@
-# Implementation stub — fill in your logic here
-from gen_int.python.procedure.record_signature_protocol import record_signature_protocol
+# Implementation — turn a SignGuestbook command into a GuestbookSigned fact.
+from datetime import datetime, timezone
+from uuid import uuid4
+
from gen_int.python.procedure.record_signature_context import record_signature_context
from gen_def.pydantic.commands import SignGuestbook
+from gen_def.pydantic.events import GuestbookSigned
def record_signature(
context: record_signature_context,
command: SignGuestbook,
) -> None:
- raise NotImplementedError
+ # Business rule: a signature must carry a non-empty name.
+ if not command.visitor_name.strip():
+ raise ValueError("visitor_name must not be empty")
+
+ # Stamp identity + time here, in the procedure — events are immutable facts,
+ # so everything needed to replay them must be baked in at emit time.
+ context.emit.guestbook_signed(
+ GuestbookSigned(
+ signature_id=str(uuid4()),
+ visitor_name=command.visitor_name.strip(),
+ message=command.message.strip(),
+ signed_at=datetime.now(timezone.utc),
+ )
+ )
The projection merges each event into the read model through the SQLAlchemy adapter:
--- a/lib/python-uv/projection/signature_store/src/signature_store.py
+++ b/lib/python-uv/projection/signature_store/src/signature_store.py
@@ -1,11 +1,20 @@
-# Implementation stub — fill in your logic here
-from gen_int.python.projection.signature_store_projection import signature_store_projection
+# Implementation — fold each GuestbookSigned event into the read model.
from gen_int.python.projection.signature_store_projection import signature_store_context
from gen_def.pydantic.events import GuestbookSigned
+from gen_def.sqla.models.guestbook import Signature
def signature_store(
event: GuestbookSigned,
context: signature_store_context,
) -> None:
- raise NotImplementedError
+ # context.adapter is a SqlaAdapter — it hands us a live SQLAlchemy session.
+ context.adapter.session.merge(
+ Signature(
+ id=event.signature_id,
+ visitor_name=event.visitor_name,
+ message=event.message,
+ signed_at=event.signed_at,
+ )
+ )
+ context.adapter.session.commit()
The query reads the model back out, newest first:
--- a/lib/python-uv/query/list_signatures/src/list_signatures.py
+++ b/lib/python-uv/query/list_signatures/src/list_signatures.py
@@ -1,7 +1,21 @@
-# Implementation stub — fill in your logic here
-from gen_int.python.query.list_signatures import list_signatures_query, list_signatures_context
+# Implementation — read the guestbook model back out, newest first.
+from gen_int.python.query.list_signatures import list_signatures_context
from gen_def.pydantic.query.list_signatures import ListSignaturesInput, ListSignaturesOutput
+from gen_def.sqla.models.guestbook import Signature
-def list_signatures(input: ListSignaturesInput, context: list_signatures_context) -> ListSignaturesOutput:
- raise NotImplementedError
+def list_signatures(
+ input: ListSignaturesInput,
+ context: list_signatures_context,
+) -> ListSignaturesOutput:
+ # context.adapter is a SqlaAdapter — read from the same store the projection wrote.
+ query = (
+ context.adapter.session.query(Signature)
+ .order_by(Signature.signed_at.desc())
+ )
+ if input.limit is not None:
+ query = query.limit(input.limit)
+
+ return ListSignaturesOutput(
+ signatures=[f"{row.visitor_name}: {row.message}" for row in query.all()],
+ )
Apply all three:
$ git apply edits/record_signature.py.diff edits/signature_store.py.diff edits/list_signatures.py.diff
Step 7 — Wire it up and run¶
Everything DIZZY generates is a typed package; a host supplies the glue — the
database, the event routing, and the calls. That's demo.py. It owns an in-memory
SQLite database, routes each emitted guestbook_signed event into the projection, signs
the guestbook three times, then runs the query:
"""Wire the generated guestbook feature together and run it end to end.
This is the glue a host application writes by hand: it owns the database, the
event log, and the wiring. Dizzy generates the typed pieces (commands, events,
contexts, adapters, model tables) and the per-element packages under
`lib/python-uv/`; this file just connects them.
Everything imported here is an installed workspace package, so run demo inside
the generated workspace environment:
uv sync --project lib/python-uv
uv run --project lib/python-uv python demo.py
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from gen_def.pydantic.commands import SignGuestbook
from gen_def.pydantic.events import GuestbookSigned
from gen_def.pydantic.query.list_signatures import ListSignaturesInput
from gen_def.sqla.models.guestbook import Base
from gen_int.python.adapters.sqla import SqlaAdapter
from gen_int.python.procedure.record_signature_context import (
record_signature_context,
record_signature_emitters,
)
from gen_int.python.projection.signature_store_projection import signature_store_context
from gen_int.python.query.list_signatures import list_signatures_context
# Each element is its own installed package, exposing a top-level module.
from record_signature import record_signature
from signature_store import signature_store
from list_signatures import list_signatures
def main() -> None:
# The host owns persistence. Here: an in-memory SQLite database.
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
session = Session(engine)
adapter = SqlaAdapter(session=session)
# --- Reactivity loop: command -> procedure -> event -> projection ---
# The procedure emits events; the host routes each emitted event into the
# projections that listen for it. Here we route guestbook_signed straight
# into signature_store.
def on_guestbook_signed(event: GuestbookSigned) -> None:
signature_store(event, signature_store_context(adapter=adapter))
proc_context = record_signature_context(
emit=record_signature_emitters(guestbook_signed=on_guestbook_signed)
)
for name, message in [
("Ada", "Hello from 1843"),
("Grace", "Compiled it"),
("Edsger", "Goto considered harmful"),
]:
record_signature(proc_context, SignGuestbook(visitor_name=name, message=message))
# --- Data loop: query reads the model the projection built ---
result = list_signatures(
ListSignaturesInput(limit=10),
list_signatures_context(adapter=adapter),
)
print("Guestbook (newest first):")
for line in result.signatures:
print(f" - {line}")
if __name__ == "__main__":
main()
Sync the generated workspace and run it:
$ uv sync --project lib/python-uv
<...>
$ uv run --project lib/python-uv python demo.py
Guestbook (newest first):
- Edsger: Goto considered harmful
- Grace: Compiled it
- Ada: Hello from 1843
🎉 That's the whole feature. A command flowed through a procedure into an event, a projection folded it into a model, and a query read it back — both of DIZZY's loops, generated from a single feature-file and a handful of edits you made to the parts only you could decide.