FraiseQL Philosophy¶
Understanding FraiseQL's design principles and innovative approaches.
Overview¶
FraiseQL is built on forward-thinking design principles that prioritize developer experience, security by default, and PostgreSQL-native patterns. Unlike traditional GraphQL frameworks, FraiseQL embraces conventions that reduce boilerplate while maintaining flexibility.
Core Principles:
- Automatic Database Injection - Zero-config data access
- JSONB-First Architecture - Embrace PostgreSQL's strengths
- Auto-Documentation - Single source of truth
- Session Variable Injection - Security without complexity
- Composable Patterns - Framework provides tools, you control composition
Beginner Introduction¶
If You're New to FraiseQL¶
FraiseQL might seem different if you're used to traditional web frameworks. Here's what makes it special:
Think "Database-First": Instead of starting with your API and figuring out the database later, FraiseQL starts with PostgreSQL and builds your API on top. Your database becomes the foundation of your application.
Key Concepts to Know: - CQRS: Separate reading data from writing data - JSONB Views: Pre-packaged data ready for GraphQL - Database-First: Business logic lives in PostgreSQL
Why This Matters: Traditional frameworks often fight against the database. FraiseQL works with PostgreSQL, using its strengths (JSONB, functions, views) to build faster, more maintainable APIs.
Quick Philosophy Check¶
Before diving deep, ask yourself: - Do you want your database to do more heavy lifting? - Are you tired of ORM complexity? - Do you want automatic multi-tenancy and security? - Would you like 10-100x performance improvements?
If yes, FraiseQL's philosophy might be perfect for you.
Automatic Database Injection¶
The Problem with Traditional Frameworks¶
Most GraphQL frameworks require manual database setup in every resolver:
import fraiseql
from fraiseql.types import ID
# ❌ Traditional approach - repetitive and error-prone
@fraiseql.query
async def get_user(info, id: ID) -> User:
# Must manually get database from somewhere
db = get_database_from_somewhere()
# Or pass it through complex dependency injection
return await db.find_one("users", {"id": id})
FraiseQL's Solution¶
FraiseQL automatically injects the database into info.context["db"]:
import fraiseql
from fraiseql.types import ID
# ✅ FraiseQL - database automatically available
@fraiseql.query
async def get_user(info, id: ID) -> User:
db = info.context["db"] # Always available!
return await db.find_one("v_user", where={"id": id})
How It Works¶
-
Configuration - Specify database URL once:
-
Automatic Setup - FraiseQL creates and manages connection pool:
-
Context Injection - Every resolver gets
dbin context: ```python import fraiseql
@fraiseql.query async def any_query(info) -> Any: db = info.context["db"] # FraiseQLRepository instance # Ready to use immediately ```
Benefits¶
- Zero boilerplate - No manual connection management
- Type-safe -
dbis alwaysFraiseQLRepository - Connection pooling - Automatic pool management
- Transaction support - Built-in transaction handling
- Consistent - Same API across all resolvers
Advanced: Custom Context¶
You can extend context while keeping auto-injection:
async def get_context(request: Request) -> dict:
"""Custom context with user + auto database injection."""
return {
# Your custom context
"user_id": extract_user_from_jwt(request),
"tenant_id": extract_tenant_from_jwt(request),
# No need to add "db" - FraiseQL adds it automatically!
}
app = create_fraiseql_app(
config=config,
context_getter=get_context # Database still auto-injected
)
JSONB-First Architecture¶
Philosophy¶
FraiseQL embraces PostgreSQL's JSONB as a first-class storage mechanism, not just for flexible schemas, but as a performance and developer experience optimization.
Traditional vs JSONB-First¶
Traditional ORM Approach:
-- Rigid schema, many columns
CREATE TABLE tb_user (
id UUID PRIMARY KEY,
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(20),
address_line1 VARCHAR(255),
address_line2 VARCHAR(255),
city VARCHAR(100),
-- ... 20 more columns
);
FraiseQL JSONB-First Approach:
-- Flexible, indexed, performant
CREATE TABLE tb_user (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
data JSONB NOT NULL
);
-- Indexes for commonly queried fields
CREATE INDEX idx_user_email ON tb_user USING GIN ((data->'email'));
CREATE INDEX idx_user_name ON tb_user USING GIN ((data->'name'));
-- View for GraphQL
CREATE VIEW v_user AS
SELECT
id,
tenant_id,
data->>'first_name' as first_name,
data->>'last_name' as last_name,
data->>'email' as email,
data
FROM tb_user;
Why JSONB-First?¶
1. Schema Evolution Without Migrations:
import fraiseql
from fraiseql.types import ID
# Add new field - no migration needed!
@fraiseql.type(sql_source="v_user")
class User:
"""User account.
Fields:
id: User identifier
email: Email address
name: Full name
preferences: User preferences (NEW! Just add it)
"""
id: ID
email: str
name: str
preferences: UserPreferences | None = None # Added without ALTER TABLE
2. JSON Passthrough Performance:
import fraiseql
from fraiseql.types import ID
# PostgreSQL JSONB → GraphQL JSON directly
# No Python object instantiation needed!
@fraiseql.query
async def user(info, id: ID) -> User:
db = info.context["db"]
# Returns JSONB directly - 10-100x faster
return await db.find_one("v_user", where={"id": id})
3. Flexible Data Models:
-- Different tenants can have different user fields
-- Tenant A users
{"first_name": "John", "last_name": "Doe", "department": "Sales"}
-- Tenant B users (different structure!)
{"full_name": "Jane Smith", "division": "Marketing", "employee_id": "E123"}
JSONB Best Practices¶
1. Use Views for GraphQL:
CREATE VIEW v_product AS
SELECT
id,
tenant_id,
data->>'name' as name,
(data->>'price')::decimal as price,
data->>'sku' as sku,
data -- Full JSONB for passthrough
FROM tb_product;
2. Index Frequently Queried Fields:
-- GIN index for contains queries
CREATE INDEX idx_product_search ON tb_product
USING GIN ((data->'name') gin_trgm_ops);
-- B-tree for exact matches
CREATE INDEX idx_product_sku ON tb_product ((data->>'sku'));
3. Validate in PostgreSQL, Not Python:
CREATE FUNCTION validate_user_data(data jsonb) RETURNS boolean AS $$
BEGIN
-- Email required
IF NOT (data ? 'email') THEN
RAISE EXCEPTION 'email is required';
END IF;
-- Email format
IF NOT (data->>'email' ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$') THEN
RAISE EXCEPTION 'invalid email format';
END IF;
RETURN true;
END;
$$ LANGUAGE plpgsql;
-- Use in constraint
ALTER TABLE tb_user
ADD CONSTRAINT check_user_data
CHECK (validate_user_data(data));
When NOT to Use JSONB¶
- High-cardinality numeric queries - Use regular columns for complex numeric aggregations
- Foreign key relationships - Use UUID columns, not nested JSONB
- Frequently joined data - Extract to separate table with foreign keys
-- ❌ Don't do this
CREATE TABLE tb_order (
id UUID,
data JSONB -- Contains user_id, product_id
);
-- ✅ Do this
CREATE TABLE tb_order (
id UUID,
user_id UUID REFERENCES tb_user(id), -- FK for joins
product_id UUID REFERENCES tb_product(id), -- FK for joins
data JSONB -- Additional flexible data
);
Auto-Documentation from Code¶
Single Source of Truth¶
FraiseQL extracts documentation from Python docstrings, eliminating manual schema documentation:
import fraiseql
from fraiseql.types import ID
@fraiseql.type(sql_source="v_user")
class User:
"""User account with authentication and profile information.
Users are created during registration and can access the system
based on their assigned roles and permissions.
Fields:
id: Unique user identifier (UUID v4)
email: Email address used for login (must be unique)
first_name: User's first name
last_name: User's last name
created_at: Account creation timestamp
is_active: Whether user account is active
"""
id: ID
email: str
first_name: str
last_name: str
created_at: datetime
is_active: bool
Result - GraphQL schema includes all documentation:
"""
User account with authentication and profile information.
Users are created during registration and can access the system
based on their assigned roles and permissions.
"""
type User {
"Unique user identifier (UUID v4)"
id: ID!
"Email address used for login (must be unique)"
email: String!
"User's first name"
firstName: String!
# ... etc
}
Benefits for LLM Integration¶
This auto-documentation is perfect for LLM-powered applications:
- Rich Context - LLMs see full descriptions via introspection
- Always Updated - Docs can't get out of sync with code
- Consistent Format - Standardized across entire API
- Zero Maintenance - No separate documentation files
Session Variable Injection¶
Security by Default¶
FraiseQL automatically sets PostgreSQL session variables from GraphQL context:
# Context from authenticated request
async def get_context(request: Request) -> dict:
token = extract_jwt(request)
return {
"tenant_id": token["tenant_id"],
"user_id": token["user_id"]
}
# FraiseQL automatically executes:
# SET LOCAL app.tenant_id = '<tenant_id>';
# SET LOCAL app.contact_id = '<user_id>';
Multi-Tenant Isolation¶
Views automatically filter by tenant:
CREATE VIEW v_order AS
SELECT *
FROM tb_order
WHERE tenant_id = current_setting('app.tenant_id')::uuid;
Now all queries are automatically tenant-isolated:
import fraiseql
@fraiseql.query
async def orders(info) -> list[Order]:
db = info.context["db"]
# Automatically filtered by tenant from JWT!
return await db.find("v_order")
Security Benefits:
- ✅ Tenant ID from verified JWT, not user input
- ✅ Impossible to query other tenant's data
- ✅ Works at database level (defense in depth)
- ✅ Zero application-level filtering logic
In PostgreSQL Everything¶
One Database to Rule Them All¶
FraiseQL eliminates external dependencies by implementing caching, error tracking, and observability directly in PostgreSQL. This "In PostgreSQL Everything" philosophy delivers cost savings, operational simplicity, and consistent performance.
Cost Savings:
Traditional Stack:
- Sentry: $300-3,000/month
- Redis Cloud: $50-500/month
- Total: $350-3,500/month
FraiseQL Stack:
- PostgreSQL: Already running (no additional cost)
- Total: $0/month additional
Operational Simplicity:
Before: FastAPI + PostgreSQL + Redis + Sentry + Grafana = 5 services
After: FastAPI + PostgreSQL + Grafana = 3 services
PostgreSQL-Native Caching (Redis Alternative)¶
from fraiseql.caching import PostgresCache
cache = PostgresCache(db_pool)
await cache.set("user:123", user_data, ttl=3600)
# Features:
# - UNLOGGED tables for Redis-level performance
# - No WAL overhead = fast writes
# - Shared across app instances
# - TTL-based automatic expiration
# - Pattern-based deletion
Performance: UNLOGGED tables skip write-ahead logging, providing Redis-level write performance while maintaining read speed. Data survives crashes (unlike Redis default) and is automatically shared across all app instances.
PostgreSQL-Native Error Tracking (Sentry Alternative)¶
from fraiseql.monitoring import init_error_tracker
tracker = init_error_tracker(db_pool, environment="production")
await tracker.capture_exception(error, context={
"user_id": user.id,
"request_id": request_id,
"operation": "create_order"
})
# Features:
# - Automatic error fingerprinting and grouping (like Sentry)
# - Full stack trace capture
# - Request/user context preservation
# - OpenTelemetry trace correlation
# - Issue management (resolve, ignore, assign)
# - Notification triggers (Email, Slack, Webhook)
Observability: All errors stored in PostgreSQL with automatic grouping. Query directly for debugging:
-- Find all errors for a user
SELECT * FROM monitoring.errors
WHERE context->>'user_id' = '123'
ORDER BY occurred_at DESC;
-- Correlate errors with traces
SELECT e.*, t.*
FROM monitoring.errors e
JOIN monitoring.traces t ON e.trace_id = t.trace_id
WHERE e.fingerprint = 'order_creation_failed';
Integrated Observability Stack¶
OpenTelemetry Integration:
# Traces and metrics automatically stored in PostgreSQL
# Full correlation with errors and business events
SELECT
e.message as error,
t.duration_ms as trace_duration,
c.entity_name as affected_entity
FROM monitoring.errors e
JOIN monitoring.traces t ON e.trace_id = t.trace_id
JOIN tb_entity_change_log c ON t.trace_id = c.trace_id::text
WHERE e.fingerprint = 'payment_processing_error'
ORDER BY e.occurred_at DESC
LIMIT 10;
Grafana Dashboards:
Pre-built dashboards in grafana/:
- Error monitoring (grouping, rates, trends)
- OpenTelemetry traces (spans, performance)
- Performance metrics (latency, throughput)
- All querying PostgreSQL directly (no exporters needed)
Why "In PostgreSQL Everything"?¶
1. Cost-Effective: Save $300-3,000/month by eliminating SaaS services 2. Operational Simplicity: One database to manage, backup, and monitor 3. Consistent Performance: No external network calls for caching or error tracking 4. Full Control: Self-hosted, no vendor lock-in, complete data ownership 5. Correlation: Errors + traces + metrics + business events in one query 6. ACID Guarantees: All observability data benefits from PostgreSQL transactions
Composable Over Opinionated¶
Framework Provides Tools¶
FraiseQL gives you composable utilities, not rigid patterns:
from fraiseql.monitoring import HealthCheck, check_database
# Create health check
health = HealthCheck()
# Add only checks you need
health.add_check("database", check_database)
# Optionally add custom checks
health.add_check("s3", my_s3_check)
# Use in your endpoints
@app.get("/health")
async def health_endpoint():
return await health.run_checks()
You Control Composition¶
Unlike opinionated frameworks that dictate: - ❌ Where files go - ❌ How to structure modules - ❌ What patterns to use
FraiseQL provides: - ✅ Building blocks (HealthCheck, @mutation, @query) - ✅ Clear interfaces (CheckResult, CheckFunction) - ✅ Flexibility in composition
Performance Through Simplicity¶
JSON Passthrough¶
Skip Python object creation entirely:
import fraiseql
# PostgreSQL JSONB → GraphQL JSON
# No intermediate Python objects!
@fraiseql.query
async def users(info) -> list[User]:
db = info.context["db"]
# Returns JSONB directly - 10-100x faster
return await db.find("v_user")
# With Rust transformer: 80x faster
# With APQ: 3-5x additional speedup
# With TurboRouter: 2-3x additional speedup
Database-First Operations¶
Move logic to PostgreSQL when possible:
-- Complex business logic in database
CREATE FUNCTION calculate_order_totals(order_id uuid)
RETURNS jsonb AS $$
-- SQL aggregations, JOINs, window functions
-- Much faster than Python loops
$$ LANGUAGE sql;
import fraiseql
from fraiseql.types import ID
@fraiseql.query
async def order_totals(info, id: ID) -> OrderTotals:
db = info.context["db"]
# Database does the heavy lifting
return await db.execute_function(
"calculate_order_totals",
{"order_id": id}
)
See Also¶
- Database API - Auto-injected database methods
- Session Variables - Automatic injection details
- Decorators - FraiseQL decorator patterns
- Performance - JSON passthrough and optimization layers