Skip to content

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.

When a createUser mutation runs:

  1. FraiseQL executes your SQL function inside a transaction
  2. The transaction commits
  3. The Rust runtime evaluates configured observers for createUser
  4. Matching observer actions (webhooks, Slack) are dispatched asynchronously
  5. 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.

Observer architecture: schema compilation pipeline to runtime observer dispatch Observer architecture: schema compilation pipeline to runtime observer dispatch
Python schema compiles to JSON; fraiseql.toml configures observer actions; the Rust runtime dispatches them after mutations.

Observer backend and actions are configured in fraiseql.toml. The Rust runtime reads this configuration and handles event dispatch after each mutation commits.

fraiseql.toml
[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:

fraiseql.toml
[[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:

email-service/src/routes/fraiseql-webhook.ts
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;

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:

notification-service/src/routes/fraiseql-webhook.ts
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);
});

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:

projection-service/src/routes/fraiseql-webhook.ts
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);
});

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”
email-service/src/routes/fraiseql-webhook.ts
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);
}
});

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.

fraiseql.toml
# Observer 1 — send welcome email
[[observers.handlers]]
name = "welcome-email"
event = "user.created"
action = "webhook"
webhook_url = "${WELCOME_EMAIL_SERVICE_URL}"
max_attempts = 3
backoff_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 = 5
backoff_strategy = "exponential"

Handlers execute in definition order. Each has its own retry budget.

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:

fraiseql.toml
[[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 complete

When 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

For observers that must not process the same event twice (e.g., billing, ledger entries), use the CheckpointStrategy with an idempotency table:

fraiseql.toml
[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)
);
StrategyGuaranteesUse for
at_least_once (default)Fast, may duplicateIdempotent handlers (emails, webhooks)
effectively_onceDeduplication via PostgreSQLBilling, ledger entries, non-idempotent actions

Both observers and database triggers react to data changes. They serve different purposes:

ObserverDB Trigger
RunsAfter commit, outside transactionInside transaction
Can call external servicesYesNo
Rolls back on failureNoYes
Access to context / authVia webhook request bodyNo
Can read application stateVia your own DB connectionOnly as SQL
Visible in application codeYes (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.

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.

Run FraiseQL with debug logging to see observer dispatch in real time:

Terminal window
RUST_LOG=debug fraiseql run

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

Terminal window
RUST_LOG=debug fraiseql run 2>&1 | grep "req_01HV3KQBN7"