Skip to content

Federation Example - E-Commerce Platform

This guide walks through building a complete e-commerce backend using FraiseQL’s federation capabilities to coordinate across three separate databases.

Client
|
FraiseQL API
|
+-- Primary DB (Orders, Customers, Shipping)
+-- Inventory DB (Products, Stock, Reserves)
+-- Payments DB (Ledger, Refunds, Billing)
-- Customers
CREATE TABLE tb_customer (
pk_customer SERIAL PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
identifier TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
phone TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Orders
CREATE TABLE tb_order (
pk_order SERIAL PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
identifier TEXT NOT NULL UNIQUE,
fk_customer INTEGER NOT NULL REFERENCES tb_customer(pk_customer),
total DECIMAL(10, 2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Order Items
CREATE TABLE tb_order_item (
pk_order_item SERIAL PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
fk_order INTEGER NOT NULL REFERENCES tb_order(pk_order),
product_id UUID NOT NULL, -- FK to inventory DB
quantity INT NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Shipping Info
CREATE TABLE tb_shipping (
pk_shipping SERIAL PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
fk_order INTEGER NOT NULL UNIQUE REFERENCES tb_order(pk_order),
address TEXT NOT NULL,
city TEXT NOT NULL,
postal_code TEXT NOT NULL,
country TEXT NOT NULL,
tracking_number TEXT,
status TEXT NOT NULL DEFAULT 'pending',
estimated_delivery DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Views for FraiseQL (always with JSONB data column)
CREATE VIEW v_order AS
SELECT
o.id,
o.identifier,
jsonb_build_object(
'id', o.id::text,
'identifier', o.identifier,
'total', o.total,
'status', o.status,
'created_at', o.created_at,
'updated_at', o.updated_at
) AS data
FROM tb_order o
WHERE o.deleted_at IS NULL;
CREATE VIEW v_customer AS
SELECT
c.id,
c.identifier,
jsonb_build_object(
'id', c.id::text,
'identifier', c.identifier,
'name', c.name,
'email', c.email,
'phone', c.phone,
'created_at', c.created_at
) AS data
FROM tb_customer c
WHERE c.deleted_at IS NULL;
import fraiseql
from fraiseql.scalars import ID
from decimal import Decimal
from datetime import datetime
# ==================== PRIMARY DATABASE ====================
@fraiseql.type
class Customer:
id: ID
name: str
email: str
phone: str | None
created_at: datetime
payment_methods: list['PaymentMethod'] # Federated from payments DB
@fraiseql.type
class OrderItem:
id: ID
product_id: ID
quantity: int
unit_price: Decimal
product: 'Product' # Federated from inventory DB
@fraiseql.type
class Shipping:
id: ID
order_id: ID
address: str
city: str
postal_code: str
country: str
tracking_number: str | None
status: str
estimated_delivery: str | None
@fraiseql.type
class Order:
id: ID
total: Decimal
status: str
created_at: datetime
updated_at: datetime
items: list[OrderItem] # Local relation
shipping: Shipping # Local relation
customer: Customer # Federated from primary DB
payment: 'Transaction' # Federated from payments DB
reservations: list['Reservation'] # Federated from inventory DB
# ==================== INVENTORY DATABASE ====================
@fraiseql.type
class Product:
id: ID
sku: str
name: str
price: Decimal
@fraiseql.type
class InventoryStatus:
product_id: ID
sku: str
name: str
warehouse: str
quantity: int
reserved: int
available: int
@fraiseql.type
class Reservation:
id: ID
order_id: ID
product_id: ID
quantity: int
status: str
created_at: datetime
expires_at: datetime | None
released_at: datetime | None
product: Product # Federated from inventory DB (same DB, different view)
# ==================== PAYMENTS DATABASE ====================
@fraiseql.type
class PaymentMethod:
id: ID
customer_id: ID
type: str
is_default: bool
@fraiseql.type
class Transaction:
id: ID
order_id: ID
customer_id: ID
amount: Decimal
status: str
gateway: str | None
created_at: datetime
processed_at: datetime | None
@fraiseql.type
class Refund:
id: ID
transaction_id: ID
order_id: ID
amount: Decimal
reason: str | None
status: str
created_at: datetime
# ==================== QUERIES ====================
@fraiseql.query(sql_source="v_order")
def order(id: ID) -> Order:
"""Get order by ID with all federated relations."""
pass
@fraiseql.query(sql_source="v_order")
def orders(customer_id: ID | None = None, limit: int = 20) -> list[Order]:
"""Get orders, optionally filtered by customer."""
pass
@fraiseql.query(sql_source="v_customer")
def customer(id: ID) -> Customer:
"""Get customer with payment methods."""
pass
@fraiseql.query(sql_source="v_inventory_status")
def inventory_status(product_id: ID | None = None) -> list[InventoryStatus]:
"""Get current inventory status across warehouses."""
pass
# ==================== MUTATIONS ====================
@fraiseql.mutation(sql_source="fn_create_order", operation="CREATE")
def create_order(
customer_id: ID,
shipping_address: str,
shipping_city: str,
shipping_postal: str,
shipping_country: str,
payment_method_id: ID,
) -> Order:
"""Create order. fn_create_order handles inventory reservation and payment via pg_notify."""
pass

The create_order mutation coordinates writes across all three databases. In FraiseQL this is handled by a PostgreSQL function — the Python decorator is a compile-time declaration only. Cross-database coordination uses pg_notify events forwarded to NATS by FraiseQL observers.

db/schema/03_functions/fn_create_order.sql
CREATE OR REPLACE FUNCTION fn_create_order(
p_customer_id UUID,
p_shipping_address TEXT,
p_shipping_city TEXT,
p_shipping_postal TEXT,
p_shipping_country TEXT,
p_payment_method_id UUID
)
RETURNS SETOF v_order
LANGUAGE plpgsql AS $$
DECLARE
v_order tb_order;
BEGIN
-- Create order record
INSERT INTO tb_order (fk_customer, total, status)
SELECT pk_customer, 0, 'pending'
FROM tb_customer WHERE id = p_customer_id
RETURNING * INTO v_order;
-- Create shipping record
INSERT INTO tb_shipping (fk_order, address, city, postal_code, country, status)
VALUES (v_order.pk_order,
p_shipping_address, p_shipping_city,
p_shipping_postal, p_shipping_country, 'pending');
-- Notify inventory and payment services via pg_notify.
-- FraiseQL observers forward these to NATS topics.
PERFORM pg_notify(
'order_created',
jsonb_build_object(
'order_id', v_order.id,
'customer_id', p_customer_id,
'payment_method_id', p_payment_method_id
)::text
);
RETURN QUERY SELECT * FROM v_order WHERE pk_order = v_order.pk_order;
END;
$$;

Configure FraiseQL to forward pg_notify channels to NATS:

fraiseql.toml
[federation]
enabled = true
[[observers]]
channel = "order_created"
nats_subject = "fraiseql.order.created"
[[observers]]
channel = "order_cancelled"
nats_subject = "fraiseql.order.cancelled"

Inventory reservation and payment processing are handled by separate services that subscribe to these NATS subjects. Each service updates its own database and emits completion/failure events, allowing independent rollback without a centralised saga framework.

query GetOrder {
order(id: "550e8400-e29b-41d4-a716-446655440000") {
id
status
total
customer {
name
email
paymentMethods {
type
isDefault
}
}
items {
quantity
unitPrice
product {
sku
name
price
}
}
reservations {
status
product {
name
}
}
payment {
amount
status
processedAt
}
}
}

Configure separate connection pools for each database:

[databases.primary]
url = "${PRIMARY_DB}"
pool_size = 50
pool_timeout = 30000
[databases.inventory]
url = "${INVENTORY_DB}"
pool_size = 30
pool_timeout = 30000
[databases.payments]
url = "${PAYMENTS_DB}"
pool_size = 20
pool_timeout = 30000
[federation]
default_timeout = 5000 # 5 seconds
batch_size = 100
[federation.retry]
max_attempts = 2
backoff = "exponential"

Federation Reference

Complete federation API documentation.

NATS Integration

Event coordination across services.

Error Handling

Saga failure recovery patterns.

Performance Guide

Query optimization for federated APIs.