Skip to content

Federation

FraiseQL supports two federation modes:

  1. Multi-Database Federation — A single FraiseQL process connects to multiple databases simultaneously and routes queries to the appropriate database
  2. Apollo Federation v2 — FraiseQL acts as a GraphQL subgraph that can be composed with other services in an Apollo Federation gateway

This page covers multi-database federation. For Apollo Federation, see Apollo Federation Support below.

FraiseQL Federation lets you expose data from multiple databases — PostgreSQL, MySQL, SQLite — through a single GraphQL endpoint. Each database is a named connection in fraiseql.toml. Queries that touch different databases are resolved in parallel and merged before the response leaves the server.

Multi-database federation is a good fit when:

  • You have microservices where each service owns its own database, but you want a unified API layer
  • You are migrating a legacy database to a new schema and need to serve both simultaneously
  • You have a dedicated analytics database (read replica or separate OLAP store) alongside your transactional database
  • You are running PostgreSQL + MySQL together and do not want two separate APIs

Declare each database as a named entry under [databases] in fraiseql.toml:

[databases.primary]
url = "${DATABASE_URL}"
type = "postgresql"
[databases.analytics]
url = "${ANALYTICS_DB_URL}"
type = "postgresql"
[databases.legacy]
url = "${LEGACY_DB_URL}"
type = "mysql"

FraiseQL reads type to choose the correct driver. Supported values: "postgresql", "mysql", "sqlite", "sqlserver".

Assign each query to its database with the database parameter:

import fraiseql
from dataclasses import dataclass
@fraiseql.type
class User:
"""User record from the primary transactional database."""
id: str
name: str
email: str
@fraiseql.type
class UserAnalytics:
"""Engagement metrics from the analytics database."""
user_id: str
total_orders: int
lifetime_value: float
last_order_date: str
@fraiseql.query(sql_source="v_user", database="primary")
def user(id: str) -> User:
"""Fetch a user from the primary database."""
pass
@fraiseql.query(sql_source="v_user_analytics", database="analytics")
def user_analytics(user_id: str) -> UserAnalytics:
"""Fetch engagement metrics from the analytics database."""
pass

A single GraphQL operation can reference resolvers backed by different databases. FraiseQL dispatches the sub-queries in parallel and assembles the result:

query UserDashboard($id: ID!) {
user(id: $id) {
id
name
email
}
userAnalytics(userId: $id) {
totalOrders
lifetimeValue
lastOrderDate
}
}

Expected response:

{
"data": {
"user": {
"id": "usr_123",
"name": "Alice",
"email": "alice@example.com"
},
"userAnalytics": {
"totalOrders": 47,
"lifetimeValue": 3240.50,
"lastOrderDate": "2024-01-10"
}
}
}

user is resolved against v_user on the primary PostgreSQL connection. userAnalytics is resolved against v_user_analytics on the analytics PostgreSQL connection. Both queries run concurrently. The client receives a single merged response.

FraiseQL does not execute SQL JOINs across database boundaries — that would require moving data between servers. Instead, FraiseQL resolves each database segment independently and joins results in application memory before sending the response.

What this means in practice:

ScenarioBehaviour
Two fields from the same databaseSingle SQL query with a real JOIN
Two fields from different databasesTwo parallel queries; joined in FraiseQL memory
Filtering by a field from another databaseNot pushable to SQL; filter applied in memory
Ordering by a field from another databaseNot pushable to SQL; sort applied in memory

For list queries, FraiseQL batches lookups automatically. A query for 500 users followed by their analytics records produces two SQL queries — not 501:

query {
users(limit: 500) {
id
name
}
# userAnalytics is fetched for all 500 users in a single batched query
}

When a write on one database needs to trigger an event consumed by another service, FraiseQL can integrate with NATS for durable cross-service messaging.

To enable NATS integration:

[observers]
transport = "nats"
[observers.nats]
url = "nats://localhost:4222"

When a mutation executes, FraiseQL can publish events to NATS via the observer system. Downstream services subscribe to these events independently of their database connection.

When one database is unavailable, FraiseQL returns data from the healthy databases and null for fields backed by the unavailable one. The response includes a structured error:

{
"data": {
"user": { "id": "usr_123", "name": "Alice", "email": "alice@example.com" },
"userAnalytics": null
},
"errors": [
{
"message": "Database 'analytics' unavailable",
"path": ["userAnalytics"],
"extensions": { "code": "DATABASE_UNAVAILABLE", "database": "analytics" }
}
]
}

This allows read-heavy UIs to degrade gracefully. The client receives partial data rather than a complete failure.

Migration Pattern: Legacy + New Schema in Parallel

Section titled “Migration Pattern: Legacy + New Schema in Parallel”

A common use of federation is serving a legacy MySQL database alongside a new PostgreSQL schema during a migration. Both databases are exposed through the same API, and the client is unaware of the boundary:

[databases.primary]
url = "${NEW_POSTGRES_URL}"
type = "postgresql"
[databases.legacy]
url = "${OLD_MYSQL_URL}"
type = "mysql"
@fraiseql.query(sql_source="v_order", database="primary")
def order(id: str) -> Order:
"""New orders in PostgreSQL."""
pass
@fraiseql.query(sql_source="v_legacy_order", database="legacy")
def legacy_order(id: str) -> LegacyOrder:
"""Orders not yet migrated, still in MySQL."""
pass

Once migration is complete, remove the legacy database from fraiseql.toml and deprecate the legacyOrder query.

In addition to multi-database federation, FraiseQL supports Apollo Federation v2. This allows FraiseQL to participate in a larger GraphQL architecture where multiple services expose their schemas through a gateway.

[federation]
enabled = true
apollo_version = 2
[[federation.entities]]
name = "User"
key_fields = ["id"]
[[federation.entities]]
name = "Order"
key_fields = ["id"]

When using Apollo Federation, define entity resolvers that the gateway can call to resolve references:

@fraiseql.entity_resolver(entity="User", key_fields=["id"])
def resolve_user(id: str) -> User:
"""Resolve a User entity by ID for the Apollo gateway."""
pass

When to Use Apollo Federation vs. Multi-Database

Section titled “When to Use Apollo Federation vs. Multi-Database”
Use CaseRecommended Approach
Single team, multiple databasesMulti-database federation
Multiple teams, service boundariesApollo Federation
Migrating from Apollo GatewayApollo Federation
Maximum query performanceMulti-database federation (no gateway hop)
Independent service deploymentApollo Federation

When FraiseQL acts as a subgraph in an Apollo Federation setup, a per-entity circuit breaker prevents cascading failures. After a configured number of consecutive errors the circuit opens: further calls to that entity are rejected immediately with 503 Service Unavailable and a Retry-After header rather than waiting for a timeout.

The circuit moves through three states:

StateBehaviour
ClosedNormal operation — requests pass through
OpenTripped after failure_threshold consecutive errors — requests rejected with 503
HalfOpenRecovery probe — success_threshold successes closes the circuit
[federation.circuit_breaker]
enabled = true
failure_threshold = 5 # open after N consecutive errors (default: 5)
recovery_timeout_secs = 30 # probe after this many seconds (default: 30)
success_threshold = 2 # successes in HalfOpen needed to close (default: 2)
FieldTypeDefaultDescription
enabledbooltrueEnable the circuit breaker
failure_thresholdinteger5Consecutive errors before opening
recovery_timeout_secsinteger30Seconds before attempting a probe
success_thresholdinteger2Successes in HalfOpen before closing

Apply stricter thresholds to specific entities:

[[federation.circuit_breaker.per_database]]
database = "Order" # name must match the federation entity
failure_threshold = 3 # stricter: open after 3 errors
recovery_timeout_secs = 60

Multiple [[federation.circuit_breaker.per_database]] entries are allowed — each with its own database name.

FraiseQL exposes a Prometheus gauge for the circuit state of each entity:

fraiseql_federation_circuit_breaker_state{entity="Order"} 1
# 0 = Closed, 1 = Open, 2 = HalfOpen

Multi-database federation has these limitations:

  1. No cross-database transactions: Each database operation is independent. There is no two-phase commit or distributed transaction coordinator.
  2. No cross-database foreign keys: Referential integrity must be enforced at the application level.
  3. In-memory joins: Cross-database field resolution happens in FraiseQL memory, which can be slower than database-level joins for large result sets.
  4. Consistency: Different databases may have different replication lags. Data returned from multiple databases may not be point-in-time consistent.

Minimize cross-database queries. If two fields are frequently requested together, consider:

  • Moving them to the same database
  • Using materialized views to replicate data
  • Accepting the in-memory join performance tradeoff

Handle partial failures gracefully. When a database is unavailable, your client should be prepared to receive null for fields from that database with appropriate error handling.

Document your database boundaries. Make it clear in your schema which types come from which databases. This helps developers understand potential consistency and performance implications.

Multi-Database

Multi-Database — Configuration reference for multiple databases

Observers

Observers — React to cross-database changes