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.