GraphQL Resolver Yazımı: Veriyi Kaynaktan Çekme

GraphQL ile çalışmaya başladığınızda şema tasarımı güzel görünür, type’lar yerli yerinde durur, query’ler mantıklıdır. Ama asıl iş resolver’ları yazmaya geldiğinde başlar. Çünkü resolver, GraphQL’in “bu veriyi nereden alacağım?” sorusuna verdiği cevaptır. Veritabanı mı, REST API mi, cache mi, yoksa başka bir servis mi? Hepsini resolver içinde halledersiniz. Bu yazıda resolver yazımını sıfırdan ele alacağız, gerçek dünya senaryolarıyla pekiştireceğiz.

Resolver Nedir, Ne İşe Yarar?

GraphQL şemanızda bir field tanımladığınızda, o field’ın değerini kim hesaplar? İşte resolver. Her field için bir resolver fonksiyonu yazabilirsiniz. Eğer yazmazsanız GraphQL default resolver’ı kullanır, yani parent objedeki aynı isimli property’yi döner.

Resolver fonksiyonları dört parametre alır:

  • root (parent): Üst resolver’dan gelen obje. Root query’lerde genellikle null ya da boş objedir.
  • args: Client’ın query’de gönderdiği argümanlar. id, filter, pagination gibi şeyler buradan gelir.
  • context: Tüm resolver’lar arasında paylaşılan obje. Veritabanı bağlantısı, authentication bilgisi, dataloader instance’ları buraya koyulur.
  • info: Query hakkında meta bilgi. Hangi field’ların istendiği, query AST’i gibi şeyler. Çoğu zaman kullanmazsınız ama bazen işe yarar.
// Temel resolver imzası
const resolver = (parent, args, context, info) => {
  // veriyi döndür
};

Bu dört parametreyi aklınızda tutun. Resolver yazarken sürekli bunlara başvuracaksınız.

İlk Resolver’ı Yazmak

Basit bir kullanıcı yönetim sistemi düşünelim. Şemamız şöyle olsun:

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
}

type Query {
  user(id: ID!): User
  users: [User!]!
  post(id: ID!): Post
}

Bu şema için resolver map’i oluşturalım. Node.js ve Apollo Server kullandığımızı varsayalım, ama mantık her platformda aynıdır.

const resolvers = {
  Query: {
    // Tekil kullanıcı getir
    user: async (parent, args, context) => {
      const { id } = args;
      const user = await context.db.users.findById(id);
      if (!user) {
        throw new Error(`Kullanıcı bulunamadı: ${id}`);
      }
      return user;
    },

    // Tüm kullanıcıları listele
    users: async (parent, args, context) => {
      return await context.db.users.findAll();
    },

    // Tekil post getir
    post: async (parent, args, context) => {
      const { id } = args;
      return await context.db.posts.findById(id);
    }
  }
};

Burada dikkat edin, her resolver async fonksiyon. GraphQL otomatik olarak Promise’leri çözümler, dolayısıyla async/await kullanmak hem temiz hem de güvenli.

Context ile Veritabanı Bağlantısı Yönetimi

Context, resolver’lar arası köprüdür. Veritabanı bağlantınızı her resolver’a ayrı ayrı import etmek yerine context üzerinden paylaşırsınız. Apollo Server’da context şöyle ayarlanır:

const { ApolloServer } = require('@apollo/server');
const { startStandaloneServer } = require('@apollo/server/standalone');
const { PrismaClient } = require('@prisma/client');

const prisma = new PrismaClient();

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => {
    // Her request için çalışır
    const token = req.headers.authorization || '';
    const user = await verifyToken(token);
    
    return {
      db: prisma,
      currentUser: user,
    };
  },
  listen: { port: 4000 },
});

Artık tüm resolver’larınızda context.db ile Prisma’ya, context.currentUser ile oturum açmış kullanıcıya erişebilirsiniz. Bu pattern büyük projelerde çok temiz kalmanızı sağlar.

İlişkili Veri Çekme: Nested Resolver’lar

GraphQL’in gerçek gücü ilişkili verileri tek sorguda çekebilmektir. Kullanıcının postlarını çekmek istediğimizde User type’ına bir resolver eklememiz gerekir:

const resolvers = {
  Query: {
    user: async (parent, args, context) => {
      return await context.db.user.findUnique({
        where: { id: args.id }
      });
    },
    users: async (parent, args, context) => {
      return await context.db.user.findMany();
    }
  },

  // User type'ının field resolver'ları
  User: {
    posts: async (parent, args, context) => {
      // parent burada User objesi
      // parent.id ile o kullanıcının postlarını çekiyoruz
      return await context.db.post.findMany({
        where: { authorId: parent.id }
      });
    }
  },

  // Post type'ının field resolver'ları
  Post: {
    author: async (parent, args, context) => {
      // parent burada Post objesi
      return await context.db.user.findUnique({
        where: { id: parent.authorId }
      });
    }
  }
};

Bu yapı çalışır ama bir sorun var: N+1 problemi. 10 kullanıcı getirirseniz, her kullanıcı için ayrı bir veritabanı sorgusu tetiklenir. 10 kullanıcı için 10 + 1 = 11 sorgu. Bunu ileride DataLoader ile çözeceğiz.

Gerçek Dünya Senaryosu: E-ticaret Uygulaması

Bir e-ticaret sisteminde ürünleri ve kategorileri yöneten resolver’lar yazalım. Bu sefer daha karmaşık bir senaryo:

type Product {
  id: ID!
  name: String!
  price: Float!
  stock: Int!
  category: Category!
  reviews: [Review!]!
  averageRating: Float
}

type Query {
  products(
    categoryId: ID
    minPrice: Float
    maxPrice: Float
    inStock: Boolean
    page: Int
    limit: Int
  ): ProductConnection!
}

type ProductConnection {
  items: [Product!]!
  totalCount: Int!
  hasNextPage: Boolean!
}
const resolvers = {
  Query: {
    products: async (parent, args, context) => {
      const {
        categoryId,
        minPrice,
        maxPrice,
        inStock,
        page = 1,
        limit = 20
      } = args;

      // Filtre objesi oluştur
      const where = {};
      
      if (categoryId) where.categoryId = categoryId;
      if (minPrice !== undefined) where.price = { ...where.price, gte: minPrice };
      if (maxPrice !== undefined) where.price = { ...where.price, lte: maxPrice };
      if (inStock === true) where.stock = { gt: 0 };
      if (inStock === false) where.stock = 0;

      const skip = (page - 1) * limit;

      // Toplam sayıyı ve ürünleri paralel çek
      const [items, totalCount] = await Promise.all([
        context.db.product.findMany({
          where,
          skip,
          take: limit,
          orderBy: { createdAt: 'desc' }
        }),
        context.db.product.count({ where })
      ]);

      return {
        items,
        totalCount,
        hasNextPage: skip + items.length < totalCount
      };
    }
  },

  Product: {
    category: async (parent, args, context) => {
      return await context.db.category.findUnique({
        where: { id: parent.categoryId }
      });
    },

    reviews: async (parent, args, context) => {
      return await context.db.review.findMany({
        where: { productId: parent.id },
        orderBy: { createdAt: 'desc' }
      });
    },

    // averageRating hesaplanan bir field
    averageRating: async (parent, args, context) => {
      const result = await context.db.review.aggregate({
        where: { productId: parent.id },
        _avg: { rating: true }
      });
      return result._avg.rating;
    }
  }
};

Burada Promise.all kullanımına dikkat edin. Toplam sayı ve ürün listesini paralel sorgularla çekiyoruz. Bu, sıralı sorguya göre neredeyse yarı sürede tamamlanır.

DataLoader ile N+1 Problemini Çözmek

N+1 problemi GraphQL’in en bilinen zorluğudur. DataLoader bu sorunu batch loading ve caching ile çözer. Her request başında yeni DataLoader instance’ları oluşturmanız gerekir, aksi halde cache’ler requestler arasında karışır.

const DataLoader = require('dataloader');

// Context factory'nizde DataLoader'ları oluşturun
const createLoaders = (db) => ({
  userLoader: new DataLoader(async (userIds) => {
    // Tüm id'leri tek sorguda çek
    const users = await db.user.findMany({
      where: { id: { in: userIds } }
    });
    
    // DataLoader sırayı korumak ister, map ile düzenle
    const userMap = {};
    users.forEach(user => {
      userMap[user.id] = user;
    });
    
    return userIds.map(id => userMap[id] || null);
  }),

  postsByUserLoader: new DataLoader(async (userIds) => {
    const posts = await db.post.findMany({
      where: { authorId: { in: userIds } }
    });
    
    // Her userId için postları grupla
    const postMap = {};
    userIds.forEach(id => { postMap[id] = []; });
    posts.forEach(post => {
      if (postMap[post.authorId]) {
        postMap[post.authorId].push(post);
      }
    });
    
    return userIds.map(id => postMap[id]);
  })
});

// Context'e ekle
context: async ({ req }) => {
  return {
    db: prisma,
    loaders: createLoaders(prisma),
    currentUser: await verifyToken(req.headers.authorization)
  };
}

Artık resolver’larınızda DataLoader kullanırsınız:

const resolvers = {
  Post: {
    // Artık N+1 yok, batch halinde yükleniyor
    author: async (parent, args, context) => {
      return await context.loaders.userLoader.load(parent.authorId);
    }
  },

  User: {
    posts: async (parent, args, context) => {
      return await context.loaders.postsByUserLoader.load(parent.id);
    }
  }
};

DataLoader magic’i şudur: Aynı request döngüsünde birden fazla load() çağrısı yapılırsa, bunları tek bir batch’te toplar ve tek veritabanı sorgusuyla çözer. 100 kullanıcının yazarını çekmek isteseydiniz 100 sorgu yerine 1 sorgu gider.

REST API’den Veri Çekme

Her şey veritabanı değil. Bazen resolver’ınız üçüncü parti bir REST API’ye bağlanmak zorunda kalır. Mesela bir hava durumu servisi:

const fetch = require('node-fetch');

const resolvers = {
  Query: {
    weather: async (parent, args, context) => {
      const { city } = args;
      
      try {
        const response = await fetch(
          `https://api.weatherapi.com/v1/current.json?key=${process.env.WEATHER_API_KEY}&q=${encodeURIComponent(city)}`
        );
        
        if (!response.ok) {
          throw new Error(`Hava durumu servisi hata döndürdü: ${response.status}`);
        }
        
        const data = await response.json();
        
        // GraphQL şemanıza uygun formata dönüştür
        return {
          city: data.location.name,
          temperature: data.current.temp_c,
          humidity: data.current.humidity,
          description: data.current.condition.text,
          updatedAt: new Date(data.current.last_updated).toISOString()
        };
      } catch (error) {
        // Hataları GraphQL'e uygun şekilde yönet
        console.error('Hava durumu çekme hatası:', error);
        throw new Error('Hava durumu bilgisi şu an alınamıyor');
      }
    }
  }
};

Burada önemli bir nokta: External API hatalarını doğrudan client’a yansıtmayın. Loglayın, sonra kullanıcı dostu bir hata mesajı döndürün. Servis API key’lerinizi veya internal hata detaylarını client’a sızdırmak güvenlik açığıdır.

Mutation Resolver’ları

Veri yazmak da okumak kadar önemli. Mutation resolver’ları genellikle validation, authorization ve veritabanı işlemlerini birleştirir:

const resolvers = {
  Mutation: {
    createPost: async (parent, args, context) => {
      // Authentication kontrolü
      if (!context.currentUser) {
        throw new Error('Bu işlem için giriş yapmanız gerekiyor');
      }

      const { title, content, categoryId } = args.input;

      // Basit validation
      if (!title || title.trim().length < 3) {
        throw new Error('Başlık en az 3 karakter olmalı');
      }

      if (!content || content.trim().length < 10) {
        throw new Error('İçerik en az 10 karakter olmalı');
      }

      // Kategori var mı kontrol et
      const category = await context.db.category.findUnique({
        where: { id: categoryId }
      });

      if (!category) {
        throw new Error('Belirtilen kategori bulunamadı');
      }

      // Postu oluştur
      const post = await context.db.post.create({
        data: {
          title: title.trim(),
          content: content.trim(),
          authorId: context.currentUser.id,
          categoryId
        }
      });

      return post;
    },

    updatePost: async (parent, args, context) => {
      if (!context.currentUser) {
        throw new Error('Bu işlem için giriş yapmanız gerekiyor');
      }

      const { id, ...updateData } = args.input;

      // Post var mı ve bu kullanıcıya ait mi?
      const existingPost = await context.db.post.findUnique({
        where: { id }
      });

      if (!existingPost) {
        throw new Error('Post bulunamadı');
      }

      if (existingPost.authorId !== context.currentUser.id) {
        throw new Error('Bu postu düzenleme yetkiniz yok');
      }

      return await context.db.post.update({
        where: { id },
        data: updateData
      });
    }
  }
};

Resolver’larda Hata Yönetimi

GraphQL hataları REST’ten farklı çalışır. Bir field hata verirse GraphQL o field’ı null yapar ve errors array’ine ekler, ama diğer field’lar çalışmaya devam eder. Bunu daha iyi yönetmek için custom error class’ları kullanın:

const { GraphQLError } = require('graphql');

// Custom hata tipleri
class NotFoundError extends GraphQLError {
  constructor(resource, id) {
    super(`${resource} bulunamadı: ${id}`, {
      extensions: {
        code: 'NOT_FOUND',
        resource,
        id
      }
    });
  }
}

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

// Resolver'larda kullanımı
const resolvers = {
  Query: {
    user: async (parent, args, context) => {
      const user = await context.db.user.findUnique({
        where: { id: args.id }
      });
      
      if (!user) {
        throw new NotFoundError('Kullanıcı', args.id);
      }
      
      // Admin değilse başka kullanıcıların detayını gösterme
      if (user.id !== context.currentUser?.id && !context.currentUser?.isAdmin) {
        throw new AuthorizationError();
      }
      
      return user;
    }
  }
};

extensions alanı client tarafında hata tipini programatik olarak kontrol etmek için kullanılır. code: 'NOT_FOUND' gibi sabit kodlar frontend’in hataları işlemesini kolaylaştırır.

Resolver Performans Optimizasyonu

Büyük projelerde resolver performansı kritik hale gelir. Birkaç pratik ipucu:

  • Sadece ihtiyaç duyulan field’ları seç: info parametresi sayesinde client’ın hangi field’ları istediğini bilip veritabanı sorgusunu optimize edebilirsiniz.
  • Cache kullanın: Sık değişmeyen veriler için Redis veya in-memory cache ekleyin.
  • Promise.all ile paralel sorgular çalıştırın: Bağımsız sorgular için asla sıralı await kullanmayın.
  • Pagination zorunlu kılın: Liste döndüren her resolver’da limit/offset veya cursor-based pagination uygulayın.
// Kötü: Sıralı sorgular
const user = await db.user.findUnique({ where: { id } });
const posts = await db.post.findMany({ where: { authorId: id } });
const stats = await db.userStat.findUnique({ where: { userId: id } });

// İyi: Paralel sorgular
const [user, posts, stats] = await Promise.all([
  db.user.findUnique({ where: { id } }),
  db.post.findMany({ where: { authorId: id } }),
  db.userStat.findUnique({ where: { userId: id } })
]);

Sonuç

Resolver yazmak başta basit görünür, ama production kalitesinde resolver’lar için göz önünde bulundurmanız gereken pek çok şey var. Authentication ve authorization kontrollerini unutmamak, N+1 problemine karşı DataLoader kullanmak, hataları doğru yönetmek, performans için paralel sorgular çalıştırmak ve dış servislere yapılan çağrılarda sağlam hata yakalama mekanizmaları kurmak bunların başında geliyor.

Context’i iyi tasarlamak resolver kodunu ciddi ölçüde sadeleştirir. Veritabanı bağlantısı, DataLoader instance’ları ve kullanıcı bilgisini context üzerinden paylaşmak, her resolver’ı kendi başına yönetilebilir bir birim haline getirir.

Başlangıç için her şeyi mükemmel yapmanıza gerek yok. Önce çalışan resolver’lar yazın, sonra DataLoader ekleyin, ardından error handling’i iyileştirin. GraphQL’in güzelliği şema değişmeden resolver implementasyonunu istediğiniz gibi geliştirebilmenizdir.

Bir yanıt yazın

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