πŸ—οΈ Database-First CQRS

Queries from views, mutations via PostgreSQL functions. True separation of concerns with ACID guarantees.

What is Database-First CQRS?

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

How It Works

1. Define PostgreSQL Views (Queries)

-- 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;

2. Define PostgreSQL Functions (Mutations)

-- 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;

3. Map to GraphQL

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

Key Benefits

πŸ”’

ACID Guarantees

Business logic in PostgreSQL functions = transactional consistency. No eventual consistency, no race conditions.

⚑

Optimized Reads

Table views (tv_*) store pre-composed JSONB for sub-millisecond reads. 20-100x faster than typical ORM queries.

πŸ”„

Row-Level Sync

Unlike materialized views that refresh entirely, table views sync per-row on mutations. Fast incremental updates, not full table rebuilds.

πŸ›‘οΈ

Enforced Validation

Database-level constraints and validation. Cannot bypass via direct SQL or other clients.

πŸ“Š

Structured Error Handling

mutation_response type with semantic status codes. Type-safe error handling in GraphQL.

πŸ—οΈ

True Separation

Queries can't modify data. Mutations enforce business rules. CQRS at database level.

πŸ“

Automatic Audit Trails

Functions log to tb_entity_change_log automatically. Enterprise compliance built-in.

Mutation Response Pattern

FraiseQL uses a structured mutation_response type with semantic status codes:

mutation_response Type
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
);
Status Code Taxonomy
-- 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.

Perfect For

Why Table Views, Not Materialized Views?

❌ Materialized Views (mv_*)

  • β€’ REFRESH MATERIALIZED VIEW recomputes entire view
  • β€’ Expensive for large datasets
  • β€’ Data is stale between refreshes
  • β€’ Good for: analytics dashboards, reports

βœ… Table Views (tv_*)

  • β€’ fn_sync_tv_*() updates single row on mutation
  • β€’ Fast incremental updates
  • β€’ Always up-to-date after mutation completes
  • β€’ Good for: GraphQL APIs, real-time data

The 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.

Why Database-First CQRS?

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.

Honest Trade-offs

You Get

  • β€’ Sub-millisecond reads from table views
  • β€’ ACID on every mutation
  • β€’ Single source of truthβ€”no ORM drift
  • β€’ Audit trails and change data capture built-in

You Accept

  • β€’ PostgreSQL-only (intentional)
  • β€’ 2-10x storage for table views (varies by nesting)
  • β€’ Business logic in the database

Why PL/pgSQL Is the Right Target for LLMs

SQL is one of the most documented languages in existence. LLMs generate it reliably.

  • β€’ Fewer tokens: PL/pgSQL is 30-50% shorter than equivalent Python. Cheaper inference.
  • β€’ Lower hallucination: Syntax hasn't changed in decades.
  • β€’ Single context: Validation, logic, and data access in one function.

"My team doesn't know SQL" is no longer a blocker. The LLM writes it; your team reviews it.

External API Calls: The Observer Pattern

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.

Ready for Database-Driven Design?

Database-First CQRS is a core feature of FraiseQL+