πŸ—οΈ 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 βœ… MutationResult type Transactional, no eventual consistency
Error Handling Structured returns βœ… Status, message, data Type-safe error handling

How It Works

1. Define PostgreSQL Views (Queries)

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

2. Define PostgreSQL Functions (Mutations)

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

3. Map to GraphQL

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

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 0.5-1ms reads. 20-100x faster than typical ORM queries, 2-3x faster than optimized stacks with Redis.

πŸ”„

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

MutationResult type with status, message, data. 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.

MutationResult Type

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
}

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

  • β€’ 0.5-1ms 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+