Skip to content

1.4: Design Principles

Audience: Architects, team leads, technical decision-makers Prerequisite: Core Concepts, Database-Centric Architecture Reading Time: 15-20 minutes


Overview

FraiseQL is built on five core design principles that guide every architectural decision. These principles explain not just what FraiseQL does, but why it does it that way. Understanding these principles helps you:

  • Know when FraiseQL is the right choice
  • Predict how FraiseQL will behave in edge cases
  • Understand the tradeoffs you're making
  • Design your schema and data model for optimal results

FraiseQL is a Python runtime GraphQL framework for PostgreSQL. You define types and resolvers with decorators; at application startup the GraphQL schema is assembled in memory and served over FastAPI. There is no build step and no compiled artifact — everything happens at runtime.


Principle 1: Database-Centric Design

Statement: The database is the primary application interface, not an implementation detail.

What This Means

In FraiseQL, you don't build a GraphQL schema and then map it to a database. Instead, you design your PostgreSQL schema first, then derive your GraphQL API from it.

Traditional Approach:
┌──────────────────┐
│  GraphQL Schema  │  (source of truth)
│  (in code)       │
└────────┬─────────┘
         │ (maps to)
┌────────▼─────────┐
│  Database Schema │  (implementation)
└──────────────────┘

FraiseQL Approach:
┌──────────────────┐
│ Database Schema  │  (source of truth)
│ (views, tables) │
└────────┬─────────┘
         │ (generates)
┌────────▼─────────┐
│  GraphQL Schema  │  (API interface)
└──────────────────┘

Why This Matters

1. Database constraints become API guarantees

-- PostgreSQL write table (source of truth)
CREATE TABLE tb_user (
  pk_user BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,  -- internal key, never exposed
  id UUID NOT NULL UNIQUE,                       -- public GraphQL ID
  email VARCHAR(255) NOT NULL UNIQUE,            -- constraint at the database level
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

When you define NOT NULL in the database, FraiseQL's GraphQL schema correctly reflects that the field is non-nullable. You don't need to specify it twice. See Naming Patterns for why tb_user (singular write table), pk_user (internal BIGINT), and id (public UUID) are the conventions.

2. Database performance directly translates to API performance

-- Adding a PostgreSQL index
CREATE INDEX idx_user_email ON tb_user(email);

This index doesn't require any change to your FraiseQL code. The GraphQL API automatically gets faster because the underlying SQL queries execute faster.

3. Database expertise becomes API design expertise

A good database schema — with clear relationships, appropriate denormalization, and strategic read views — automatically becomes a good GraphQL API. Your database team's knowledge directly benefits API design.

Implications

  • ✅ Your PostgreSQL schema is your API contract
  • ✅ Database changes can be tested independently before API deployment
  • ✅ Database metrics directly predict API performance
  • ❌ You cannot add fields to the GraphQL API that don't exist in the database
  • ❌ You cannot hide database limitations — they become API limitations

Principle 2: Push Computation Into PostgreSQL

Statement: Do the work where the data lives — in PostgreSQL — and keep the application layer thin.

What This Means

FraiseQL deliberately puts the heavy lifting in PostgreSQL rather than in Python. Reads come from views that pre-shape their results as JSONB; writes go through PostgreSQL functions that own all the business logic.

Reads (queries): A @fraiseql.query resolver reads from a v_/tv_ view whose data JSONB column is built with jsonb_build_object(...). PostgreSQL composes the nested object; the application only requests it.

import fraiseql

@fraiseql.type(sql_source="v_user", jsonb_column="data")
class User:
    id: ID
    email: str
    created_at: DateTime

@fraiseql.query
async def users(info) -> list[User]:
    db = info.context["db"]
    return await db.find("v_user")

Writes (mutations): A @fraiseql.mutation resolver calls a fn_ PostgreSQL function via db.execute_function. The function performs validation and the write, then returns JSONB indicating success or failure.

@fraiseql.mutation
async def create_user(info, input: CreateUserInput) -> CreateUserSuccess | CreateUserError:
    db = info.context["db"]
    result = await db.execute_function(
        "fn_create_user",
        {"name": input.name, "email": input.email},
    )
    if not result.get("success"):
        return CreateUserError(message=result.get("message", "failed"))
    return CreateUserSuccess(user=User(**result["user"]))

The schema is assembled at application startup, in memory, by build_fraiseql_schema(...) (or wrapped as a FastAPI app by create_fraiseql_app(...)). There is no separate build or compile step and no on-disk schema artifact.

Why This Matters

1. One round trip, one query plan

Because a read view already builds the full nested data object, a GraphQL query resolves to a single SQL SELECT against that view rather than many small queries. PostgreSQL's planner handles the joins.

2. Business logic has one home

All write logic lives in fn_ functions inside PostgreSQL. Validation, constraints, and side effects are enforced transactionally next to the data, not scattered across application code.

3. The Rust pipeline accelerates the hot path

The optional Rust extension (fraiseql_rs) accelerates JSON transformation and field selection at runtime — trimming the view's data JSONB down to exactly the requested GraphQL fields. It is a performance accelerator on the JSON path, not a separate architecture.

Implications

  • ✅ Reads are a single planned query against a view
  • ✅ Write logic is centralized, transactional, and testable in SQL
  • ✅ Field selection happens on a fast Rust path at runtime
  • ❌ You must be comfortable writing PostgreSQL views and functions
  • ❌ Logic you would normally put in a Python resolver belongs in SQL instead

Principle 3: Type Safety as a Constraint

Statement: Types should be enforced at the database layer, not as a suggestion.

What This Means

In FraiseQL, type safety is enforced at multiple levels:

  1. Database schema enforcement
CREATE TABLE tb_product (
  pk_product BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
  id UUID NOT NULL UNIQUE,
  name VARCHAR(255) NOT NULL,      -- string type
  price NUMERIC(10, 2) NOT NULL,   -- numeric type
  is_active BOOLEAN NOT NULL       -- boolean type
);
  1. GraphQL schema enforcement
type Product {
  id: ID!
  name: String!
  price: Float!
  isActive: Boolean!
}
  1. Python type annotations on the decorated type
import fraiseql
from fraiseql.types import ID

@fraiseql.type(sql_source="v_product", jsonb_column="data")
class Product:
    id: ID
    name: str
    price: float
    is_active: bool

Why This Matters

1. Invalid states become impossible

You cannot accidentally return a null value for a field marked non-null. The annotated Python type and the GraphQL schema agree, and the database constraints back them.

2. Database constraints are API guarantees

Foreign keys in the database become relationship requirements in the API. Unique constraints become uniqueness guarantees.

3. Identifiers follow the trinity pattern

Public types expose id: ID! (a UUID) and optionally identifier: String (a human-readable slug). The internal pk_/fk_ BIGINT keys used for fast joins are never exposed. See the Trinity Pattern.

Example: Type Safety in Action

import fraiseql
from fraiseql.types import ID

# ❌ BAD: exposes an internal key, wrong identifier shape
@fraiseql.type(sql_source="v_user", jsonb_column="data")
class BadUser:
    user_id: str          # ❌ should be id: ID (public UUID)
    email: str

# ✅ GOOD: follows the trinity pattern
@fraiseql.type(sql_source="v_user", jsonb_column="data")
class User:
    id: ID                # ✅ public UUID identifier
    email: str            # ✅ matches the view's data JSONB

See Naming Patterns and the Type System for the full set of conventions and scalars.

Implications

  • ✅ Type mismatches surface when the schema is built at startup
  • ✅ Invalid data cannot reach the database
  • ✅ Client developers know exactly what types to expect
  • ❌ Cannot work around database type constraints in the API

Principle 4: Predictable Performance

Statement: Predictable behavior enables optimization and capacity planning.

What This Means

Because every read is a single query against a PostgreSQL view and every write is a single function call, the performance characteristics of each GraphQL operation map directly onto a known SQL query.

You can answer, per operation:

  • Which view or function does this operation hit?
  • What indexes does that view need for optimal performance?
  • What does EXPLAIN ANALYZE say about its query plan?
  • What's the worst-case latency under load?
# This nested query resolves against a single read view (v_user),
# whose `data` JSONB already embeds orders → items → product.
# PostgreSQL plans one SELECT; no per-row N+1 fan-out.
@fraiseql.query
async def user(info, id: ID) -> User | None:
    db = info.context["db"]
    return await db.find_one("v_user", id=id)
query GetUserOrders($id: ID!) {
  user(id: $id) {
    orders {
      items {
        product { name }
      }
    }
  }
}

Because the view pre-composes the nested structure, the whole tree comes back in one planned query. For heavy nested reads, a tv_ table-backed projection view stores the pre-composed JSONB and is refreshed by functions or triggers.

Why This Matters

1. Database teams can optimize effectively

With each operation mapped to a concrete view or function, database teams can identify bottlenecks, create the right indexes, and validate query plans before deployment.

2. Capacity planning becomes predictable

You can measure the cost of each operation and accurately predict system capacity needs.

3. Performance regressions are detectable

If a query suddenly becomes slow, you know something changed at the database level (new data volume, missing index, schema change) — not in an opaque application resolver.

Implications

  • ✅ Query performance is predictable and reproducible
  • ✅ Optimization efforts have a clear target (the view or function)
  • ✅ Performance problems are database problems — visible and fixable
  • ❌ Unexpected data growth can make a view expensive over time
  • ❌ Tuning means tuning PostgreSQL, not application code

See Performance Characteristics for measured behavior.


Principle 5: Simplicity Over Flexibility

Statement: Assume a single PostgreSQL data source and optimize for that case.

What This Means

FraiseQL is designed around one assumption: your data lives in PostgreSQL. Given that, everything else becomes simpler. FraiseQL v1 is PostgreSQL-only by design — it leans fully into PostgreSQL features (JSONB, views, functions, indexes) rather than abstracting them away behind a lowest-common-denominator interface.

If your data is in PostgreSQL:
  - No need to hand-write per-field resolvers
  - No need to orchestrate data from multiple sources
  - No need to implement caching logic in the app
  - No need to wire up relationship resolution by hand

Result: A GraphQL API with minimal application code

Examples of This Principle

Single source of truth

import fraiseql
from fraiseql.types import ID

# One PostgreSQL database is the source of truth.
@fraiseql.type(sql_source="v_user", jsonb_column="data")
class User:
    id: ID
    email: str
    # No custom resolver logic, no external API fetching,
    # no application-side caching to manage.

Relationships are explicit in SQL

-- Foreign keys define relationships (internal BIGINT keys)
ALTER TABLE tb_order
  ADD CONSTRAINT fk_order_user
  FOREIGN KEY (fk_user) REFERENCES tb_user(pk_user);

Read views embed related data into the data JSONB using these relationships, so the GraphQL API exposes them without a hand-written resolver. See Naming Patterns for the pk_/fk_ conventions.

Why This Matters

1. Less code to write and maintain

No per-field resolvers, no data loaders, no application caching logic. Just type definitions, views, and functions.

2. Easier to understand

A simpler system is easier to reason about, easier to debug, and easier to optimize.

3. Better performance for the common case

Without abstraction overhead, you get strong performance for the case FraiseQL targets: a single PostgreSQL database.

Implications

  • ✅ Minimal application code (type definitions plus SQL)
  • ✅ Clear data ownership and responsibility
  • ✅ Easy to understand and audit data access
  • ❌ Not suited to aggregating data from multiple external sources
  • ❌ Derived data is cached in PostgreSQL (e.g. tv_ projection views), not in the app
  • ❌ Complex calculations belong in the database

How These Principles Work Together

The five principles form a coherent design philosophy:

Database-Centric Design (Principle 1)
    ↓
    Means: PostgreSQL schema is your API contract
    ↓
Push Computation Into PostgreSQL (Principle 2)
    ↓
    Means: Reads from views, writes through functions
    ↓
Type Safety (Principle 3)
    ↓
    Means: Enforce types at every layer
    ↓
Predictable Performance (Principle 4)
    ↓
    Means: Each operation maps to a known SQL query
    ↓
Simplicity Over Flexibility (Principle 5)
    ↓
    Means: Single PostgreSQL source, minimal code

Real-World Consequence: Auditing

Together these principles enable a powerful property: clear query auditability.

-- Every GraphQL query resolves to a known SELECT on a v_/tv_ view,
-- and every mutation to a known fn_ function call.
-- So you can read the view/function definition to see exactly
-- what data each API operation touches and verify its authorization.

Real-World Consequence: Performance Optimization

1. Profile the view or function behind a slow operation (Principle 4)
2. Add a PostgreSQL index, or pre-compose with a tv_ projection view (Principle 1)
3. The API automatically gets faster — no application code changes (Principle 2)
4. Performance improves at the source (Principle 1)
5. Type safety is unchanged throughout (Principle 3)

When These Principles Apply

✅ FraiseQL Is Right When

  • Your data lives in PostgreSQL
  • Performance and predictability matter
  • You want to minimize application code
  • Your team has database expertise
  • You're comfortable shaping reads with views and writes with functions

❌ FraiseQL Is Wrong When

  • Your primary data is in a NoSQL system or a non-PostgreSQL database
  • You need to aggregate data from many external sources at request time
  • You need extensive application-layer business logic rather than logic in SQL


Summary

The five design principles of FraiseQL are:

  1. Database-Centric Design — PostgreSQL is the primary interface; GraphQL derives from it
  2. Push Computation Into PostgreSQL — reads from views, writes through functions
  3. Type Safety — types enforced at the database, GraphQL, and Python layers
  4. Predictable Performance — each operation maps to a known SQL query
  5. Simplicity Over Flexibility — assume a single PostgreSQL source, optimize for it

These principles work together to create a GraphQL system that is:

  • Simple: minimal application code needed
  • Predictable: each operation maps to a known query plan
  • Safe: type safety at every layer
  • Fast: PostgreSQL views and indexes plus the Rust JSON pipeline at runtime
  • Auditable: clear visibility into what each operation reads and writes