REST’ten GraphQL’e Geçişte Yapılan Yaygın Hatalar ve Çözümleri

REST API’lerle yıllarca çalıştıktan sonra birisi sana “Artık GraphQL’e geçiyoruz” dediğinde, ilk tepkin genellikle heyecan oluyor. Sonra migration başlıyor ve birkaç hafta içinde o heyecan yerini “neden bu kadar karmaşıklaştı?” sorusuna bırakıyor. Bu yazıda, ekiplerin REST’ten GraphQL’e geçerken düştüğü en yaygın tuzakları ve bu tuzaklardan nasıl çıkılacağını gerçek dünya deneyimleriyle anlatacağım.

GraphQL’i REST’in Üzerine Yapıştırmak

En yaygın hata şu: Mevcut REST endpoint’lerini olduğu gibi alıp GraphQL resolver’larına dönüştürmek. Kulağa mantıklı geliyor, değil mi? Aslında değil.

REST’te /api/users/{id}/orders/{orderId}/items gibi bir endpoint varsa ve bunu birebir GraphQL’e taşırsanız, GraphQL’in sağlaması gereken esnekliği kaybedersiniz. Çünkü GraphQL’in güzelliği tam da bu hiyerarşiyi client’ın ihtiyacına göre şekillendirabilmesinde yatıyor.

Yanlış yaklaşım şuna benziyor:

# REST endpoint'lerini birebir GraphQL'e taşıma - YANLIS
type Query {
  getUserOrderItems(userId: ID!, orderId: ID!): [OrderItem]
  getUserOrders(userId: ID!): [Order]
  getUser(userId: ID!): User
}

Doğru yaklaşım ise graph ilişkilerini doğru modellemek:

# Dogru GraphQL schema tasarimi
type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  name: String!
  email: String!
  orders: [Order!]!
}

type Order {
  id: ID!
  status: OrderStatus!
  items: [OrderItem!]!
  total: Float!
}

type OrderItem {
  id: ID!
  product: Product!
  quantity: Int!
  price: Float!
}

Bu şekilde client, tek bir sorguda istediği derinlikte veri çekebilir. İlk tasarımda ise her ilişki için ayrı sorgu yazmak zorunda kalırsın.

N+1 Problemi: En Sık Karşılaşılan Performans Kabusu

REST’ten geçiş yapan ekiplerin neredeyse tamamı bu sorunla karşılaşıyor. N+1 problemi, bir liste sorguladığında her eleman için ayrı bir veritabanı sorgusu tetiklenmesi durumudur.

Örneğin 100 kullanıcı ve her kullanıcının siparişlerini çektiğinde arka planda 101 sorgu çalışıyor: 1 kullanıcı listesi + 100 ayrı sipariş sorgusu. Production’da bu durum sistemi adeta felç ediyor.

# N+1 problemi olusturan naive resolver - YANLIS
const resolvers = {
  Query: {
    users: async () => {
      return await db.query('SELECT * FROM users');
    }
  },
  User: {
    orders: async (user) => {
      # Her kullanici icin ayri sorgu - N+1 problemi!
      return await db.query(
        'SELECT * FROM orders WHERE user_id = $1', 
        [user.id]
      );
    }
  }
};

Çözüm DataLoader kullanmak. DataLoader, aynı tick içindeki sorguları batch’leyerek tek bir veritabanı sorgusuna indiriyor:

# DataLoader ile N+1 cozumu - DOGRU
const DataLoader = require('dataloader');

const orderLoader = new DataLoader(async (userIds) => {
  # Tum kullanici ID'leri icin tek sorgu
  const orders = await db.query(
    'SELECT * FROM orders WHERE user_id = ANY($1)',
    [userIds]
  );
  
  # Her kullanici icin siparisleri grupla
  return userIds.map(userId => 
    orders.filter(order => order.user_id === userId)
  );
});

const resolvers = {
  User: {
    orders: async (user) => {
      return await orderLoader.load(user.id);
    }
  }
};

Bunu production’a almadan önce mutlaka query logging açarak kaç sorgu gittiğini doğrula. 100 kullanıcı sorgusu 101 sorgudan 2 sorguya inmeliydi.

Over-fetching ve Under-fetching’i Yanlış Anlamak

REST’in temel sorunları olarak gösterilen over-fetching (fazla veri çekme) ve under-fetching (yetersiz veri çekme) GraphQL ile çözülmeli. Ama çoğu geçiş projesinde bu sorunlar devam ediyor, hatta bazen daha da kötüleşiyor.

Neden? Çünkü schema’yı tasarlarken client’ın gerçek ihtiyaçlarını analiz etmek yerine, var olan REST response’larını aynen kopyalıyorlar.

# REST response'unu kopyalayan GraphQL type - YANLIS
type User {
  id: ID!
  firstName: String
  lastName: String
  email: String
  phoneNumber: String
  address: String
  city: String
  country: String
  postalCode: String
  birthDate: String
  profileImage: String
  createdAt: String
  updatedAt: String
  lastLoginAt: String
  isActive: Boolean
  isVerified: Boolean
  # ... 20 alan daha
}

Bunun yerine domain’e göre type’ları ayır:

# Dogru yaklasim - ilgili alanlari grupla
type User {
  id: ID!
  profile: UserProfile!
  contact: UserContact!
  account: UserAccount!
}

type UserProfile {
  firstName: String!
  lastName: String!
  birthDate: String
  profileImage: String
}

type UserContact {
  email: String!
  phoneNumber: String
  address: UserAddress
}

type UserAddress {
  street: String
  city: String
  country: String
  postalCode: String
}

type UserAccount {
  isActive: Boolean!
  isVerified: Boolean!
  createdAt: String!
  lastLoginAt: String
}

Bu yapıda bir mobile app yalnızca user { id profile { firstName lastName profileImage } } çekebilirken, bir admin paneli tüm alanları talep edebilir.

Authentication ve Authorization’ı Doğru Konuma Koymamak

REST’te her endpoint için ayrı middleware yazıyorsun. GraphQL’e geçince bazı ekipler resolver seviyesinde auth yazmaya başlıyor. Bu başlı başına bir kabus.

Düşün: 50 resolver var ve her birine if (!user) throw new AuthError() yazmak zorunda kalıyorsun. Birini unutursan güvenlik açığı doğuyor.

# Her resolver'da tekrar eden auth kontrolu - YANLIS
const resolvers = {
  Query: {
    sensitiveData: async (_, __, context) => {
      if (!context.user) {
        throw new Error('Unauthorized');
      }
      if (!context.user.roles.includes('admin')) {
        throw new Error('Forbidden');
      }
      return await getSensitiveData();
    },
    anotherSensitiveData: async (_, __, context) => {
      if (!context.user) {  # Tekrar tekrar ayni kontrol
        throw new Error('Unauthorized');
      }
      # ...
    }
  }
};

Schema directive’leri veya middleware katmanı kullanarak auth’u merkezi bir yere taşı:

# graphql-shield ile merkezi authorization - DOGRU
const { shield, rule, and } = require('graphql-shield');

const isAuthenticated = rule({ cache: 'contextual' })(
  async (parent, args, context) => {
    return context.user !== null && context.user !== undefined;
  }
);

const isAdmin = rule({ cache: 'contextual' })(
  async (parent, args, context) => {
    return context.user?.roles?.includes('admin') === true;
  }
);

const permissions = shield({
  Query: {
    sensitiveData: and(isAuthenticated, isAdmin),
    userProfile: isAuthenticated,
    publicPosts: allow
  },
  Mutation: {
    createPost: isAuthenticated,
    deleteUser: and(isAuthenticated, isAdmin)
  }
});

Bu yaklaşımla auth mantığı tek bir yerde yönetiliyor ve gözden kaçırma ihtimali minimuma iniyor.

Error Handling’i REST Gibi Yapmak

REST’te HTTP status code’ları anlam taşıyor: 404, 403, 500 gibi. GraphQL’de ise her şey 200 ile dönüyor ve hata yönetimi tamamen farklı çalışıyor. Bunu kavramayan ekipler, REST’teki hata mantığını GraphQL’e taşımaya çalışıyor ve ortaya tutarsız bir API çıkıyor.

# REST tarzı hata yonetimi - YANLIS
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      const user = await findUser(id);
      if (!user) {
        throw new Error('404: User not found');  # HTTP kodu ile string
      }
      return user;
    }
  }
};

GraphQL’de hataları union type’lar veya custom error class’larla yönetmek çok daha temiz:

# GraphQL native hata yonetimi - DOGRU

# Schema tarafinda
const typeDefs = `
  type UserNotFoundError {
    message: String!
    userId: ID!
  }

  type ValidationError {
    message: String!
    field: String!
  }

  union UserResult = User | UserNotFoundError | ValidationError

  type Query {
    user(id: ID!): UserResult!
  }
`;

# Resolver tarafinda
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      if (!isValidId(id)) {
        return {
          __typename: 'ValidationError',
          message: 'Gecersiz kullanici ID formati',
          field: 'id'
        };
      }
      
      const user = await findUser(id);
      if (!user) {
        return {
          __typename: 'UserNotFoundError',
          message: 'Kullanici bulunamadi',
          userId: id
        };
      }
      
      return { __typename: 'User', ...user };
    }
  }
};

Client tarafında da union type’ları handle etmek çok daha açık ve tip güvenli oluyor.

Pagination’ı Sonradan Eklemek

REST’te ?page=1&limit=20 ile çalışıyordun. GraphQL’e geçince ilk sürümde pagination’ı atladın, “sonra ekleriz” dedin. Bu karar seni sonradan büyük bir refactoring’e sürüklüyor.

GraphQL’de pagination için endüstri standardı olan Cursor-based pagination’ı baştan implemente et:

# Cursor-based pagination - Relay spec uyumlu
const typeDefs = `
  type PageInfo {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
    startCursor: String
    endCursor: String
  }

  type UserEdge {
    node: User!
    cursor: String!
  }

  type UserConnection {
    edges: [UserEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }

  type Query {
    users(
      first: Int
      after: String
      last: Int
      before: String
      filter: UserFilter
    ): UserConnection!
  }
`;

const resolvers = {
  Query: {
    users: async (_, { first = 10, after, filter }) => {
      const cursor = after ? decodeCursor(after) : null;
      
      const users = await db.query(`
        SELECT * FROM users 
        WHERE ($1::timestamp IS NULL OR created_at > $1)
        AND ($2::text IS NULL OR name ILIKE '%' || $2 || '%')
        ORDER BY created_at ASC
        LIMIT $3
      `, [cursor, filter?.name, first + 1]);
      
      const hasNextPage = users.length > first;
      const edges = users.slice(0, first).map(user => ({
        node: user,
        cursor: encodeCursor(user.created_at)
      }));
      
      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!cursor,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor
        },
        totalCount: await db.count('users')
      };
    }
  }
};

Offset-based pagination yerine cursor-based tercih et. Büyük veri setlerinde offset performanssız çalışıyor ve sayfa arası kayıt kayma problemi yaşanıyor.

Versioning Stratejisini Kaçırmak

REST’te /api/v1/ ve /api/v2/ gibi versioning kolay anlaşılır bir yapı sunuyor. GraphQL’e geçince “GraphQL’de versioning olmaz” diye duymuş olan ekipler hiçbir deprecation stratejisi olmaksızın ilerliyor. Aylar sonra field kaldırmak isteyince client’lar bozuluyor.

GraphQL’de field deprecation direktifini aktif kullan:

# GraphQL field deprecation - DOGRU YAKLASIM
type User {
  id: ID!
  name: String!
  
  # Eski alan - deprecated olarak isaretlendi
  username: String @deprecated(reason: "Lutfen 'name' alanini kullanin. Bu alan 2024-06-01 tarihinde kaldirilacak.")
  
  # Eski nested yapiyi deprecated yap
  address: String @deprecated(reason: "Lutfen 'location { city country }' kullaning.")
  
  # Yeni yapi
  location: UserLocation
  
  email: String!
}

Aynı zamanda bir monitoring kur ve hangi client’ların deprecated field’ları hala kullandığını izle:

# Apollo Server extension ile deprecated field kullanim takibi
const { ApolloServer } = require('@apollo/server');

const deprecationTracker = {
  requestDidStart() {
    return {
      willSendResponse({ document, response }) {
        # Kullanilan deprecated field'lari logla
        const deprecatedFields = findDeprecatedFieldsInDocument(document);
        if (deprecatedFields.length > 0) {
          console.warn('Deprecated fields used:', {
            fields: deprecatedFields,
            timestamp: new Date().toISOString(),
            userAgent: context.req?.headers['user-agent']
          });
        }
      }
    };
  }
};

Caching Stratejisini Yanlış Kurmak

REST’te HTTP caching çok kolay çalışıyor. CDN veya Nginx üzerinde bir URL’yi cache’lemek için iki satır config yeterliydi. GraphQL’de ise tüm istekler POST olarak tek bir endpoint’e geliyor. Bunu fark etmeyen ekipler ya hiç cache koymuyor ya da yanlış seviyede cache yapıyor.

# Apollo Server ile persisted queries ve caching
const { ApolloServer } = require('@apollo/server');
const { KeyvAdapter } = require('@apollo/utils.keyvadapter');
const Keyv = require('keyv');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  cache: new KeyvAdapter(new Keyv({
    store: new KeyvRedis('redis://localhost:6379'),
    ttl: 300  # 5 dakika default TTL
  }))
});

# Resolver seviyesinde granular caching
const resolvers = {
  Query: {
    publicProducts: async (_, args, { dataSources }) => {
      # Bu veri sik degismiyorsa cache'le
      return dataSources.productAPI.getProducts({
        cacheOptions: { ttl: 3600 }  # 1 saat cache
      });
    },
    userCart: async (_, __, { user, dataSources }) => {
      # Kullaniciya ozgu veriyi cache'leme veya cok kisa TTL ver
      return dataSources.cartAPI.getUserCart(user.id, {
        cacheOptions: { ttl: 30 }  # 30 saniye
      });
    }
  }
};

CDN tarafında ise GET request’leri kullanan persisted queries yöntemini değerlendir. Bu sayede query hash’ini URL parametresi olarak gönderebilir ve CDN cache’inden faydalanabilirsin.

Rate Limiting ve Query Complexity Kontrolü

REST’te her endpoint için ayrı rate limit koymak kolaydı. GraphQL’de tek endpoint olduğu için basit IP-based rate limiting yetersiz. Bir client, iç içe geçmiş sonsuz derinlikte bir sorgu göndererek sunucunu çökertebilir.

# Query complexity ve depth limiting
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const depthLimit = require('graphql-depth-limit');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    # Maximum 7 seviye derinlik
    depthLimit(7),
    
    # Maximum 1000 complexity puani
    createComplexityLimitRule(1000, {
      onCost: (cost) => {
        console.log('Query complexity:', cost);
      }
    })
  ]
});

# Ornek karmasik sorgu - bu reddedilmeli
# {
#   users {
#     orders {
#       items {
#         product {
#           reviews {
#             author {
#               orders {  # <-- 7 seviyeyi asti!
#                 items { ... }
#               }
#             }
#           }
#         }
#       }
#     }
#   }
# }

Production’da bu limitleri belirlerken gerçek client sorgularını analiz et. Çok sıkı koyarsan meşru sorgular da reddediliyor, çok gevşek koyarsan DDoS vektörü oluşturuyorsun.

Monitoring ve Observability’i Sonraya Bırakmak

REST’te her endpoint için ayrı metrik toplamak kolaydı. GraphQL’de ise hangi query’lerin yavaş olduğunu, hangi field’ların çok kullanıldığını veya hangi resolver’ların hata verdiğini görmek için özel çaba gerekiyor.

Apollo Studio veya benzeri bir araç kullanmıyorsan en azından şunları kur:

# Custom GraphQL metrics - Prometheus ile
const { ApolloServer } = require('@apollo/server');
const prometheus = require('prom-client');

const queryDurationHistogram = new prometheus.Histogram({
  name: 'graphql_query_duration_seconds',
  help: 'GraphQL query execution duration',
  labelNames: ['operation_name', 'operation_type'],
  buckets: [0.1, 0.5, 1, 2, 5]
});

const queryErrorCounter = new prometheus.Counter({
  name: 'graphql_query_errors_total',
  help: 'Total number of GraphQL query errors',
  labelNames: ['operation_name', 'error_type']
});

const metricsPlugin = {
  requestDidStart({ request }) {
    const startTime = Date.now();
    const operationName = request.operationName || 'anonymous';
    
    return {
      willSendResponse({ response }) {
        const duration = (Date.now() - startTime) / 1000;
        const operationType = getOperationType(request.document);
        
        queryDurationHistogram
          .labels(operationName, operationType)
          .observe(duration);
          
        if (response.errors?.length > 0) {
          response.errors.forEach(error => {
            queryErrorCounter
              .labels(operationName, error.extensions?.code || 'UNKNOWN')
              .inc();
          });
        }
      }
    };
  }
};

Bu metrikleri Grafana’da görerek hangi operasyonların SLA’ını karşılamadığını hemen tespit edebilirsin.

Geçiş Sürecini “Hepsini Bir Anda” Olarak Planlamak

Belki de en kritik hata: Tüm REST API’yi tek bir sprint’te GraphQL’e çevirmeye çalışmak. Bu yaklaşım neredeyse her zaman başarısızlıkla sonuçlanıyor.

Bunun yerine federated veya incremental yaklaşım benimse. REST API’yi tamamen silmeden yan yana çalıştır. Apollo Federation kullanıyorsan subgraph’leri yavaş yavaş ekleyebilirsin. Kullanım oranı düşük endpoint’leri en sona bırak.

Pratik bir geçiş stratejisi şu şekilde işliyor:

  • Aşama 1: En çok kullanılan ve iyi test edilmiş domain’den başla (örneğin ürün kataloğu)
  • Aşama 2: GraphQL gateway’i REST’in önüne koy, eski client’lar REST’i kullanmaya devam etsin
  • Aşama 3: Yeni feature’ları sadece GraphQL’den yaz, eski endpoint’lere dokunma
  • Aşama 4: Client’ları birer birer GraphQL’e migrate et, REST kullanımını izle
  • Aşama 5: Kullanımı sıfıra düşen REST endpoint’lerini kapat

Bu yaklaşımda rollback yapmak da çok daha kolay oluyor.

Sonuç

REST’ten GraphQL’e geçiş, sadece teknik bir değişim değil; API tasarım felsefesinin köklü bir dönüşümü. N+1 probleminden authorization mimarisine, caching stratejisinden monitoring’e kadar her aşamada REST alışkanlıklarını bilinçli olarak geride bırakmak gerekiyor.

En önemli çıkarımlar şunlar: DataLoader olmadan production’a çıkma. Auth’u resolver seviyesinde dağıtma, merkezi tut. Schema’yı client ihtiyaçlarına göre tasarla, mevcut REST response’larını kopyalama. Pagination ve deprecation stratejisini baştan kur. Ve her şeyden önemlisi, geçişi aşamalı yap.

GraphQL gerçekten güçlü bir araç, ama bu güç ancak doğru kullanıldığında değer yaratıyor. Aceleyle ve yanlış alışkanlıklarla yapılan bir geçiş, REST’in tüm sorunlarını GraphQL’in karmaşıklığıyla birleştirip ortaya daha kötü bir sistem çıkarıyor. Sabırlı ol, her aşamayı ölç ve takımdaki herkesi bu felsefi değişime ortak et.

Bir yanıt yazın

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