Skip to content

Advanced Federation Patterns

FraiseQL federation means Apollo Federation v2 subgraphs. Each FraiseQL service defines its own GraphQL schema using Python decorators, compiles it to a Rust runtime, and exposes a standard Apollo Federation subgraph endpoint. A gateway — either the built-in fraiseql gateway or Apollo Router — composes these independent subgraphs into a unified supergraph for clients.

This is fundamentally different from multi-database querying. Each FraiseQL service has one database and one compiled schema. Cross-service data access happens through Apollo Federation’s type extension and @key mechanism — not through Python code.

FraiseQL’s CQRS architecture — where each type is a single JSONB view owned by exactly one subgraph — dramatically simplifies federation query planning compared to general-purpose gateways:

ConcernGeneral-purpose gatewayFraiseQL gateway
Routing granularityField-level (complex query planner)Type-level (HashMap lookup)
Type ownershipMultiple subgraphs can contribute fields to the same typeEach type is fully owned by one subgraph
Query plan complexityDependency graph with @requires / @providesFan-out + batch _entities
Entity resolutionPer-field fetch with data dependency orderingBatch _entities call per subgraph

This works because FraiseQL enforces single-owner JSONB views: a User type maps to v_user in exactly one subgraph. There is no field-level splitting to coordinate. The gateway’s query planner is a HashMap<TypeName, SubgraphUrl> — orders of magnitude simpler than a general-purpose planner.

See Federation Gateway Guide for setup and configuration.

graph TD
client["Client"]
subgraph SUPERGRAPH["Apollo Router (Supergraph)"]
router["Apollo Router\n(query planning + routing)"]
end
subgraph USER_SVC["User Service"]
user_py["schema.py\n@fraiseql.type User"]
user_compiled["schema.compiled.json"]
user_rust["Rust Runtime\n:4001/graphql"]
user_db["PostgreSQL\ntb_user, v_user"]
end
subgraph ORDER_SVC["Order Service"]
order_py["schema.py\n@fraiseql.type Order"]
order_compiled["schema.compiled.json"]
order_rust["Rust Runtime\n:4002/graphql"]
order_db["PostgreSQL\ntb_order, v_order"]
end
client -- "GraphQL query" --> router
router -- "subgraph query" --> user_rust
router -- "subgraph query" --> order_rust
user_py -- "fraiseql compile" --> user_compiled --> user_rust
order_py -- "fraiseql compile" --> order_compiled --> order_rust
user_rust --> user_db
order_rust --> order_db

FraiseQL Python is schema authoring only — no runtime Python executes queries:

Python @decorators → schema.json → fraiseql compile → schema.compiled.json → Rust runtime

Each service completes this pipeline independently. The Rust runtime is a fully Apollo Federation v2 compliant subgraph server.


Apollo Federation v2 uses @key directives to declare which fields uniquely identify an entity. FraiseQL emits these directives when you annotate your types.

user-service/schema.py
import fraiseql
from fraiseql.scalars import ID, DateTime
@fraiseql.type
class User:
"""Apollo Federation entity — resolvable by id."""
id: ID
name: str
email: str
created_at: DateTime
@fraiseql.query
def user(id: ID) -> User | None:
"""Look up a single user."""
return fraiseql.config(sql_source="v_user")
@fraiseql.query
def users(limit: int = 20, offset: int = 0) -> list[User]:
"""List all users."""
return fraiseql.config(sql_source="v_user")
fraiseql.export_schema("schema.json")

The corresponding fraiseql.toml declares this as a federation subgraph and names the entity key:

user-service/fraiseql.toml
[project]
name = "user-service"
version = "1.0.0"
[fraiseql]
schema_file = "schema.json"
output_file = "schema.compiled.json"
[database]
type = "postgresql"
url = "${DATABASE_URL}"
[federation]
enabled = true
entity_key = "id"

The compiled schema exposes a standard Apollo Federation subgraph with _entities and _service fields, and User @key(fields: "id") in the SDL.

The Order service defines Order types that reference User by its federation key, without importing or calling the User service from Python:

order-service/schema.py
import fraiseql
from fraiseql.scalars import ID, DateTime, Decimal
from enum import Enum
@fraiseql.enum
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
@fraiseql.type
class Order:
"""An order placed by a user."""
id: ID
user_id: ID # The federation join key — matches User.id
status: OrderStatus
total: Decimal
created_at: DateTime
@fraiseql.input
class CreateOrderInput:
user_id: ID
items: list[str]
@fraiseql.query
def order(id: ID) -> Order | None:
"""Look up a single order."""
return fraiseql.config(sql_source="v_order")
@fraiseql.query
def orders(limit: int = 20, offset: int = 0) -> list[Order]:
"""List all orders."""
return fraiseql.config(sql_source="v_order")
@fraiseql.mutation(sql_source="fn_create_order", operation="CREATE")
def create_order(input: CreateOrderInput) -> Order:
"""Create a new order."""
pass
fraiseql.export_schema("schema.json")
order-service/fraiseql.toml
[project]
name = "order-service"
version = "1.0.0"
[fraiseql]
schema_file = "schema.json"
output_file = "schema.compiled.json"
[database]
type = "postgresql"
url = "${DATABASE_URL}"
[federation]
enabled = true
entity_key = "id"
# Declare that Order.user_id references User.id in the user-service subgraph
external_references = [
{ type = "User", key = "id", field = "user_id" }
]

The Apollo Router composes all subgraph schemas into a unified supergraph. It handles query planning — splitting a client query across the appropriate subgraphs and merging results.

router.yaml
supergraph:
listen: 0.0.0.0:4000
subgraphs:
users:
routing_url: http://user-service:4001/graphql
orders:
routing_url: http://order-service:4002/graphql
# Federation introspection: Apollo Router fetches SDL from each subgraph
# at startup to build the supergraph composition

To compose the supergraph schema using the Rover CLI:

Terminal window
rover supergraph compose --config supergraph.yaml > supergraph.graphql
supergraph.yaml
federation_version: =2.4.0
subgraphs:
users:
routing_url: http://user-service:4001/graphql
schema:
subgraph_url: http://user-service:4001/graphql
orders:
routing_url: http://order-service:4002/graphql
schema:
subgraph_url: http://order-service:4002/graphql

Once composed, clients query the supergraph as if it were a single GraphQL API. The Apollo Router resolves Order.user by:

  1. Fetching the order from the Order service (gets user_id)
  2. Using user_id as the @key to fetch User from the User service via the _entities query
# Client queries the Apollo Router on :4000
query GetOrderWithUser {
order(id: "550e8400-e29b-41d4-a716-446655440000") {
id
status
total
user {
id
name
email
}
}
}

No Python code handles the join. The Apollo Router manages cross-service data fetching based on the compiled supergraph schema.


Each FraiseQL service independently enforces its own authorization using fraiseql.field(requires_scope=...). Authorization is compile-time configuration baked into each service’s schema.compiled.json.

user-service/schema.py
from typing import Annotated
import fraiseql
from fraiseql.scalars import ID, DateTime
@fraiseql.type
class User:
id: ID
name: str
# Requires scope to access — enforced by the User service Rust runtime
email: Annotated[str, fraiseql.field(requires_scope="read:User.email")]
# Mask mode: returns null for callers without the scope
phone: Annotated[str | None, fraiseql.field(
requires_scope="read:User.phone",
on_deny="mask"
)]
created_at: DateTime
order-service/schema.py
from typing import Annotated
import fraiseql
from fraiseql.scalars import ID, Decimal, DateTime
@fraiseql.type
class Order:
id: ID
user_id: ID
status: str
# Only billing team can see the raw total
total: Annotated[Decimal, fraiseql.field(requires_scope="billing:read")]
created_at: DateTime

Each FraiseQL service owns its own PostgreSQL database following the standard trinity pattern.

-- tb_user: write target
CREATE TABLE tb_user (
pk_user BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL, -- email address as human key
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX idx_tb_user_id ON tb_user(id);
CREATE UNIQUE INDEX idx_tb_user_identifier ON tb_user(identifier);
-- v_user: read side, returns JSONB for FraiseQL
CREATE VIEW v_user AS
SELECT
u.id,
jsonb_build_object(
'id', u.id::text,
'identifier', u.identifier,
'name', u.name,
'email', u.identifier,
'created_at', u.created_at
) AS data
FROM tb_user u;
-- tb_order: write target
CREATE TABLE tb_order (
pk_order BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL, -- order number as human key
fk_user BIGINT NOT NULL, -- internal FK to tb_user in user-service (denormalized)
user_id UUID NOT NULL, -- public UUID exposed in GraphQL
status TEXT NOT NULL DEFAULT 'pending',
total NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX idx_tb_order_id ON tb_order(id);
CREATE INDEX idx_tb_order_user_id ON tb_order(user_id);
-- v_order: read side
CREATE VIEW v_order AS
SELECT
o.id,
jsonb_build_object(
'id', o.id::text,
'identifier', o.identifier,
'user_id', o.user_id::text,
'status', o.status,
'total', o.total,
'created_at', o.created_at
) AS data
FROM tb_order o;

Each service follows the same build and deployment pattern:

Terminal window
# User service
cd user-service/
python schema.py # generates schema.json
fraiseql compile # generates schema.compiled.json
fraiseql-server --schema schema.compiled.json --port 4001
# Order service
cd order-service/
python schema.py # generates schema.json
fraiseql compile # generates schema.compiled.json
fraiseql-server --schema schema.compiled.json --port 4002
# Apollo Router
rover supergraph compose --config supergraph.yaml > supergraph.graphql
router --config router.yaml --supergraph supergraph.graphql

Or with Docker Compose:

docker-compose.yml
services:
user-service:
build: ./user-service
environment:
DATABASE_URL: postgresql://postgres:password@user-db:5432/users
ports:
- "4001:4001"
order-service:
build: ./order-service
environment:
DATABASE_URL: postgresql://postgres:password@order-db:5432/orders
ports:
- "4002:4002"
apollo-router:
image: ghcr.io/apollographql/router:latest
volumes:
- ./router.yaml:/dist/config/router.yaml
- ./supergraph.graphql:/dist/config/supergraph.graphql
ports:
- "4000:4000"
depends_on:
- user-service
- order-service

The pattern extends naturally. A Review service that references both User and Order:

review-service/schema.py
import fraiseql
from fraiseql.scalars import ID, DateTime
@fraiseql.type
class Review:
"""A product review left by a user on an order."""
id: ID
user_id: ID # References User.id in user-service
order_id: ID # References Order.id in order-service
rating: int
body: str
created_at: DateTime
@fraiseql.query
def reviews(order_id: ID | None = None, limit: int = 20) -> list[Review]:
"""List reviews, optionally filtered by order."""
return fraiseql.config(sql_source="v_review")
@fraiseql.input
class CreateReviewInput:
order_id: ID
rating: int
body: str
@fraiseql.mutation(
sql_source="fn_create_review",
operation="CREATE",
inject={"user_id": "jwt:sub"},
)
def create_review(input: CreateReviewInput) -> Review:
"""Create a review. User ID injected from JWT."""
pass
fraiseql.export_schema("schema.json")

Add to supergraph.yaml and recompose — no changes needed to the existing User or Order services.


Each FraiseQL Rust runtime exposes Prometheus metrics independently. The Apollo Router exposes additional federation-level metrics.

any-service/fraiseql.toml
[security.enterprise]
enabled = true
log_level = "info"

Apollo Router metrics to track:

  • apollo_router_http_request_duration_seconds — end-to-end latency
  • apollo_router_subgraph_request_duration_seconds{subgraph="users"} — per-subgraph latency
  • apollo_router_cache_hit_count — query plan cache efficiency

  • Each FraiseQL service owns exactly one domain and one database
  • Services share entity keys (UUIDs) but never share database connections
  • Keep services small enough that their schema.py fits in one file
  • Use UUID (ID scalar) as federation keys — never expose internal pk_ integers
  • Denormalize foreign UUIDs in each service’s database for federation joins
  • Follow the trinity pattern (pk_ + id UUID + identifier TEXT) in every table
  • Compile schemas at build time, not at container startup
  • Pin Apollo Router version in your supergraph compose config
  • Test subgraph schemas individually before composing
  • Each service enforces its own scopes independently
  • Pass the original JWT from the client through to all subgraphs via Apollo Router header forwarding
  • Never trust Apollo Router to enforce authorization — each subgraph must validate

Federation Feature Reference

Federation — Full feature reference including circuit breaker and NATS