Skip to content

Migrating from Apollo Server to FraiseQL

This guide walks through migrating from manually-built Apollo Server GraphQL backends to FraiseQL’s database-first approach.

1. Write TypeScript interfaces
2. Write GraphQL schema by hand
3. Implement resolvers manually
4. Connect to database
5. Handle N+1 queries manually (DataLoader)
6. Implement caching manually
AspectApolloFraiseQL
Schema DefinitionGraphQL SDL (manual)Python/TS/Go types with decorators
ResolversHand-written TypeScriptMapped to SQL views — no resolver code
DatabaseORM or raw queries in resolversHand-written SQL views (v_*)
MutationsResolver functionsPostgreSQL functions (fn_*)
Cachingredis-apollo-linkBuilt-in federation cache
SubscriptionsWebSocket (manual PubSub)Native with NATS
PerformanceManual optimization + DataLoaderAutomatic batching via SQL joins
Type Safetyapollo-codegenNative SDK types
RuntimeNode.jsRust binary
ComplexityHigh (resolver logic)Low (decorators + SQL)
  1. Understand Your Current Apollo Setup

    Inventory all type definitions, resolvers, and subscriptions before migrating.

    import { ApolloServer, gql } from 'apollo-server-express';
    import { PrismaClient } from '@prisma/client';
    const typeDefs = gql`
    type User {
    id: ID!
    email: String!
    name: String!
    posts: [Post!]!
    }
    type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    }
    type Query {
    user(id: ID!): User
    users(limit: Int): [User!]!
    post(id: ID!): Post
    posts(limit: Int): [Post!]!
    }
    type Mutation {
    createUser(email: String!, name: String!): User!
    createPost(title: String!, content: String!, authorId: ID!): Post!
    }
    `;
    const resolvers = {
    Query: {
    user: async (_, { id }) => prisma.user.findUnique({ where: { id } }),
    users: async (_, { limit }) => prisma.user.findMany({ take: limit }),
    post: async (_, { id }) => prisma.post.findUnique({ where: { id } }),
    posts: async (_, { limit }) => prisma.post.findMany({ take: limit })
    },
    User: {
    posts: async (user) => prisma.post.findMany({ where: { userId: user.id } })
    // N+1 problem: runs once per user!
    },
    Post: {
    author: async (post) => prisma.user.findUnique({ where: { id: post.userId } })
    },
    Mutation: {
    createUser: async (_, { email, name }) =>
    prisma.user.create({ data: { email, name } }),
    createPost: async (_, { title, content, authorId }) =>
    prisma.post.create({ data: { title, content, userId: authorId } })
    }
    };
  2. Write SQL Views for Queries

    For every Apollo Query resolver, create a PostgreSQL view (v_*). Relationships between types become JOIN clauses in the view — no DataLoader required.

    -- v_user: replaces User query resolvers
    CREATE VIEW v_user AS
    SELECT
    u.id,
    u.email,
    u.name,
    COALESCE(
    json_agg(
    json_build_object('id', p.id, 'title', p.title, 'content', p.content)
    ) FILTER (WHERE p.id IS NOT NULL),
    '[]'
    ) AS posts
    FROM tb_user u
    LEFT JOIN tb_post p ON p.user_id = u.id
    GROUP BY u.id;
    -- v_post: replaces Post query resolvers
    CREATE VIEW v_post AS
    SELECT
    p.id,
    p.title,
    p.content,
    u.id AS author_id,
    u.email AS author_email,
    u.name AS author_name
    FROM tb_post p
    JOIN tb_user u ON u.id = p.user_id;
  3. Write PostgreSQL Functions for Mutations

    For every Apollo Mutation resolver, create a PostgreSQL function (fn_*).

    CREATE FUNCTION fn_create_user(p_email TEXT, p_name TEXT)
    RETURNS SETOF v_user AS $$
    BEGIN
    INSERT INTO tb_user (email, name) VALUES (p_email, p_name);
    RETURN QUERY SELECT * FROM v_user WHERE email = p_email;
    END;
    $$ LANGUAGE plpgsql;
    CREATE FUNCTION fn_create_post(p_title TEXT, p_content TEXT, p_author_id UUID)
    RETURNS SETOF v_post AS $$
    BEGIN
    INSERT INTO tb_post (title, content, user_id) VALUES (p_title, p_content, p_author_id);
    RETURN QUERY SELECT * FROM v_post WHERE user_id = p_author_id ORDER BY id DESC LIMIT 1;
    END;
    $$ LANGUAGE plpgsql;
  4. Convert Apollo Schema to FraiseQL Decorators

    Map each GraphQL SDL type to a FraiseQL type decorator. The Rust binary reads these decorator definitions to generate the full GraphQL schema — no SDL authoring needed.

    from fraiseql import FraiseQL, ID
    fraiseql = FraiseQL()
    @fraiseql.type
    class User:
    id: ID
    email: str
    name: str
    posts: list['Post']
    @fraiseql.type
    class Post:
    id: ID
    title: str
    content: str
    author_id: ID
    author: User
    # Queries map to SQL views
    @fraiseql.query(sql_source="v_user")
    def user(id: ID) -> User:
    """Get single user."""
    pass
    @fraiseql.query(sql_source="v_user")
    def users(limit: int = 50) -> list[User]:
    """Get multiple users."""
    pass
    @fraiseql.query(sql_source="v_post")
    def post(id: ID) -> Post:
    """Get single post."""
    pass
    @fraiseql.query(sql_source="v_post")
    def posts(limit: int = 50) -> list[Post]:
    """Get multiple posts."""
    pass
    # Mutations map to PostgreSQL functions
    @fraiseql.mutation(sql_source="fn_create_user")
    def create_user(email: str, name: str) -> User:
    """Create user."""
    pass
    @fraiseql.mutation(sql_source="fn_create_post")
    def create_post(title: str, content: str, author_id: ID) -> Post:
    """Create post."""
    pass
  5. Update Frontend Client Code

    The GraphQL query syntax is identical — only the client initialization changes.

    import { useQuery, gql } from '@apollo/client';
    const GET_USERS = gql`
    query GetUsers {
    users(limit: 10) { id name }
    }
    `;
    function UsersList() {
    const { data } = useQuery(GET_USERS);
    // ...
    }
  6. Migrate Subscriptions

    const pubsub = new PubSub();
    const resolvers = {
    Subscription: {
    userCreated: {
    subscribe: () => pubsub.asyncIterator(['USER_CREATED'])
    }
    },
    Mutation: {
    createUser: async (_, { email, name }) => {
    const user = await prisma.user.create({ data: { email, name } });
    pubsub.publish('USER_CREATED', { userCreated: user });
    return user;
    }
    }
    };
  7. Performance Test and Decommission

    Run performance tests to validate the improvement. Apollo with DataLoader typically runs 10-20 queries per request; FraiseQL uses pre-compiled SQL views, reducing this to 1-2 queries.

    Once results are confirmed, decommission the Apollo Server and remove its dependencies.

// Querying 10 users with posts = 11 queries
const users = await prisma.user.findMany({ take: 10 }); // 1 query
// Then for each user's posts field:
// User.posts resolver called 10 times = 10 more queries
// Total: 11 queries

Even with DataLoader, you must remember to wire it up manually for every relationship.

query GetUsers {
users(limit: 10) {
name
posts { # Resolved by a single batched SQL JOIN in the view
title
}
}
}

FraiseQL executes only 2 queries:

  1. SELECT * FROM v_user LIMIT 10
  2. SELECT * FROM v_post WHERE user_id IN (list_of_user_ids)

That’s 5x fewer queries — and you cannot accidentally create N+1 because the SQL is pre-written.

MetricApollo ServerFraiseQL
Resolver Lines500+0 (mapped to SQL views)
Queries/Request112
Request Time~200ms~20ms
Development TimeHighLow
CachingManualAutomatic
N+1 ProblemsCommonArchitecturally impossible
RuntimeNode.jsRust binary
  • Document all Apollo resolvers and queries
  • Map Apollo types to FraiseQL decorator types
  • Create SQL views (v_*) for all queries
  • Create PostgreSQL functions (fn_*) for all mutations
  • Define FraiseQL decorators
  • Test FraiseQL produces same results as Apollo
  • Update frontend clients
  • Migrate subscriptions to NATS
  • Performance testing (expect 5-10x faster)
  • Decommission Apollo

Prisma Migration

Also migrating from Prisma ORM.

Read guide

Hasura Migration

Coming from Hasura’s database-first approach.

Read guide

NATS Integration

Learn how FraiseQL subscriptions work with NATS.

Read guide

Getting Started

Learn FraiseQL fundamentals from scratch.

Read guide

  1. Test API parity — Ensure both APIs return identical data:

    Terminal window
    # Apollo query
    curl -X POST http://localhost:4000/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ users { id name posts { title } } }"}' \
    > apollo_result.json
    # FraiseQL query
    curl -X POST http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ users { id name posts { title } } }"}' \
    > fraiseql_result.json
    # Compare (should be identical)
    diff apollo_result.json fraiseql_result.json
  2. Load test comparison:

    Terminal window
    # Test Apollo performance
    ab -n 1000 -c 10 -p query.json -T application/json \
    http://localhost:4000/graphql
    # Test FraiseQL performance
    ab -n 1000 -c 10 -p query.json -T application/json \
    http://localhost:8080/graphql
  3. Check N+1 elimination:

    Terminal window
    # Enable query logging in PostgreSQL
    # Run a query with nested data
    # FraiseQL: Should see 1 SQL query
    # Apollo (without DataLoader): Would see N+1 queries
  4. Verify mutations work:

    Terminal window
    curl -X POST http://localhost:8080/graphql \
    -H "Content-Type: application/json" \
    -d '{
    "query": "mutation { createUser(email: \"test@test.com\", name: \"Test User\") { id name } }"
    }'
  5. Check subscriptions (if used):

    Terminal window
    # WebSocket test
    wscat -c ws://localhost:8080/subscriptions

Symptoms:

Error: Cannot compile schema - missing type User

Solution:

  1. Check all types are defined
  2. Verify forward references use string literals: 'User'
  3. Run: fraiseql validate

Symptoms: GraphQL queries work but return different results than Apollo.

Solution:

  1. Check SQL views match Apollo resolvers:

    -- Compare Apollo resolver logic
    prisma.user.findMany({ include: { posts: true } })
    -- With FraiseQL view
    \d+ v_user
  2. Verify column mappings:

    -- Check view columns match GraphQL fields
    SELECT column_name FROM information_schema.columns
    WHERE table_name = 'v_user';

Symptoms:

{
"errors": [{"message": "Function fn_create_user does not exist"}]
}

Solution:

  1. Create the PostgreSQL function:

    CREATE OR REPLACE FUNCTION fn_create_user(...)
  2. Check function signature matches mutation:

    SELECT proname, proargtypes FROM pg_proc WHERE proname LIKE 'fn_%';

Symptoms: FraiseQL queries are slower than Apollo.

Solution:

  1. Check for missing indexes:

    SELECT * FROM pg_indexes WHERE tablename LIKE 'tb_%';
  2. Analyze view performance:

    EXPLAIN ANALYZE SELECT data FROM v_user WHERE id = '123';
  3. Consider materialized views for heavy queries:

    CREATE MATERIALIZED VIEW tv_user AS ...

Symptoms: WebSocket connections fail.

Solution:

  1. Verify NATS is configured:

    [nats]
    enabled = true
    url = "nats://localhost:4222"
  2. Check subscription decorator:

    @fraiseql.subscription(entity_type="User", topic="user_created")
  3. Test NATS connectivity:

    Terminal window
    nats pub test "hello"