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 | β MutationResult type | Transactional, no eventual consistency |
| Error Handling | Structured returns | β Status, message, data | Type-safe error handling |
-- v_user: SQL view for real-time data
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 with pre-composed JSONB
-- (Regular TABLE, not a view - synced via triggers)
CREATE TABLE tv_user (
id UUID PRIMARY KEY,
data JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Sync function updates tv_user from v_user
CREATE FUNCTION fn_sync_tv_user(p_id UUID)
RETURNS VOID AS $$
BEGIN
INSERT INTO tv_user (id, data, updated_at)
SELECT id, row_to_json(v_user.*)::jsonb, 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: 5-phase mutation pattern
CREATE OR REPLACE FUNCTION fn_create_user(
p_email TEXT,
p_name TEXT
) RETURNS JSONB AS $$
DECLARE
v_user_id UUID;
BEGIN
-- PHASE 1: INPUT VALIDATION
IF EXISTS (SELECT 1 FROM tb_user WHERE email = p_email) THEN
RETURN jsonb_build_object(
'status', 'error',
'message', 'Email already exists'
);
END IF;
-- PHASE 2: BUSINESS LOGIC (INSERT)
INSERT INTO tb_user (email, data)
VALUES (p_email, jsonb_build_object('name', p_name))
RETURNING id INTO 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 jsonb_build_object(
'status', 'success',
'message', 'User created',
'object_data', jsonb_build_object(
'id', v_user_id,
'email', p_email,
'name', p_name
)
);
END;
$$ LANGUAGE plpgsql;
from fraiseql import fraiseql
from fraiseql.mutations import MutationResult
# Query: Maps to tv_user (fast) or v_user (real-time)
@fraiseql.type
class User:
id: uuid.UUID
email: str
name: str
role: str
@fraiseql.query
async def users(info) -> list[User]:
"""Executes: SELECT data FROM tv_user"""
return await info.context.db.fetch_all()
# Mutation: Calls fn_create_user function
@fraiseql.mutation
async def create_user(
info,
email: str,
name: str
) -> MutationResult:
"""Executes: SELECT fn_create_user($1, $2)"""
result = await info.context.db.call_function(
"fn_create_user", email, name
)
return MutationResult(**result)
# Function handles:
# - Validation (email uniqueness)
# - Insert to tb_user
# - Change log to tb_entity_change_log
# - Sync to tv_user
# - Structured response
Business logic in PostgreSQL functions = transactional consistency. No eventual consistency, no race conditions.
Table views (tv_*) store pre-composed JSONB for 0.5-1ms reads. 20-100x faster than typical ORM queries, 2-3x faster than optimized stacks with Redis.
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.
MutationResult type with status, message, data. 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 provides a standardized mutation response pattern:
from fraiseql.mutations import MutationResult
# GraphQL type automatically generated:
type MutationResult {
status: String! # "success", "error", "warning"
message: String! # Human-readable message
object_data: JSON # The created/updated object
}
# Example mutation response:
{
"status": "success",
"message": "User created successfully",
"object_data": {
"id": 123,
"email": "user@example.com",
"name": "Jane Doe"
}
}
# Example error response:
{
"status": "error",
"message": "Email already exists",
"object_data": null
}
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+