Apollo Server Resolver Yazımı: Tip Güvenli Yaklaşım

GraphQL API geliştirmeye başladığınızda en büyük sorunlardan biri tip güvenliğini sağlamak oluyor. Özellikle büyük projelerde resolver’lar arasında geçen veriler tip kontrolü olmadan kaos haline gelebiliyor. Apollo Server ile çalışırken TypeScript’i doğru kullanmak, gece yarısı production hatalarını önemli ölçüde azaltıyor. Bu yazıda Apollo Server resolver’larını tip güvenli bir şekilde nasıl yazacağınızı gerçek dünya senaryolarıyla ele alacağım.

Neden Tip Güvenli Resolver Yazmalıyız?

Bir e-ticaret projesinde çalıştığınızı düşünün. User tipinden Order tipine geçerken, Order içinde Product listesi dönerken tip hatalarını runtime’da yakalamak yerine derleme aşamasında yakalamak istiyorsunuz. JavaScript ile yazılmış resolver’larda undefined is not a function hatası production’da patlıyor ve müşteri siparişlerini göremez hale geliyor. TypeScript ile bu hataların büyük çoğunluğunu daha kodu çalıştırmadan görürsünüz.

Tip güvenli resolver yazımının faydaları şunlardır:

  • Erken hata tespiti: Derleme aşamasında tip uyumsuzlukları yakalanır
  • Otomatik tamamlama: IDE desteği ile resolver içinde hangi alanların mevcut olduğunu görürsünüz
  • Refactoring güvenliği: Şema değişikliklerinde etkilenen tüm resolver’lar hemen belli olur
  • Dokümantasyon: Tipler, kodun kendisi kadar açıklayıcı belgeler sunar
  • Takım içi iletişim: Yeni ekip üyeleri veri yapısını hızla kavrar

Temel Kurulum ve Yapı

Önce projeyi kuralım. Apollo Server 4 ve TypeScript kullanacağız:

mkdir graphql-tipli-api && cd graphql-tipli-api
npm init -y
npm install @apollo/server graphql
npm install -D typescript @types/node ts-node nodemon
npm install graphql-codegen @graphql-codegen/cli
npx tsc --init

tsconfig.json dosyanızı şöyle ayarlayın:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Şimdi temel şema tanımlamasını yapalım. GraphQL şeması ile TypeScript tipleri arasındaki köprüyü kurmak için önce şemayı yazıyoruz:

# src/schema/typeDefs.ts
import { gql } from 'graphql-tag';

export const typeDefs = gql`
  type User {
    id: ID!
    email: String!
    username: String!
    createdAt: String!
    orders: [Order!]!
  }

  type Product {
    id: ID!
    name: String!
    price: Float!
    stock: Int!
    category: String!
  }

  type OrderItem {
    product: Product!
    quantity: Int!
    unitPrice: Float!
  }

  type Order {
    id: ID!
    user: User!
    items: [OrderItem!]!
    totalAmount: Float!
    status: OrderStatus!
    createdAt: String!
  }

  enum OrderStatus {
    PENDING
    PROCESSING
    SHIPPED
    DELIVERED
    CANCELLED
  }

  type Query {
    user(id: ID!): User
    users: [User!]!
    product(id: ID!): Product
    products(category: String): [Product!]!
    order(id: ID!): Order
  }

  type Mutation {
    createUser(input: CreateUserInput!): User!
    createOrder(input: CreateOrderInput!): Order!
    updateOrderStatus(orderId: ID!, status: OrderStatus!): Order!
  }

  input CreateUserInput {
    email: String!
    username: String!
    password: String!
  }

  input CreateOrderInput {
    userId: ID!
    items: [OrderItemInput!]!
  }

  input OrderItemInput {
    productId: ID!
    quantity: Int!
  }
`;

TypeScript Tiplerini Manuel Tanımlama

GraphQL Codegen kullanmadan önce manuel tip tanımlamasını anlayalım. Bu sayede otomatik araçların arka planda ne yaptığını kavramış olursunuz:

# src/types/index.ts

export enum OrderStatus {
  PENDING = 'PENDING',
  PROCESSING = 'PROCESSING',
  SHIPPED = 'SHIPPED',
  DELIVERED = 'DELIVERED',
  CANCELLED = 'CANCELLED'
}

export interface User {
  id: string;
  email: string;
  username: string;
  passwordHash: string; // GraphQL şemasında yok ama DB'de var
  createdAt: string;
}

export interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
  category: string;
}

export interface OrderItem {
  productId: string;
  quantity: number;
  unitPrice: number;
}

export interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  totalAmount: number;
  status: OrderStatus;
  createdAt: string;
}

// Resolver context tipi
export interface GraphQLContext {
  userId?: string;
  isAuthenticated: boolean;
  dataSources: {
    userService: UserService;
    productService: ProductService;
    orderService: OrderService;
  };
}

// Input tipleri
export interface CreateUserInput {
  email: string;
  username: string;
  password: string;
}

export interface CreateOrderInput {
  userId: string;
  items: Array<{
    productId: string;
    quantity: number;
  }>;
}

Resolver Tiplerini Tanımlama

Apollo Server’ın sağladığı Resolvers tipini kullanarak resolver’larınızı tip güvenli yapabilirsiniz:

# src/resolvers/types.ts
import { GraphQLResolveInfo } from 'graphql';
import { GraphQLContext, User, Product, Order, OrderItem } from '../types';

// Parent tiplerini eşleştiriyoruz
export type UserParent = User;
export type ProductParent = Product;
export type OrderParent = Order;
export type OrderItemParent = OrderItem & { productId: string };

// Resolver fonksiyon tipi
export type ResolverFn<TResult, TParent, TContext, TArgs> = (
  parent: TParent,
  args: TArgs,
  context: TContext,
  info: GraphQLResolveInfo
) => Promise<TResult> | TResult;

// Query resolver tipleri
export interface QueryResolvers {
  user: ResolverFn<
    User | null,
    {},
    GraphQLContext,
    { id: string }
  >;
  users: ResolverFn<User[], {}, GraphQLContext, {}>;
  product: ResolverFn<
    Product | null,
    {},
    GraphQLContext,
    { id: string }
  >;
  products: ResolverFn<
    Product[],
    {},
    GraphQLContext,
    { category?: string }
  >;
  order: ResolverFn<
    Order | null,
    {},
    GraphQLContext,
    { id: string }
  >;
}

Gerçek Resolver Implementasyonu

Şimdi gerçek e-ticaret senaryosunda resolver’ları yazalım. Her resolver’ın tip güvenli olduğuna dikkat edin:

# src/resolvers/queries.ts
import { GraphQLResolveInfo } from 'graphql';
import { GraphQLContext, User, Product, Order } from '../types';

export const queryResolvers = {
  Query: {
    user: async (
      _parent: unknown,
      args: { id: string },
      context: GraphQLContext,
      _info: GraphQLResolveInfo
    ): Promise<User | null> => {
      if (!context.isAuthenticated) {
        throw new Error('Kimlik doğrulama gerekli');
      }
      return context.dataSources.userService.findById(args.id);
    },

    products: async (
      _parent: unknown,
      args: { category?: string },
      context: GraphQLContext
    ): Promise<Product[]> => {
      if (args.category) {
        return context.dataSources.productService.findByCategory(
          args.category
        );
      }
      return context.dataSources.productService.findAll();
    },

    order: async (
      _parent: unknown,
      args: { id: string },
      context: GraphQLContext
    ): Promise<Order | null> => {
      if (!context.isAuthenticated || !context.userId) {
        throw new Error('Kimlik doğrulama gerekli');
      }

      const order = await context.dataSources.orderService.findById(
        args.id
      );

      // Kullanıcı kendi siparişini görebilir
      if (order && order.userId !== context.userId) {
        throw new Error('Bu siparişe erişim yetkiniz yok');
      }

      return order;
    }
  }
};

İlişkisel Resolver’lar ve N+1 Problemi

GraphQL’in en yaygın performans sorunlarından biri N+1 problemidir. Tip güvenli resolver yazarken bu sorunu da ele almak gerekir:

# src/resolvers/typeResolvers.ts
import { GraphQLContext, User, Order, OrderItem, Product } from '../types';
import DataLoader from 'dataloader';

// DataLoader ile N+1 çözümü
export const createProductLoader = (context: GraphQLContext) => {
  return new DataLoader<string, Product | null>(
    async (productIds: readonly string[]) => {
      const products = await context.dataSources.productService
        .findByIds([...productIds]);

      const productMap = new Map(
        products.map((p: Product) => [p.id, p])
      );

      return productIds.map(id => productMap.get(id) || null);
    }
  );
};

export const typeResolvers = {
  User: {
    orders: async (
      parent: User,
      _args: {},
      context: GraphQLContext
    ): Promise<Order[]> => {
      return context.dataSources.orderService.findByUserId(parent.id);
    }
  },

  Order: {
    user: async (
      parent: Order,
      _args: {},
      context: GraphQLContext
    ): Promise<User> => {
      const user = await context.dataSources.userService.findById(
        parent.userId
      );

      if (!user) {
        throw new Error(`Kullanıcı bulunamadı: ${parent.userId}`);
      }

      return user;
    },

    items: async (
      parent: Order,
      _args: {},
      _context: GraphQLContext
    ): Promise<OrderItem[]> => {
      // Order zaten items içeriyor, dönüştürme işlemi yapıyoruz
      return parent.items;
    }
  },

  OrderItem: {
    product: async (
      parent: OrderItem & { productId: string },
      _args: {},
      context: GraphQLContext
    ): Promise<Product> => {
      // DataLoader kullanarak N+1 önleme
      const product = await context.productLoader.load(parent.productId);

      if (!product) {
        throw new Error(`Ürün bulunamadı: ${parent.productId}`);
      }

      return product;
    }
  }
};

Mutation Resolver’ları ve Validasyon

Mutation resolver’larında tip güvenliği özellikle önemlidir. Gelen input verilerini validate ederken TypeScript’ten maksimum fayda sağlayalım:

# src/resolvers/mutations.ts
import { GraphQLContext, CreateUserInput, CreateOrderInput, Order } from '../types';
import { validateEmail, hashPassword, calculateTotal } from '../utils';

export const mutationResolvers = {
  Mutation: {
    createUser: async (
      _parent: unknown,
      args: { input: CreateUserInput },
      context: GraphQLContext
    ) => {
      const { email, username, password } = args.input;

      // Tip güvenli validasyon
      if (!validateEmail(email)) {
        throw new Error('Geçersiz email formatı');
      }

      if (password.length < 8) {
        throw new Error('Şifre en az 8 karakter olmalıdır');
      }

      const existingUser = await context.dataSources.userService
        .findByEmail(email);

      if (existingUser) {
        throw new Error('Bu email adresi zaten kayıtlı');
      }

      const passwordHash = await hashPassword(password);

      return context.dataSources.userService.create({
        email,
        username,
        passwordHash,
        createdAt: new Date().toISOString()
      });
    },

    createOrder: async (
      _parent: unknown,
      args: { input: CreateOrderInput },
      context: GraphQLContext
    ): Promise<Order> => {
      if (!context.isAuthenticated) {
        throw new Error('Kimlik doğrulama gerekli');
      }

      const { userId, items } = args.input;

      // Stok kontrolü - tip güvenli
      const productChecks = await Promise.all(
        items.map(async (item) => {
          const product = await context.dataSources.productService
            .findById(item.productId);

          if (!product) {
            throw new Error(`Ürün bulunamadı: ${item.productId}`);
          }

          if (product.stock < item.quantity) {
            throw new Error(
              `Yetersiz stok: ${product.name} (mevcut: ${product.stock})`
            );
          }

          return { product, quantity: item.quantity };
        })
      );

      const orderItems = productChecks.map(({ product, quantity }) => ({
        productId: product.id,
        quantity,
        unitPrice: product.price
      }));

      const totalAmount = calculateTotal(orderItems);

      const order = await context.dataSources.orderService.create({
        userId,
        items: orderItems,
        totalAmount,
        status: 'PENDING' as const,
        createdAt: new Date().toISOString()
      });

      // Stok güncelleme
      await Promise.all(
        productChecks.map(({ product, quantity }) =>
          context.dataSources.productService.decreaseStock(
            product.id,
            quantity
          )
        )
      );

      return order;
    },

    updateOrderStatus: async (
      _parent: unknown,
      args: { orderId: string; status: string },
      context: GraphQLContext
    ): Promise<Order> => {
      if (!context.isAuthenticated) {
        throw new Error('Kimlik doğrulama gerekli');
      }

      const order = await context.dataSources.orderService
        .findById(args.orderId);

      if (!order) {
        throw new Error(`Sipariş bulunamadı: ${args.orderId}`);
      }

      return context.dataSources.orderService.updateStatus(
        args.orderId,
        args.status
      );
    }
  }
};

GraphQL Codegen ile Otomatik Tip Üretimi

Büyük projelerde tipleri manuel yazmak yerine GraphQL Codegen kullanmak çok daha verimlidir:

# codegen.yml
overwrite: true
schema: "src/schema/typeDefs.ts"
generates:
  src/generated/graphql.ts:
    plugins:
      - "typescript"
      - "typescript-resolvers"
    config:
      contextType: "../types#GraphQLContext"
      mappers:
        User: "../types#User"
        Product: "../types#Product"
        Order: "../types#Order"
        OrderItem: "../types#OrderItem"
      useIndexSignature: true
      enumsAsTypes: true
      avoidOptionals: true

Codegen’i çalıştırmak için:

# package.json scripts bölümüne ekleyin
npx graphql-codegen --config codegen.yml

# Geliştirme sırasında izleme modunda çalıştırma
npx graphql-codegen --config codegen.yml --watch

Codegen çalıştıktan sonra üretilen tipler şu şekilde kullanılır:

# src/resolvers/index.ts - Codegen tipleriyle
import { Resolvers } from '../generated/graphql';
import { GraphQLContext } from '../types';

export const resolvers: Resolvers<GraphQLContext> = {
  Query: {
    user: async (_parent, args, context) => {
      // args.id otomatik olarak string tipinde
      // dönüş tipi User | null olarak zorunlu
      return context.dataSources.userService.findById(args.id);
    },

    products: async (_parent, args, context) => {
      // args.category otomatik olarak string | null | undefined
      return args.category
        ? context.dataSources.productService.findByCategory(args.category)
        : context.dataSources.productService.findAll();
    }
  },

  Mutation: {
    createUser: async (_parent, args, context) => {
      // args.input.email, args.input.username, args.input.password
      // hepsi otomatik tip güvenliği ile geliyor
      const { email, username, password } = args.input;
      const passwordHash = await hashPassword(password);
      return context.dataSources.userService.create({
        email,
        username,
        passwordHash,
        createdAt: new Date().toISOString()
      });
    }
  }
};

Hata Yönetimi ve Özel Error Tipleri

Tip güvenli hata yönetimi de resolver kalitesini artırır:

# src/errors/index.ts
import { GraphQLError } from 'graphql';

export class AuthenticationError extends GraphQLError {
  constructor(message: string = 'Kimlik doğrulama başarısız') {
    super(message, {
      extensions: {
        code: 'UNAUTHENTICATED',
        http: { status: 401 }
      }
    });
  }
}

export class AuthorizationError extends GraphQLError {
  constructor(message: string = 'Bu işlem için yetkiniz yok') {
    super(message, {
      extensions: {
        code: 'FORBIDDEN',
        http: { status: 403 }
      }
    });
  }
}

export class NotFoundError extends GraphQLError {
  constructor(resource: string, id: string) {
    super(`${resource} bulunamadı: ${id}`, {
      extensions: {
        code: 'NOT_FOUND',
        resource,
        id,
        http: { status: 404 }
      }
    });
  }
}

export class ValidationError extends GraphQLError {
  constructor(field: string, message: string) {
    super(`Validasyon hatası - ${field}: ${message}`, {
      extensions: {
        code: 'BAD_USER_INPUT',
        field,
        http: { status: 400 }
      }
    });
  }
}

Bu özel error tiplerini resolver’larınızda kullanmak hem tip güvenliği hem de istemci tarafında anlamlı hata mesajları sağlar.

Yaygın Hatalar ve Çözümleri

Tip güvenli resolver yazarken sık karşılaşılan sorunlar şunlardır:

  • Circular dependency: User Order’a, Order User’a referans verdiğinde sonsuz döngü oluşabilir. Bunu lazy loading veya ayrı tip dosyaları ile çözün
  • any kullanımı: Tip güvenliğini bozan any yerine unknown kullanın ve tip guard’larla daraltın
  • Parent tipini yanlış tanımlamak: Field resolver’larında parent tipi yanlış belirtmek runtime hatalarına yol açar
  • Async/await tutarsızlığı: Bazı resolver’lar Promise dönerken bazıları senkron değer döndürüyorsa tip uyumsuzluğu çıkabilir
  • Codegen’i güncel tutmamak: Şema değiştiğinde codegen’i çalıştırmayı unutmak tiplerin eski kalmasına neden olur
  • Context tipini gevşek tanımlamak: Context içindeki servisleri tip güvenli tanımlamak resolver kalitesini doğrudan etkiler
  • Input validasyonunu atlamak: Tip güvenliği runtime validasyonunun yerini tutmaz, ikisi birlikte çalışmalıdır

Sonuç

Apollo Server ile tip güvenli resolver yazmak başlangıçta fazladan çaba gibi görünse de orta ve uzun vadede production kararlılığını ciddi ölçüde artırıyor. Manuel tip tanımlamayı anlayarak başlayın, sonra GraphQL Codegen ile otomatikleştirin. Özellikle ekip ortamında çalışırken tipler, kodu belgeleyen ve hataları önceden yakalayan en iyi araç haline geliyor.

Gerçek projelerden edindiğim en önemli ders şu: Şema değişikliği yaptığınızda Codegen otomatik çalışacak şekilde CI/CD pipeline’ınıza entegre edin. Böylece tip uyumsuzlukları merge request aşamasında yakalanır, production’a ulaşmaz. DataLoader ile N+1 sorununu çözmek, özel error tipleri kullanmak ve context’i sıkı tiplerle tanımlamak, sürdürülebilir bir GraphQL API’nin temel taşlarıdır.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir