Queries from views, mutations via PostgreSQL functions. True separation of concerns with ACID guarantees.
FraiseQL implements Command Query Responsibility Segregation (CQRS) at the database level.
Queries execute against PostgreSQL Views (v_*, tv_*) optimized for reads.
Mutations execute PostgreSQL Functions (fn_*) that enforce business logic with ACID guarantees.
Your database becomes your domain model.
| Pattern | PostgreSQL | GraphQL | Benefit |
|---|---|---|---|
| Query (Read) | Views: v_*, tv_* | β GraphQL Query | Optimized reads, denormalized |
| Mutation (Write) | Functions: fn_* | β GraphQL Mutation | ACID guarantees, validation |
| Business Logic | PostgreSQL Functions | β mutation_response type | Transactional, no eventual consistency |
| Error Handling | Structured returns | β mutation_response + status codes | Type-safe, semantic status taxonomy |
| Trinity Identifiers | pk_* (INT), id (UUID) | β UUID only | Fast JOINs internally, secure API |
-- tb_user: Normalized base table (Trinity pattern)
CREATE TABLE tb_user (
pk_user INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT DEFAULT 'user',
created_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- v_user: SQL view exposing id + data JSONB
CREATE VIEW v_user AS
SELECT id, jsonb_build_object(
'id', id, 'email', email, 'name', name,
'role', role, 'created_at', created_at
) AS data
FROM tb_user WHERE deleted_at IS NULL;
-- tv_user: Table view (pre-composed, sub-millisecond reads)
CREATE TABLE tv_user (
id UUID PRIMARY KEY,
data JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Sync function: row-level update on mutation
CREATE FUNCTION fn_sync_tv_user(p_id UUID)
RETURNS VOID AS $$
BEGIN
INSERT INTO tv_user (id, data, updated_at)
SELECT id, data, NOW() FROM v_user WHERE id = p_id
ON CONFLICT (id) DO UPDATE SET
data = EXCLUDED.data, updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
-- fn_create_user: Returns mutation_response
CREATE OR REPLACE FUNCTION fn_create_user(
p_email TEXT,
p_name TEXT
) RETURNS app.mutation_response AS $$
DECLARE
v_pk_user INT;
v_user_id UUID;
BEGIN
-- PHASE 1: INPUT VALIDATION
IF EXISTS (SELECT 1 FROM tb_user WHERE email = p_email) THEN
RETURN (
'conflict:duplicate_email',
'Email already exists',
NULL, 'User', NULL, NULL, NULL, NULL
)::app.mutation_response;
END IF;
-- PHASE 2: BUSINESS LOGIC (INSERT)
INSERT INTO tb_user (email, name)
VALUES (p_email, p_name)
RETURNING pk_user, id INTO v_pk_user, v_user_id;
-- PHASE 3: CHANGE LOG
INSERT INTO tb_entity_change_log (
entity_type, entity_id, operation, after_state
) VALUES (
'user', v_user_id, 'INSERT',
(SELECT data FROM v_user WHERE id = v_user_id)
);
-- PHASE 4: SYNC TABLE VIEW
PERFORM fn_sync_tv_user(v_user_id);
-- PHASE 5: RETURN RESULT
RETURN (
'created',
'User created successfully',
v_user_id::TEXT, 'User',
(SELECT data FROM tv_user WHERE id = v_user_id),
ARRAY['email', 'name'],
NULL, NULL
)::app.mutation_response;
END;
$$ LANGUAGE plpgsql;
import fraiseql
from fraiseql.mutations import success, failure
@fraiseql.type
class User:
id: str
email: str
name: str
@success
class CreateUserSuccess:
user: User
message: str
@failure
class CreateUserError:
status: str # 'conflict:duplicate_email'
message: str
@fraiseql.mutation
class CreateUser:
"""Calls fn_create_user($email, $name)"""
input: CreateUserInput
success: CreateUserSuccess
failure: CreateUserError
# FraiseQL auto-routes status codes:
# 'created' β CreateUserSuccess
# 'conflict:*' β CreateUserError
# 'validation:*' β CreateUserError
Business logic in PostgreSQL functions = transactional consistency. No eventual consistency, no race conditions.
Table views (tv_*) store pre-composed JSONB for sub-millisecond reads. 20-100x faster than typical ORM queries.
Unlike materialized views that refresh entirely, table views sync per-row on mutations. Fast incremental updates, not full table rebuilds.
Database-level constraints and validation. Cannot bypass via direct SQL or other clients.
mutation_response type with semantic status codes. Type-safe error handling in GraphQL.
Queries can't modify data. Mutations enforce business rules. CQRS at database level.
Functions log to tb_entity_change_log automatically. Enterprise compliance built-in.
FraiseQL uses a structured mutation_response type with semantic status codes:
CREATE TYPE app.mutation_response AS (
status TEXT, -- 'created', 'validation:email'
message TEXT, -- Human-readable message
entity_id TEXT, -- UUID of affected entity
entity_type TEXT, -- 'User', 'Order', etc.
entity JSONB, -- Full entity data
updated_fields TEXT[], -- ['email', 'name']
cascade JSONB, -- Cache invalidation hints
metadata JSONB -- Extra context
);
-- Success statuses
'created' -- New entity inserted
'updated' -- Entity modified
'deleted' -- Entity removed
-- Error statuses (prefix:identifier)
'validation:email' -- Field validation failed
'not_found:user' -- Entity doesn't exist
'conflict:duplicate' -- Unique constraint violated
'forbidden:role' -- Permission denied
'noop:unchanged' -- No changes needed
Status codes enable precise client-side error handling. FraiseQL's Rust layer transforms snake_case β camelCase automatically.
REFRESH MATERIALIZED VIEW recomputes entire viewfn_sync_tv_*() updates single row on mutationThe insight: Storage is cheap, computation is expensive. Pre-compose your data once per write, serve it instantly on every read. Table views can also be fully rebuilt from base tables whenever needed.
| Approach | Consistency | Performance | Complexity |
|---|---|---|---|
| Application-Level CQRS | β Eventual consistency | β οΈ Requires event bus | β High (Kafka, events, etc.) |
| Traditional ORM | β ACID | β N+1 queries | β οΈ Medium (DataLoaders) |
| GraphQL Resolvers | β οΈ Depends on code | β Slow (multiple queries) | β οΈ Medium (manual optimization) |
| FraiseQL Database-First | β ACID guaranteed | β Single query, views | β Low (database-driven) |
Database-First CQRS is simpler and faster. No event buses, no eventual consistency, no complex infrastructure. PostgreSQL is your domain model.
SQL is one of the most documented languages in existence. LLMs generate it reliably.
"My team doesn't know SQL" is no longer a blocker. The LLM writes it; your team reviews it.
Don't call APIs from database functions. Write events to a table; workers process them.
-- Mutation: emit event atomically
INSERT INTO app.tb_event_log (event_type, payload)
VALUES ('send_email', jsonb_build_object('to', email, 'template', 'welcome'));
-- Worker: poll, call API, mark done
SELECT * FROM app.tb_event_log WHERE processed_at IS NULL;
Events commit with your transaction. No lost messages. Retries are trivial. The database is your queue.
Database-First CQRS is a core feature of FraiseQL+