GraphQL API Design: Schema-First Development
Learn GraphQL schema design principles, resolver patterns, and best practices for building scalable GraphQL APIs with proper type safety.
Table of Contents
Alex RiveraGraphQL Architect
Sep 1, 2025•5 min read
GraphQL API Design: Schema-First Development
GraphQL provides a powerful alternative to REST APIs with its flexible query language and type system. Schema-first development ensures type safety and better API design from the ground up.
Schema-First Approach
Defining Your Schema
Start with the GraphQL schema definition:
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
profile: Profile
}
type Post {
id: ID!
title: String!
content: String!
author: User!
publishedAt: DateTime!
tags: [String!]!
}
type Profile {
bio: String
avatar: String
website: String
}
type Query {
users(limit: Int, offset: Int): [User!]!
user(id: ID!): User
posts(category: String, authorId: ID): [Post!]!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
createPost(input: CreatePostInput!): Post!
publishPost(id: ID!): Post!
}
input CreateUserInput {
email: String!
name: String!
password: String!
}
input UpdateUserInput {
name: String
bio: String
}
input CreatePostInput {
title: String!
content: String!
tags: [String!]
}Schema Design Principles
1. Use Descriptive Names
# Good
type User {
id: ID!
email: String!
fullName: String!
dateOfBirth: Date
}
# Avoid
type User {
id: ID!
mail: String!
name: String!
dob: Date
}2. Proper Nullability
type User {
id: ID!
name: String!
email: String!
# Optional fields
bio: String
avatarUrl: String
# Always returns a value, even if empty
posts: [Post!]!
}3. Consistent Naming Conventions
- Types: PascalCase
- Fields: camelCase
- Enums: SCREAMING_SNAKE_CASE
Resolver Implementation
Basic Resolvers
import { Resolvers } from "./generated/graphql";
const resolvers: Resolvers = {
Query: {
users: async (_, { limit = 10, offset = 0 }) => {
return await userService.findAll({ limit, offset });
},
user: async (_, { id }) => {
return await userService.findById(id);
},
posts: async (_, { category, authorId }) => {
const filter = {};
if (category) filter.category = category;
if (authorId) filter.authorId = authorId;
return await postService.findAll(filter);
},
},
Mutation: {
createUser: async (_, { input }) => {
return await userService.create(input);
},
createPost: async (_, { input }, { user }) => {
if (!user) throw new AuthenticationError("Not authenticated");
return await postService.create({ ...input, authorId: user.id });
},
},
};Resolver Relationships
const resolvers: Resolvers = {
User: {
posts: async (user) => {
return await postService.findByAuthorId(user.id);
},
profile: async (user) => {
return await profileService.findByUserId(user.id);
},
},
Post: {
author: async (post) => {
return await userService.findById(post.authorId);
},
},
};DataLoader for N+1 Problem
Implementing DataLoader
import DataLoader from "dataloader";
class UserService {
private userLoader = new DataLoader(async (ids: string[]) => {
const users = await this.userRepository.findByIds(ids);
const userMap = new Map(users.map((user) => [user.id, user]));
return ids.map((id) => userMap.get(id) || null);
});
async findById(id: string): Promise<User | null> {
return this.userLoader.load(id);
}
async findByIds(ids: string[]): Promise<User[]> {
return this.userLoader.loadMany(ids);
}
}Batch and Cache
const postLoader = new DataLoader(async (authorIds: string[]) => {
const posts = await db.posts.find({ authorId: { $in: authorIds } });
const postsByAuthor = authorIds.map((authorId) =>
posts.filter((post) => post.authorId === authorId)
);
return postsByAuthor;
}, {
cacheKeyFn: (key) => key.toString(),
maxBatchSize: 100,
});Error Handling
Custom Error Types
import { GraphQLError } from "graphql";
export class AuthenticationError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: {
code: "UNAUTHENTICATED",
http: { status: 401 },
},
});
}
}
export class ForbiddenError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: {
code: "FORBIDDEN",
http: { status: 403 },
},
});
}
}
export class ValidationError extends GraphQLError {
constructor(message: string, field: string) {
super(message, {
extensions: {
code: "VALIDATION_ERROR",
field,
http: { status: 400 },
},
});
}
}Error Formatting
const formatError = (error: GraphQLError) => {
const { originalError } = error;
if (originalError instanceof ValidationError) {
return {
message: originalError.message,
extensions: {
code: originalError.extensions.code,
field: originalError.extensions.field,
},
};
}
// Log internal errors but don't expose to client
console.error(error);
return {
message: "Internal server error",
extensions: {
code: "INTERNAL_ERROR",
},
};
};Authentication and Authorization
Context with Authentication
interface Context {
user?: User;
dataLoaders: {
userLoader: DataLoader<string, User>;
postLoader: DataLoader<string, Post[]>;
};
}
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }): Promise<Context> => {
const token = req.headers.authorization?.replace("Bearer ", "");
let user: User | undefined;
if (token) {
try {
const payload = verifyToken(token);
user = await userService.findById(payload.userId);
} catch (error) {
// Invalid token
}
}
return {
user,
dataLoaders: {
userLoader: new DataLoader(/* ... */),
postLoader: new DataLoader(), /* ... */
},
};
},
});Field-Level Authorization
const resolvers: Resolvers = {
Query: {
users: combineResolvers(
isAuthenticated,
hasRole("ADMIN"),
async () => await userService.findAll(),
),
myPosts: combineResolvers(
isAuthenticated,
async (_, __, { user }) => await postService.findByAuthorId(user!.id),
),
},
Mutation: {
updateUser: combineResolvers(
isAuthenticated,
ownsResource("user", "id"),
async (_, { id, input }) => await userService.update(id, input),
),
},
};Schema Stitching and Federation
Schema Stitching
Combine multiple schemas:
import { makeExecutableSchema } from "@graphql-tools/schema";
import { stitchSchemas } from "@graphql-tools/stitch";
const userSchema = makeExecutableSchema({
typeDefs: userTypeDefs,
resolvers: userResolvers,
});
const postSchema = makeExecutableSchema({
typeDefs: postTypeDefs,
resolvers: postResolvers,
});
const gatewaySchema = stitchSchemas({
subschemas: [userSchema, postSchema],
});Apollo Federation
# Users service schema
type User @key(fields: "id") {
id: ID!
email: String! @external
posts: [Post!]!
}
# Posts service schema
type Post @key(fields: "id") {
id: ID!
title: String!
author: User! @provides(fields: "email")
}
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]! @requires(fields: "id")
}Performance Optimization
Query Complexity Analysis
import {
fieldExtensionsEstimator,
getComplexity,
simpleEstimator,
} from "graphql-query-complexity";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityRule({
maximumComplexity: 1000,
variables: {},
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
onComplete: (complexity: number) => {
console.log("Query complexity:", complexity);
},
}),
],
});Caching Strategies
import { ApolloServerPluginCacheControl } from "apollo-server-core";
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginCacheControl({
defaultMaxAge: 300, // 5 minutes
calculateHttpHeaders: true,
}),
],
});
// Field-level cache hints
const resolvers = {
Query: {
users: async (_, __, ___, info) => {
info.cacheControl.setCacheHint({ maxAge: 60 });
return await userService.findAll();
},
},
};Testing GraphQL APIs
Unit Testing Resolvers
import { makeExecutableSchema } from "@graphql-tools/schema";
import { graphql } from "graphql";
describe("User Resolvers", () => {
const schema = makeExecutableSchema({ typeDefs, resolvers });
it("should return users", async () => {
const query = `
query {
users {
id
name
email
}
}
`;
const result = await graphql(schema, query);
expect(result.errors).toBeUndefined();
expect(result.data?.users).toBeDefined();
});
});Integration Testing
import { createTestClient } from "apollo-server-testing";
const { query, mutate } = createTestClient(server);
describe("User API", () => {
it("should create a user", async () => {
const CREATE_USER = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`;
const result = await mutate({
mutation: CREATE_USER,
variables: {
input: {
name: "John Doe",
email: "john@example.com",
},
},
});
expect(result.errors).toBeUndefined();
expect(result.data?.createUser).toMatchObject({
name: "John Doe",
email: "john@example.com",
});
});
});Conclusion
Schema-first GraphQL development provides a solid foundation for building scalable, type-safe APIs. By following proper design principles, implementing efficient resolvers, and adding appropriate error handling and authentication, you can create GraphQL APIs that are both powerful and maintainable.