Observers
Observers are actions that run after a mutation completes successfully. The mutation commits to the database first, then FraiseQL dispatches configured observer actions. This means observers cannot roll back the write — they are for side effects: sending emails, updating projection tables, publishing events, calling webhooks.
This guide builds directly on the blog API from Your First API — users, posts, and comments.
How Observers Fit In
Section titled “How Observers Fit In”When a createUser mutation runs:
- FraiseQL executes your SQL function inside a transaction
- The transaction commits
- The Rust runtime evaluates configured observers for
createUser - Matching observer actions (webhooks, Slack) are dispatched asynchronously
- The GraphQL response returns to the client — it does not wait for observers to finish
Observers are configured in TOML, not in Python. The Python SDK is compile-time only — it defines the GraphQL schema (types, queries, mutations) and exports it to JSON. Observer delivery, retry logic, and side-effect dispatch are handled entirely by the Rust runtime.
Observers fire on the database commit that follows any successful mutation — regardless of which transport initiated it. A createUser mutation sent via GraphQL, REST POST, or gRPC unary call all trigger the same configured observer actions.
Architecture
Section titled “Architecture”Configuring Observers in TOML
Section titled “Configuring Observers in TOML”Observer backend and actions are configured in fraiseql.toml. The Rust runtime reads this configuration and handles event dispatch after each mutation commits.
[observers]backend = "nats" # Options: "nats", "redis", "postgres", "in-memory"nats_url = "nats://localhost:4222"
# For Redis backend:# backend = "redis"# redis_url = "redis://localhost:6379"Each observer action maps a database event to an action (webhook, Slack). They are configured alongside the [observers] backend:
[[observers.handlers]]name = "welcome-email"event = "user.created"action = "webhook"webhook_url = "${WELCOME_EMAIL_WEBHOOK_URL}"Step 1: Your First Observer — Welcome Email on createUser
Section titled “Step 1: Your First Observer — Welcome Email on createUser”When a user is created, your email service should be notified. Configure a webhook observer in fraiseql.toml and implement an HTTP endpoint in your email service to receive the event.
fraiseql.toml — configure the observer:
[[observers.handlers]]name = "welcome-email"event = "user.created"action = "webhook"webhook_url = "${WELCOME_EMAIL_SERVICE_URL}"Your email service — implement the webhook receiver:
import express from 'express';import { sendEmail } from '../mailer';
const router = express.Router();
router.post('/hooks/fraiseql', async (req, res) => { const event = req.body;
// FraiseQL sends: { event, table, timestamp, data: { new, old } } if (event.event !== 'INSERT' || event.table !== 'tb_user') { return res.sendStatus(204); }
const user = event.data.new;
await sendEmail({ to: user.email, subject: 'Welcome to the blog', body: `Hi ${user.name}, your account is ready.`, });
res.sendStatus(200);});
export default router;package main
import ( "encoding/json" "net/http"
"your-module/mailer")
type FraiseQLEvent struct { Event string `json:"event"` Table string `json:"table"` Data struct { New map[string]any `json:"new"` Old map[string]any `json:"old"` } `json:"data"`}
func fraiseqlWebhookHandler(w http.ResponseWriter, r *http.Request) { var event FraiseQLEvent if err := json.NewDecoder(r.Body).Decode(&event); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return }
if event.Event != "INSERT" || event.Table != "tb_user" { w.WriteHeader(http.StatusNoContent) return }
user := event.Data.New mailer.Send(r.Context(), mailer.Message{ To: user["email"].(string), Subject: "Welcome to the blog", Body: "Hi " + user["name"].(string) + ", your account is ready.", })
w.WriteHeader(http.StatusOK)}Step 2: Observer on createComment — Notify the Post Author
Section titled “Step 2: Observer on createComment — Notify the Post Author”When someone comments on a post, notify the post author. Configure a second observer in fraiseql.toml and implement the receiver in your notification service.
fraiseql.toml:
[[observers.handlers]]name = "comment-notification"event = "comment.created"action = "webhook"webhook_url = "${NOTIFICATION_SERVICE_URL}"Your notification service:
router.post('/hooks/fraiseql', async (req, res) => { const event = req.body;
if (event.event !== 'INSERT' || event.table !== 'tb_comment') { return res.sendStatus(204); }
const comment = event.data.new;
// Look up the post and its author using your own DB connection const post = await db.query( 'SELECT author_email, title FROM tb_post WHERE pk_post = $1', [comment.fk_post] ); if (!post || post.author_email === comment.author_email) { return res.sendStatus(200); // Don't notify if commenter is the author }
await sendEmail({ to: post.author_email, subject: `New comment on "${post.title}"`, body: `${comment.author_name} commented: "${comment.content.slice(0, 100)}"`, });
res.sendStatus(200);});func commentWebhookHandler(w http.ResponseWriter, r *http.Request) { var event FraiseQLEvent json.NewDecoder(r.Body).Decode(&event)
if event.Event != "INSERT" || event.Table != "tb_comment" { w.WriteHeader(http.StatusNoContent) return }
comment := event.Data.New
// Look up post author using your own DB connection var authorEmail, postTitle string err := db.QueryRowContext(r.Context(), `SELECT u.email, p.title FROM tb_post p JOIN tb_user u ON u.pk_user = p.fk_user WHERE p.pk_post = $1`, comment["fk_post"], ).Scan(&authorEmail, &postTitle) if err != nil || authorEmail == comment["author_email"].(string) { w.WriteHeader(http.StatusOK) return }
mailer.Send(r.Context(), mailer.Message{ To: authorEmail, Subject: "New comment on " + postTitle, })
w.WriteHeader(http.StatusOK)}Step 3: Observer on publishPost — Update a Projection Table
Section titled “Step 3: Observer on publishPost — Update a Projection Table”Observers are the natural place to keep projection tables in sync. The Projection Tables guide introduces tv_post_stats. When a post is published, your receiver can update the projection directly.
fraiseql.toml:
[[observers.handlers]]name = "update-post-stats"event = "post.updated"condition = "status == 'published'"action = "webhook"webhook_url = "${PROJECTION_SERVICE_URL}"Your projection service:
router.post('/hooks/fraiseql', async (req, res) => { const event = req.body;
if (event.table !== 'tb_post' || event.data.new?.status !== 'published') { return res.sendStatus(204); }
const post = event.data.new;
// Update projection table using your own DB connection await db.query( `UPDATE tv_post_stats SET published_at = $1 WHERE post_id = $2`, [event.timestamp, post.id], );
res.sendStatus(200);});func projectionWebhookHandler(w http.ResponseWriter, r *http.Request) { var event FraiseQLEvent json.NewDecoder(r.Body).Decode(&event)
if event.Table != "tb_post" { w.WriteHeader(http.StatusNoContent) return }
post := event.Data.New if post["status"] != "published" { w.WriteHeader(http.StatusNoContent) return }
db.ExecContext(r.Context(), `UPDATE tv_post_stats SET published_at = $1 WHERE post_id = $2`, event.Timestamp, post["id"], )
w.WriteHeader(http.StatusOK)}This is the connection between observers and projection tables: mutations write to the canonical tables, observer webhook receivers keep your read models current.
Step 4: Error Handling in Observer Webhooks
Section titled “Step 4: Error Handling in Observer Webhooks”An observer failure does not roll back the mutation. The write has already committed. Your webhook endpoint must handle its own errors — returning a non-2xx status triggers FraiseQL’s retry logic.
Return 200 on handled errors; log internally
Section titled “Return 200 on handled errors; log internally”router.post('/hooks/fraiseql', async (req, res) => { const event = req.body; const user = event.data.new;
try { await sendEmail({ to: user.email, subject: 'Welcome to the blog', body: `Hi ${user.name}, your account is ready.`, }); res.sendStatus(200); } catch (err) { // Log internally — return 200 so FraiseQL does not retry a transient client error logger.error('Failed to send welcome email', { userId: user.id, error: err }); res.sendStatus(200); }});func fraiseqlWebhookHandler(w http.ResponseWriter, r *http.Request) { var event FraiseQLEvent json.NewDecoder(r.Body).Decode(&event)
user := event.Data.New err := mailer.Send(r.Context(), mailer.Message{To: user["email"].(string)}) if err != nil { // Log internally — return 200 so FraiseQL does not retry a non-retryable error slog.ErrorContext(r.Context(), "failed to send welcome email", "user_id", user["id"], "error", err) } w.WriteHeader(http.StatusOK)}Return 5xx to trigger retry (for transient failures)
Section titled “Return 5xx to trigger retry (for transient failures)”Return a 5xx status when the failure is transient (network timeout, downstream service unavailable) and retrying is appropriate. FraiseQL will retry according to the configured backoff strategy.
router.post('/hooks/fraiseql', async (req, res) => { try { await publishEvent(req.body); res.sendStatus(200); } catch (err) { if (err.code === 'ECONNREFUSED') { // Downstream service unreachable — ask FraiseQL to retry return res.status(503).json({ error: 'Event bus unavailable' }); } // Other errors — log and acknowledge logger.error('Observer error', err); res.sendStatus(200); }});Step 5: Multiple Observers on One Mutation
Section titled “Step 5: Multiple Observers on One Mutation”Register multiple handlers for the same event in fraiseql.toml. Each handler executes independently — a failure in one does not block the others.
# Observer 1 — send welcome email[[observers.handlers]]name = "welcome-email"event = "user.created"action = "webhook"webhook_url = "${WELCOME_EMAIL_SERVICE_URL}"max_attempts = 3backoff_strategy = "exponential"
# Observer 2 — publish signup event to event bus[[observers.handlers]]name = "signup-event"event = "user.created"action = "webhook"webhook_url = "${EVENT_BUS_INGEST_URL}"max_attempts = 5backoff_strategy = "exponential"Handlers execute in definition order. Each has its own retry budget.
Synchronous Mode
Section titled “Synchronous Mode”By default, observers are fire-and-forget — the mutation response returns immediately without waiting for observer actions to complete. For use cases that require guaranteed side effects before the response, set synchronous = true:
[[observers.handlers]]name = "create-stripe-customer"event = "user.created"action = "webhook"webhook_url = "${STRIPE_WEBHOOK_URL}"synchronous = true # mutation response waits for this observer to completeWhen synchronous = true:
- The mutation response is held until the observer action completes (or times out)
- If the observer action fails, the mutation still succeeds (the write is already committed), but the response includes a warning header
- Use this for side effects that the client needs to know completed, such as creating a linked resource in an external system
Effectively-Once Delivery
Section titled “Effectively-Once Delivery”For observers that must not process the same event twice (e.g., billing, ledger entries), use the CheckpointStrategy with an idempotency table:
[observers]backend = "nats"nats_url = "${NATS_URL}"checkpoint_strategy = "effectively_once"idempotency_table = "observer_idempotency_keys"This records each event’s idempotency key in a PostgreSQL table before processing. Duplicate events are detected via INSERT ... ON CONFLICT DO NOTHING and skipped.
The idempotency table schema:
CREATE TABLE observer_idempotency_keys ( idempotency_key TEXT NOT NULL, listener_id TEXT NOT NULL, processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (idempotency_key, listener_id));| Strategy | Guarantees | Use for |
|---|---|---|
at_least_once (default) | Fast, may duplicate | Idempotent handlers (emails, webhooks) |
effectively_once | Deduplication via PostgreSQL | Billing, ledger entries, non-idempotent actions |
Observers vs Database Triggers
Section titled “Observers vs Database Triggers”Both observers and database triggers react to data changes. They serve different purposes:
| Observer | DB Trigger | |
|---|---|---|
| Runs | After commit, outside transaction | Inside transaction |
| Can call external services | Yes | No |
| Rolls back on failure | No | Yes |
| Access to context / auth | Via webhook request body | No |
| Can read application state | Via your own DB connection | Only as SQL |
| Visible in application code | Yes (webhook receiver) | Only as SQL |
Use a database trigger when the action must be atomic with the write — for example, enforcing a derived column or maintaining a denormalized counter that must never be out of sync.
Use an observer when the action involves the outside world — sending an email, calling a webhook, updating a projection table, publishing an event. These actions cannot participate in the database transaction anyway, so placing them in observers keeps responsibility clear.
Webhook Payload Format
Section titled “Webhook Payload Format”When FraiseQL dispatches a webhook observer action (no body_template specified), the payload it sends to your endpoint looks like this:
{ "event": "INSERT", "table": "tb_user", "timestamp": "2026-02-25T10:00:00Z", "data": { "new": { "id": "550e8400-e29b-41d4-a716-446655440000", "email": "alice@example.com", "name": "Alice" }, "old": null }}For UPDATE events, "old" contains the previous field values. For DELETE events, "new" is null and "old" contains the deleted record. Use event.timestamp as a stable deduplication key.
You can override the payload shape with a Mustache body_template in the handler config. See the Observers concept for the full webhook action configuration reference.
Debugging Observers
Section titled “Debugging Observers”Run FraiseQL with debug logging to see observer dispatch in real time:
RUST_LOG=debug fraiseql runWith debug logging enabled, every observer invocation prints to stdout:
[observer] createUser fired → welcome-email[observer] welcome-email: dispatching webhook to https://email-svc/hooks/fraiseql[observer] welcome-email: completed in 142ms (status=200)[observer] createUser fired → signup-event[observer] signup-event: dispatching webhook to https://events/ingest[observer] signup-event: completed in 18ms (status=200)To trace a specific mutation end-to-end, filter by request ID:
RUST_LOG=debug fraiseql run 2>&1 | grep "req_01HV3KQBN7"What’s Next
Section titled “What’s Next”- Projection Tables — Keep read models up to date
- Advanced Patterns — Audit trails, versioning, soft deletes
- Webhooks — Push events to external systems
- Subscriptions — Real-time push to clients via WebSocket