Build Your First API
Get a working GraphQL API in minutes. Quick Start Guide
Apollo is the most popular GraphQL framework. Here’s how FraiseQL differs — and when each is the right choice.
Apollo requires you to write resolver functions for every field and relationship.
FraiseQL eliminates resolvers entirely by mapping your schema to optimized database views. It runs as a compiled Rust binary with no resolver code needed.
| Aspect | FraiseQL | Apollo Server |
|---|---|---|
| Architecture | Compiled Rust binary, database-first | Runtime, resolver-based |
| Resolver code | None | Required for every field |
| N+1 handling | Eliminated by design | DataLoader (manual setup) |
| Configuration | TOML | JavaScript/TypeScript |
| Schema definition | Code (Python, TS, Go…) | SDL or code-first |
| Performance | Predictable, sub-ms | Depends on resolvers |
| Subscriptions | Native (WebSocket + NATS) | Requires Apollo Server + PubSub setup |
| Schema federation | Built-in multi-database | Apollo Federation (separate package) |
| Learning curve | Lower | Higher |
| Flexibility | Database-centric | Unlimited |
// Apollo Serverconst resolvers = { Query: { users: async (_, args, context) => { return context.db.query('SELECT * FROM users'); }, user: async (_, { id }, context) => { return context.db.query('SELECT * FROM users WHERE id = $1', [id]); }, }, User: { posts: async (user, _, context) => { // N+1 problem without DataLoader! return context.db.query( 'SELECT * FROM posts WHERE author_id = $1', [user.id] ); }, }, Post: { author: async (post, _, context) => { return context.db.query( 'SELECT * FROM users WHERE id = $1', [post.author_id] ); }, comments: async (post, _, context) => { return context.db.query( 'SELECT * FROM comments WHERE post_id = $1', [post.id] ); }, }, Comment: { author: async (comment, _, context) => { return context.db.query( 'SELECT * FROM users WHERE id = $1', [comment.author_id] ); }, },};Lines of resolver code: 40+ Potential N+1 queries: 4 DataLoaders needed: 3+
# FraiseQL@fraiseql.typeclass User: id: str name: str posts: list['Post']
@fraiseql.typeclass Post: id: str title: str author: User comments: list['Comment']
@fraiseql.typeclass Comment: id: str content: str author: Userimport { type } from 'fraiseql';
@type()class User { id!: string; name!: string; posts!: Post[];}
@type()class Post { id!: string; title!: string; author!: User; comments!: Comment[];}
@type()class Comment { id!: string; content!: string; author!: User;}import "github.com/fraiseql/fraiseql-go"
type User struct { ID fraiseql.ID `fraiseql:"id"` Name string `fraiseql:"name"` Posts []Post `fraiseql:"posts"`}
type Post struct { ID fraiseql.ID `fraiseql:"id"` Title string `fraiseql:"title"` Author User `fraiseql:"author"` Comments []Comment `fraiseql:"comments"`}
type Comment struct { ID fraiseql.ID `fraiseql:"id"` Content string `fraiseql:"content"` Author User `fraiseql:"author"`}Lines of code: 15 (Python) / 24 (TypeScript) / 21 (Go) Potential N+1 queries: 0 DataLoaders needed: 0
// You must manually implement DataLoadersconst userLoader = new DataLoader(async (ids) => { const users = await db.query( 'SELECT * FROM users WHERE id = ANY($1)', [ids] ); return ids.map(id => users.find(u => u.id === id));});
// And wire them up in contextconst context = { loaders: { user: userLoader, post: postLoader, comment: commentLoader, }};
// And use them in every resolverUser: { posts: (user, _, { loaders }) => loaders.post.loadMany(user.postIds)}-- Composed views: tb_ tables, v_ views, child .data embedded in parentCREATE VIEW v_comment ASSELECT c.id, c.fk_post, jsonb_build_object('id', c.id, 'content', c.content) AS dataFROM tb_comment c;
CREATE VIEW v_post ASSELECT p.id, p.fk_user, jsonb_build_object( 'id', p.id, 'title', p.title, 'comments', COALESCE(jsonb_agg(vc.data), '[]'::jsonb) ) AS dataFROM tb_post pLEFT JOIN v_comment vc ON vc.fk_post = p.idGROUP BY p.id, p.fk_user;
CREATE VIEW v_user ASSELECT u.id, jsonb_build_object( 'id', u.id, 'name', u.name, 'posts', COALESCE(jsonb_agg(vp.data), '[]'::jsonb) ) AS dataFROM tb_user uLEFT JOIN v_post vp ON vp.fk_user = u.idGROUP BY u.id;One query. Zero N+1. No DataLoaders.
type User { id: ID! name: String! email: String! posts: [Post!]!}
type Post { id: ID! title: String! author: User!}
type Query { users: [User!]! user(id: ID!): User}Plus all the resolver code shown above.
@fraiseql.typeclass User: id: str name: str email: str posts: list['Post']
@fraiseql.typeclass Post: id: str title: str author: UserQueries and mutations are derived from your SQL views and functions.
// Apollo Server subscriptions require a PubSub implementationimport { PubSub } from 'graphql-subscriptions';const pubsub = new PubSub();
const resolvers = { Subscription: { userCreated: { subscribe: () => pubsub.asyncIterator(['USER_CREATED']), }, }, Mutation: { createUser: async (_, { input }) => { const user = await db.createUser(input); // Must manually publish to subscribers pubsub.publish('USER_CREATED', { userCreated: user }); return user; }, },};For production, graphql-subscriptions must be replaced with a scalable PubSub backend (Redis, MQTT, etc.) and managed separately.
@fraiseql.subscription(entity_type="User", topic="user_created")def user_created() -> User: """Subscribe to new users via NATS.""" passFraiseQL subscriptions are backed by NATS messaging. When a PostgreSQL function (fn_*) completes a write, FraiseQL automatically publishes the event — no manual pubsub.publish() calls needed.
Apollo Federation is designed to compose multiple independent GraphQL subgraph services into a unified supergraph. Each subgraph is a separate process with its own schema, and the Apollo Router stitches them together at the gateway layer.
type User @key(fields: "id") { id: ID! name: String!}
# posts-subgraph/schema.graphqltype Post { id: ID! title: String! author: User @provides(fields: "name")}
extend type User @key(fields: "id") { id: ID! @external posts: [Post!]!}Each subgraph is a separate deployed service. You run an Apollo Router instance in front of them. Federation resolves cross-service references at the gateway level with additional network hops.
FraiseQL federation connects multiple databases (PostgreSQL, MySQL, SQLite, SQL Server) inside a single compiled Rust binary. There is no gateway layer, no inter-service network calls, and no subgraph protocol.
[[databases]]name = "users_db"url = "${USERS_DB_URL}"
[[databases]]name = "analytics_db"url = "${ANALYTICS_DB_URL}"@fraiseql.typeclass User: id: str name: str # analytics field resolved from analytics_db at SQL level page_views: intFraiseQL joins across databases at the SQL view layer — the result is a single query executed by the Rust binary, with no inter-process communication.
| Apollo Federation | FraiseQL Federation | |
|---|---|---|
| Unit of federation | GraphQL subgraph services | Databases |
| Architecture | Distributed (multiple processes + gateway) | Single process |
| Cross-unit join | Network hop through Router | SQL JOIN in view |
| Deployment | Router + N subgraph services | Single Rust binary |
| Use case | Independent teams, polyglot services | Multiple databases, monolithic backend |
Use Apollo Federation when you need to compose independently deployed services owned by separate teams. Use FraiseQL federation when your data lives in multiple databases but you want a single cohesive backend.
Apollo relies on decorators and custom validation logic:
// Apollo Server with @apollo/server and custom validatorsimport { GraphQLInputObjectType } from 'graphql';
const userInputValidator = new GraphQLInputObjectType({ name: 'UserInput', fields: { email: { type: GraphQLString, validate: (value) => { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { throw new Error('Invalid email'); } } } }});@constraint directivesFraiseQL enforces validation during schema compilation:
[fraiseql.validation]email = { pattern = "^[^@]+@[^@]+\\.[^@]+$" }age = { range = { min = 0, max = 150 } }status = { enum = ["active", "inactive"] }| Aspect | FraiseQL | Apollo Server |
|---|---|---|
| Built-in validators | 13 rules | ~5-8 (via decorators) |
| Compile-time enforcement | ✅ Yes | ❌ No |
| Runtime validation overhead | None | Per-request |
| Mutual exclusivity | OneOf, AnyOf, ConditionalRequired, RequiredIfAbsent | @oneOf only (via GraphQL spec) |
| Cross-field validation | ✅ Yes | ❌ Custom code required |
| Learning curve | Low | High |
Apollo is a better choice when:
FraiseQL is a better choice when:
Create a simple Apollo Server:
npm init -ynpm install apollo-server graphqlCreate index.js:
const { ApolloServer, gql } = require('apollo-server');
const typeDefs = gql` type User { id: ID! name: String! posts: [Post!]! } type Post { id: ID! title: String! author: User! } type Query { users: [User!]! }`;
const resolvers = { Query: { users: () => [{ id: "1", name: "Alice", posts: [] }] }, User: { posts: (user) => [{ id: "p1", title: "Hello", author: user }] }, Post: { author: (post) => ({ id: "1", name: "Alice", posts: [] }) }};
const server = new ApolloServer({ typeDefs, resolvers });server.listen().then(({ url }) => { console.log(`Server ready at ${url}`);});Test the Apollo API:
node index.js &
curl -X POST http://localhost:4000 \ -H "Content-Type: application/json" \ -d '{"query": "{ users { name posts { title } } }"}'Works, but requires 30+ lines of resolver code for simple types.
Now try the same with FraiseQL:
# schema.py - just 10 linesimport fraiseql
@fraiseql.typeclass User: id: str name: str posts: list['Post']
@fraiseql.typeclass Post: id: str title: str author: Userfraiseql run
curl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ users { name posts { title } } }"}'Same result, zero resolver code, automatic database integration.
Verify N+1 handling:
In Apollo, you’d need DataLoader to avoid N+1:
// Apollo requires manual DataLoader setupconst DataLoader = require('dataloader');const userLoader = new DataLoader(async (ids) => { // Batch load users});In FraiseQL, N+1 is impossible by design — the SQL view already JOINs the data.
Extract types from your SDL:
type User { id: ID! name: String! posts: [Post!]!}@fraiseql.typeclass User: id: str name: str posts: list['Post']import { type } from 'fraiseql';
@type()class User { id!: string; name!: string; posts!: Post[];}import "github.com/fraiseql/fraiseql-go"
type User struct { ID fraiseql.ID `fraiseql:"id"` Name string `fraiseql:"name"` Posts []Post `fraiseql:"posts"`}Apollo resolver:
Mutation: { createUser: async (_, { input }, context) => { const user = await context.db.insert('users', input); await sendWelcomeEmail(user); return user; }}FraiseQL observer (the framework handles CRUD, you react to events):
@observer( entity="User", event="INSERT", actions=[ email( to="{email}", subject="Welcome to our platform!", body="Hi {name}, thanks for signing up.", ), ],)def on_user_created(): """Send welcome email when user is created.""" passThey’re not needed. Delete them.
Migrating from Apollo? See the step-by-step migration guide.
| Choose | When |
|---|---|
| Apollo | Maximum flexibility, non-database sources, existing expertise |
| FraiseQL | Database-first, zero resolvers, guaranteed performance |
Apollo gives you more control. FraiseQL gives you less code.
Build Your First API
Get a working GraphQL API in minutes. Quick Start Guide
Performance Benchmarks
See how FraiseQL performs compared to Apollo with real numbers. View Benchmarks
Migrate from Apollo
Step-by-step guide to moving your existing Apollo API. Migration Guide
How FraiseQL Works
Understand the compiled, database-first architecture. Core Concepts