From Prisma
Replace ORM-based reads with SQL views, keep Prisma for writes as long as you need.
Enterprise systems don’t migrate overnight. This guide shows you how to introduce FraiseQL into a running production system while your existing API continues to serve 100% of traffic — no cutover night, no all-or-nothing risk.
The strategy is the strangler fig pattern: FraiseQL grows alongside your existing system, one domain at a time, until the old system has nothing left to do.
Most migration strategies require parallel systems that can conflict. FraiseQL avoids this because:
v_* SQL views. Views are additive — they don’t modify tables, stored procedures, or anything your existing system depends on.fn_* functions or stored procedures. You add mutations only when you’re ready to replace an existing write path. Until then, your ORM or REST handlers continue to own writes.This means introducing FraiseQL is a series of view additions, not a system replacement. At no point does your existing API stop working.
[Phase 0] Existing system only (REST / ORM / Apollo / Hasura) ↓ ~1 day: install, connect, first view[Phase 1] FraiseQL running alongside, no client traffic ↓ ~1–2 weeks: build view coverage for one domain[Phase 2] FraiseQL serving reads for chosen domains ↓ ~1 week per domain: migrate client traffic endpoint by endpoint[Phase 3] FraiseQL owns all reads; existing system owns writes ↓ optional — only if replacing the write path[Phase 4] FraiseQL owns reads + writes via stored procedures ↓ only when confidence is high[Phase 5] Old system retiredAny phase can be your final state. Many teams permanently stay at Phase 3 — FraiseQL for reads, Entity Framework or Dapper for writes. This is not a compromise; it’s a legitimate production architecture.
The goal of this phase is to get FraiseQL running against your existing database while being physically incapable of affecting your existing system.
FraiseQL only needs SELECT on the views it serves. A dedicated read-only role ensures it literally cannot affect any write path during evaluation:
-- Create a read-only role for FraiseQLCREATE ROLE fraiseql_readonly WITH LOGIN PASSWORD 'choose-a-strong-password';GRANT CONNECT ON DATABASE myapp TO fraiseql_readonly;GRANT USAGE ON SCHEMA public TO fraiseql_readonly;GRANT SELECT ON ALL TABLES IN SCHEMA public TO fraiseql_readonly;
-- Also grant SELECT on future tables/viewsALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO fraiseql_readonly;-- Create a read-only login and userCREATE LOGIN fraiseql_readonly WITH PASSWORD = 'choose-a-strong-password';USE myapp;CREATE USER fraiseql_readonly FOR LOGIN fraiseql_readonly;EXEC sp_addrolemember 'db_datareader', 'fraiseql_readonly';-- Current connections vs limitSELECT count(*) AS active_connections, (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_connections, (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') - count(*) AS headroomFROM pg_stat_activity;If headroom is less than 10, start FraiseQL with a conservative pool:
[database]pool_min = 1pool_max = 5 # increase only after confirming headroom-- Current user connectionsSELECT COUNT(*) AS active_connectionsFROM sys.dm_exec_sessionsWHERE is_user_process = 1;
-- Max connections settingSELECT value_in_use AS max_connectionsFROM sys.configurationsWHERE name = 'max connections';Run FraiseQL on a port your existing API isn’t using. Your existing system keeps its port and serves 100% of traffic:
[database]url = "${FRAISEQL_DATABASE_URL}" # read-only role credentials
[server]port = 8081 # existing API stays on 8080 (or wherever it is)# Install FraiseQLcurl -fsSL https://install.fraiseql.dev | sh
# Start it — it connects to the DB but serves no client traffic yetfraiseql runRollback: kill the process. Nothing in your database has changed.
Don’t try to migrate everything. Pick one domain, build FraiseQL coverage for it, and validate before routing any traffic.
Good candidates:
Avoid first:
-- Add one view — does not touch any existing table or stored procedureCREATE VIEW v_order ASSELECT o.id, o.status, o.total_cents::float / 100 AS total, o.customer_id, o.created_atFROM tb_order o;@fraiseql.typeclass Order: id: str status: str total: float customer_id: str
@fraiseql.querydef orders(limit: int = 20, offset: int = 0) -> list[Order]: return fraiseql.config(sql_source="v_order")Compare FraiseQL’s output to your existing API before routing any real traffic:
# Fetch the same data from both systemsEXISTING=$(curl -s "http://localhost:8080/api/orders?limit=10")FRAISEQL=$(curl -s -X POST http://localhost:8081/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ orders(limit: 10) { id status total } }"}')
# Normalize and diff the key fieldsecho "$EXISTING" | jq '[.[] | {id, status, total}] | sort_by(.id)' \ > /tmp/existing.jsonecho "$FRAISEQL" | jq '[.data.orders[] | {id, status, total}] | sort_by(.id)' \ > /tmp/fraiseql.json
diff /tmp/existing.json /tmp/fraiseql.jsonA clean diff means the data is equivalent. If there are differences, fix the view before proceeding.
Before routing any client traffic to FraiseQL for a domain, confirm:
Rollback: all clients still point to the existing API. Nothing to undo.
Once a domain passes the pre-switch checklist, move clients to FraiseQL. Three approaches:
Update clients one by one to point at the FraiseQL endpoint. No infrastructure change. Rollback = change the URL back.
// Beforeconst client = new GraphQLClient('https://api.example.com/graphql');
// After — FraiseQL endpoint (or a different path on the same host via reverse proxy)const client = new GraphQLClient('https://api.example.com/graphql/v2');Route /graphql to FraiseQL; keep existing routes on the old system. Transparent to individual client teams.
# nginx example — route GraphQL to FraiseQL, everything else to existing APIlocation /graphql { proxy_pass http://fraiseql:8081;}
location / { proxy_pass http://existing-api:8080;}Gate the switch per client or per user segment. Maximum control, requires a flag system.
Rollback at this phase: switch the client back to the old endpoint. FraiseQL keeps running and views remain — no data is at risk.
For teams that want to move writes to FraiseQL:
fn_source@fraiseql.inputclass CreateOrderInput: customer_id: str items: list[str]
@fraiseql.mutationdef create_order(input: CreateOrderInput) -> Order: return fraiseql.config(sql_source="fn_create_order")-- Your existing stored procedure, unchanged —-- or a new one that calls itCREATE FUNCTION fn_create_order( p_customer_id UUID, p_items JSONB) RETURNS TABLE(id UUID, status TEXT, total NUMERIC) AS $$BEGIN -- ... existing business logicEND;$$ LANGUAGE plpgsql;Before shutting anything down:
One of the less obvious blockers for incremental migration is JWT compatibility. Your existing system may use claim names or signing keys that differ from what FraiseQL expects.
The simplest case. Configure FraiseQL with the same secret and tokens work immediately:
[security]jwt_secret = "${JWT_SECRET}" # same secret as your existing systemScope claims are more flexible. FraiseQL accepts permissions from any of these claims, checked in order:
scope (space-separated string — Auth0, Okta standard)scp (array — some Azure AD / Entra ID configs)permissions (array — Auth0 RBAC style)If your existing system uses one of these, no changes are needed.
FraiseQL can be configured to accept tokens from your existing auth system. Set the JWKS URI for OIDC providers:
[security]jwks_uri = "https://your-auth-provider.com/.well-known/jwks.json"jwt_audience = "https://api.example.com"Tokens issued by your existing provider work on FraiseQL without clients needing to re-authenticate.
FraiseQL requires a JWT Authorization: Bearer header. Teams using cookie-based sessions need a token exchange endpoint that issues short-lived JWTs from a valid session cookie. This is a one-time infrastructure addition; clients don’t change their auth flow.
| Phase | What FraiseQL has done | Rollback procedure | Data at risk? |
|---|---|---|---|
| 0→1 | Connected to DB, serving no traffic | Kill the process | None |
| 1→2 | Views added to DB, serving internal validation traffic | Remove views, kill process | None |
| 2→3 | Some client traffic routed to FraiseQL reads | Reroute clients to old endpoint | None |
| 3→4 | Some mutations going through FraiseQL | Reroute writes to old endpoint | None if idempotent |
| 5 | Old system shut down | Restart old system (if kept) | None if infra is retained |
Views are additive. At every phase before retirement, your existing system is still running and fully functional.
The guides below cover concept mapping for each starting point. After reading one, come back here for the phase-by-phase operational strategy.
From Prisma
Replace ORM-based reads with SQL views, keep Prisma for writes as long as you need.
From Apollo Server
Replace resolver boilerplate with views, migrate mutations on your schedule.
From Hasura
Replace auto-generated GraphQL with hand-crafted SQL views for precise control.
From REST API
Consolidate multiple endpoints into GraphQL, one domain at a time.