# Part 3: Building a GraphQL API with TypeScript and Apollo Server

## From Schema to Running Service

Part 2 designed the schema on paper. Now we implement it.

This part builds a production-style Apollo Server 4 application: Prisma 5 for database access, DataLoader to solve the N+1 query problem, Zod for input validation, and `graphql-ws` for subscriptions. The code is structured for a microservices context — this is one service among several that will federate in Part 6.

## Project Structure

```
graphql-product-service/
├── prisma/
│   └── schema.prisma
├── src/
│   ├── context.ts          # Context type + factory
│   ├── dataloaders.ts      # All DataLoader definitions
│   ├── errors.ts           # Custom error classes
│   ├── resolvers/
│   │   ├── index.ts        # Merge all resolvers
│   │   ├── product.ts      # Product query/mutation resolvers
│   │   ├── category.ts     # Category resolvers
│   │   └── subscription.ts # Subscription resolvers
│   ├── schema.ts           # SDL typeDefs
│   ├── validation.ts       # Zod schemas for inputs
│   └── index.ts            # Server entry point
├── package.json
└── tsconfig.json
```

## Dependencies

```json
{
  "dependencies": {
    "@apollo/server": "^4.11.0",
    "graphql": "^16.9.0",
    "graphql-tag": "^2.12.6",
    "graphql-scalars": "^1.23.0",
    "graphql-ws": "^5.16.0",
    "dataloader": "^2.2.2",
    "ws": "^8.18.0",
    "@prisma/client": "^5.22.0",
    "zod": "^3.23.0",
    "graphql-query-complexity": "^0.12.0"
  },
  "devDependencies": {
    "prisma": "^5.22.0",
    "typescript": "^5.5.0",
    "@types/node": "^20.14.0",
    "@types/ws": "^8.5.12",
    "tsx": "^4.16.0"
  }
}
```

## Prisma Schema

```prisma
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Product {
  id          String    @id @default(uuid())
  name        String
  slug        String    @unique
  description String?
  status      Status    @default(DRAFT)
  price       Float
  currency    String    @default("USD")
  stockCount  Int       @default(0)
  categoryId  String
  tags        String[]
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  category Category         @relation(fields: [categoryId], references: [id])
  variants ProductVariant[]
}

model ProductVariant {
  id         String   @id @default(uuid())
  sku        String   @unique
  name       String
  price      Float?
  stockCount Int      @default(0)
  productId  String
  product    Product  @relation(fields: [productId], references: [id])
  attributes Json     @default("[]")
}

model Category {
  id       String     @id @default(uuid())
  name     String
  slug     String     @unique
  parentId String?
  parent   Category?  @relation("CategoryChildren", fields: [parentId], references: [id])
  children Category[] @relation("CategoryChildren")
  products Product[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

enum Status {
  DRAFT
  PUBLISHED
  ARCHIVED
}
```

## Context

The context object is created per-request. I put database access, DataLoaders, and the authenticated user here.

```typescript
// src/context.ts
import { PrismaClient } from '@prisma/client';
import { createDataLoaders } from './dataloaders';

export interface AuthUser {
  id: string;
  email: string;
  role: 'ADMIN' | 'EDITOR' | 'VIEWER';
}

export interface AppContext {
  prisma: PrismaClient;
  loaders: ReturnType<typeof createDataLoaders>;
  user: AuthUser | null;
}

const prisma = new PrismaClient();

export function createContext({ req }: { req: { headers: Record<string, string | undefined> } }): AppContext {
  // Parse JWT from Authorization header — auth implementation in Part 5
  const user = extractUser(req.headers.authorization);

  return {
    prisma,
    loaders: createDataLoaders(prisma),
    user,
  };
}

function extractUser(authHeader: string | undefined): AuthUser | null {
  // Placeholder — full JWT validation in Part 5
  if (!authHeader) return null;
  return null;
}
```

## DataLoaders: Solving the N+1 Problem

The N+1 query problem is the single most important performance issue in GraphQL. Consider:

```graphql
query {
  products {
    edges {
      node {
        id
        name
        category {   # each product triggers a separate DB query for its category
          name
        }
      }
    }
  }
}
```

Without DataLoader, fetching 20 products fires 1 query for products + 20 queries for their categories = 21 queries. DataLoader batches the category lookups into a single `WHERE id IN (...)` query.

```typescript
// src/dataloaders.ts
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';

export function createDataLoaders(prisma: PrismaClient) {
  return {
    category: new DataLoader(
      async (ids: readonly string[]) => {
        const categories = await prisma.category.findMany({
          where: { id: { in: ids as string[] } },
        });

        // DataLoader expects the same order as the input keys
        const map = new Map(categories.map((c) => [c.id, c]));
        return ids.map((id) => map.get(id) ?? new Error(`Category ${id} not found`));
      }
    ),

    productVariants: new DataLoader(
      async (productIds: readonly string[]) => {
        const variants = await prisma.productVariant.findMany({
          where: { productId: { in: productIds as string[] } },
        });

        const byProductId = new Map<string, typeof variants>();
        for (const variant of variants) {
          if (!byProductId.has(variant.productId)) {
            byProductId.set(variant.productId, []);
          }
          byProductId.get(variant.productId)!.push(variant);
        }

        return productIds.map((id) => byProductId.get(id) ?? []);
      }
    ),
  };
}
```

Key points about DataLoader:

* Each call to `loader.load(key)` returns a Promise
* DataLoader batches all `.load()` calls from the same tick of the event loop into a single batch function call
* The cache means repeated loads with the same key return the same Promise (per-request cache, not cross-request)
* The context factory creates a **new** set of DataLoaders per request so the cache doesn't leak between requests

## Resolvers

### Product Resolvers

```typescript
// src/resolvers/product.ts
import { AppContext } from '../context';
import { validateCreateProduct, validateUpdateProduct } from '../validation';
import { UserInputError } from '../errors';

type ProductResolvers = {
  Query: Record<string, any>;
  Mutation: Record<string, any>;
  Product: Record<string, any>;
};

export const productResolvers: ProductResolvers = {
  Query: {
    product: async (_parent, { id }: { id: string }, ctx: AppContext) => {
      return ctx.prisma.product.findUnique({ where: { id } });
    },

    productBySlug: async (_parent, { slug }: { slug: string }, ctx: AppContext) => {
      return ctx.prisma.product.findUnique({ where: { slug } });
    },

    products: async (
      _parent,
      args: {
        filter?: {
          categoryId?: string;
          status?: string;
          minPrice?: number;
          maxPrice?: number;
          inStock?: boolean;
          tags?: string[];
        };
        sortBy?: string;
        sortOrder?: 'ASC' | 'DESC';
        first?: number;
        after?: string;
      },
      ctx: AppContext,
    ) => {
      const { filter = {}, sortBy = 'createdAt', sortOrder = 'DESC', first = 20, after } = args;

      const where: Record<string, any> = {};
      if (filter.categoryId) where.categoryId = filter.categoryId;
      if (filter.status) where.status = filter.status;
      if (filter.inStock !== undefined) where.stockCount = filter.inStock ? { gt: 0 } : { lte: 0 };
      if (filter.minPrice !== undefined || filter.maxPrice !== undefined) {
        where.price = {};
        if (filter.minPrice !== undefined) where.price.gte = filter.minPrice;
        if (filter.maxPrice !== undefined) where.price.lte = filter.maxPrice;
      }
      if (filter.tags?.length) {
        where.tags = { hasSome: filter.tags };
      }

      // Cursor pagination — cursor encodes the product id
      let cursorWhere = {};
      if (after) {
        const cursorId = Buffer.from(after, 'base64').toString('utf8');
        cursorWhere = { id: { gt: cursorId } };
      }

      const orderField = sortBy === 'PRICE' ? 'price' : sortBy === 'NAME' ? 'name' : 'createdAt';
      const orderDir = sortOrder === 'ASC' ? 'asc' : 'desc';

      // Fetch one extra to determine hasNextPage
      const items = await ctx.prisma.product.findMany({
        where: { ...where, ...cursorWhere },
        orderBy: { [orderField]: orderDir },
        take: first + 1,
      });

      const hasNextPage = items.length > first;
      const edges = items.slice(0, first);

      const totalCount = await ctx.prisma.product.count({ where });

      return {
        edges: edges.map((product) => ({
          cursor: Buffer.from(product.id).toString('base64'),
          node: product,
        })),
        pageInfo: {
          startCursor: edges.length > 0 ? Buffer.from(edges[0].id).toString('base64') : null,
          endCursor: edges.length > 0 ? Buffer.from(edges[edges.length - 1].id).toString('base64') : null,
          hasNextPage,
          hasPreviousPage: Boolean(after),
        },
        totalCount,
      };
    },
  },

  Mutation: {
    createProduct: async (_parent, { input }: { input: Record<string, any> }, ctx: AppContext) => {
      const data = validateCreateProduct(input);

      const existing = await ctx.prisma.product.findUnique({ where: { slug: data.slug } });
      if (existing) {
        throw new UserInputError(`Slug "${data.slug}" is already taken`);
      }

      return ctx.prisma.product.create({
        data: {
          name: data.name,
          slug: data.slug,
          description: data.description ?? null,
          price: data.price,
          currency: data.currency,
          categoryId: data.categoryId,
          tags: data.tags ?? [],
        },
      });
    },

    updateProduct: async (
      _parent,
      { id, input }: { id: string; input: Record<string, any> },
      ctx: AppContext,
    ) => {
      const data = validateUpdateProduct(input);

      const product = await ctx.prisma.product.findUnique({ where: { id } });
      if (!product) {
        throw new UserInputError(`Product ${id} not found`);
      }

      return ctx.prisma.product.update({
        where: { id },
        // Only pass fields that were actually provided
        data: Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined)),
      });
    },

    deleteProduct: async (_parent, { id }: { id: string }, ctx: AppContext) => {
      await ctx.prisma.product.delete({ where: { id } });
      return true;
    },

    updateStock: async (
      _parent,
      { productId, quantity }: { productId: string; quantity: number },
      ctx: AppContext,
    ) => {
      return ctx.prisma.product.update({
        where: { id: productId },
        data: { stockCount: quantity },
      });
    },
  },

  // Field resolvers — these run when the parent Product object is resolved
  // and use DataLoader to batch DB calls
  Product: {
    inStock: (product: { stockCount: number }) => product.stockCount > 0,

    category: (product: { categoryId: string }, _args: unknown, ctx: AppContext) => {
      // DataLoader batches all category lookups from this request
      return ctx.loaders.category.load(product.categoryId);
    },

    variants: (product: { id: string }, _args: unknown, ctx: AppContext) => {
      return ctx.loaders.productVariants.load(product.id);
    },
  },
};
```

### Category Resolvers

```typescript
// src/resolvers/category.ts
import { AppContext } from '../context';

export const categoryResolvers = {
  Query: {
    categories: async (_parent: unknown, _args: unknown, ctx: AppContext) => {
      return ctx.prisma.category.findMany({ where: { parentId: null } });
    },
  },

  Category: {
    parent: (category: { parentId: string | null }, _args: unknown, ctx: AppContext) => {
      if (!category.parentId) return null;
      return ctx.loaders.category.load(category.parentId);
    },

    children: (category: { id: string }, _args: unknown, ctx: AppContext) => {
      return ctx.prisma.category.findMany({ where: { parentId: category.id } });
    },

    products: async (
      category: { id: string },
      args: { first?: number; after?: string },
      ctx: AppContext,
    ) => {
      const { first = 20, after } = args;

      const cursorWhere = after
        ? { id: { gt: Buffer.from(after, 'base64').toString('utf8') } }
        : {};

      const items = await ctx.prisma.product.findMany({
        where: { categoryId: category.id, ...cursorWhere },
        take: first + 1,
        orderBy: { createdAt: 'desc' },
      });

      const hasNextPage = items.length > first;
      const edges = items.slice(0, first);
      const totalCount = await ctx.prisma.product.count({ where: { categoryId: category.id } });

      return {
        edges: edges.map((product) => ({
          cursor: Buffer.from(product.id).toString('base64'),
          node: product,
        })),
        pageInfo: {
          startCursor: edges.length > 0 ? Buffer.from(edges[0].id).toString('base64') : null,
          endCursor: edges.length > 0 ? Buffer.from(edges[edges.length - 1].id).toString('base64') : null,
          hasNextPage,
          hasPreviousPage: Boolean(after),
        },
        totalCount,
      };
    },
  },
};
```

### Subscriptions

Subscriptions use a pub/sub mechanism. Apollo Server v4 does not ship a built-in PubSub for production — the in-memory `PubSub` from `graphql-subscriptions` is only for development. For production I use Redis pub/sub via `graphql-redis-subscriptions`.

```typescript
// src/pubsub.ts
import { PubSub } from 'graphql-subscriptions';

// Development only — replace with graphql-redis-subscriptions in production
export const pubsub = new PubSub();

export const EVENTS = {
  PRODUCT_STATUS_CHANGED: 'PRODUCT_STATUS_CHANGED',
  STOCK_UPDATED: 'STOCK_UPDATED',
} as const;
```

```typescript
// src/resolvers/subscription.ts
import { pubsub, EVENTS } from '../pubsub';

export const subscriptionResolvers = {
  Subscription: {
    productStatusChanged: {
      subscribe: (_parent: unknown, { id }: { id: string }) => {
        return pubsub.asyncIterator(`${EVENTS.PRODUCT_STATUS_CHANGED}:${id}`);
      },
      resolve: (payload: { product: unknown }) => payload.product,
    },

    stockAlert: {
      subscribe: (_parent: unknown, { threshold }: { threshold: number }) => {
        return pubsub.asyncIterator(EVENTS.STOCK_UPDATED);
      },
      resolve: (payload: { product: { stockCount: number } }, { threshold }: { threshold: number }) => {
        if (payload.product.stockCount <= threshold) {
          return payload.product;
        }
        return null;
      },
    },
  },
};
```

Publish an event from a mutation:

```typescript
// In the updateStock mutation
updateStock: async (_parent, { productId, quantity }, ctx) => {
  const product = await ctx.prisma.product.update({
    where: { id: productId },
    data: { stockCount: quantity },
  });

  await pubsub.publish(EVENTS.STOCK_UPDATED, { product });
  return product;
},
```

### Merging Resolvers

```typescript
// src/resolvers/index.ts
import { mergeResolvers } from '@graphql-tools/merge';
import { productResolvers } from './product';
import { categoryResolvers } from './category';
import { subscriptionResolvers } from './subscription';

export const resolvers = mergeResolvers([
  productResolvers,
  categoryResolvers,
  subscriptionResolvers,
]);
```

## Zod Input Validation

```typescript
// src/validation.ts
import { z } from 'zod';

const CurrencyEnum = z.enum(['USD', 'EUR', 'GBP', 'THB']);
const StatusEnum = z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']);

const CreateProductSchema = z.object({
  name: z.string().min(1).max(200),
  slug: z
    .string()
    .min(1)
    .max(200)
    .regex(/^[a-z0-9-]+$/, 'Slug must be lowercase letters, numbers, and hyphens only'),
  description: z.string().max(5000).optional(),
  price: z.number().positive(),
  currency: CurrencyEnum,
  categoryId: z.string().uuid(),
  tags: z.array(z.string().min(1).max(50)).max(20).optional(),
});

const UpdateProductSchema = z.object({
  name: z.string().min(1).max(200).optional(),
  description: z.string().max(5000).optional().nullable(),
  price: z.number().positive().optional(),
  currency: CurrencyEnum.optional(),
  categoryId: z.string().uuid().optional(),
  tags: z.array(z.string().min(1).max(50)).max(20).optional(),
  status: StatusEnum.optional(),
});

export function validateCreateProduct(input: unknown) {
  return CreateProductSchema.parse(input);
}

export function validateUpdateProduct(input: unknown) {
  return UpdateProductSchema.parse(input);
}
```

When Zod throws a `ZodError`, map it to `UserInputError` in an error-handling wrapper or in the resolver itself.

## Custom Errors

```typescript
// src/errors.ts
import { GraphQLError } from 'graphql';

export class UserInputError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: 'BAD_USER_INPUT' },
    });
  }
}

export class NotFoundError extends GraphQLError {
  constructor(type: string, id: string) {
    super(`${type} with id "${id}" not found`, {
      extensions: { code: 'NOT_FOUND', type, id },
    });
  }
}

export class ForbiddenError extends GraphQLError {
  constructor(message = 'Permission denied') {
    super(message, {
      extensions: { code: 'FORBIDDEN' },
    });
  }
}
```

## Server Entry Point

```typescript
// src/index.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import express from 'express';
import http from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';

async function startServer() {
  const app = express();
  const httpServer = http.createServer(app);

  const schema = makeExecutableSchema({ typeDefs, resolvers });

  // WebSocket server for subscriptions
  const wsServer = new WebSocketServer({
    server: httpServer,
    path: '/graphql',
  });

  const serverCleanup = useServer(
    {
      schema,
      context: (ctx) => {
        // Subscription context — parse token from connection params
        const token = ctx.connectionParams?.authToken as string | undefined;
        return { token };
      },
    },
    wsServer,
  );

  const server = new ApolloServer({
    schema,
    plugins: [
      ApolloServerPluginDrainHttpServer({ httpServer }),
      {
        async serverWillStart() {
          return {
            async drainServer() {
              await serverCleanup.dispose();
            },
          };
        },
      },
    ],
  });

  await server.start();

  app.use(
    '/graphql',
    express.json(),
    expressMiddleware(server, {
      context: async ({ req }) => createContext({ req }),
    }),
  );

  const PORT = Number(process.env.PORT ?? 4001);
  httpServer.listen(PORT, () => {
    console.log(`Product service running at http://localhost:${PORT}/graphql`);
    console.log(`Subscriptions available at ws://localhost:${PORT}/graphql`);
  });
}

startServer().catch(console.error);
```

## What's Next

You now have a running Apollo Server with:

* Prisma 5 database access
* DataLoaders eliminating N+1 query problems
* Cursor-based pagination
* Zod input validation
* Subscriptions via `graphql-ws`
* Custom error classes with error codes

In [Part 4](https://blog.htunnthuthu.com/getting-started/programming/graphql-101/part-4-graphql-client-typescript-python), we consume this API — building a TypeScript client with Apollo Client and generated types, and a Python microservice that calls this GraphQL API using the `gql` client library.
