Skip to content

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

guestbook.feat.yaml
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:

demo.py
"""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.