Mevcut MySQL Veritabanını GraphQL API’ye Dönüştürme

Yıllarca üzerine REST endpoint’leri yığılmış, her yeni özellik isteğiyle birlikte büyüyen bir MySQL veritabanınız var diyelim. Bir noktada fark ediyorsunuz: frontend ekibi her ekran için ayrı ayrı endpoint istiyor, mobil uygulama fazla veri çekiyor, raporlama modülü performans sorunları yaratıyor. İşte tam bu noktada GraphQL devreye giriyor. Ama soru şu: mevcut MySQL şemanızı sıfırdan yazmadan GraphQL’e nasıl taşırsınız?

Bu yazıda gerçek bir e-ticaret veritabanını örnek alarak, mevcut MySQL yapısını GraphQL API’ye dönüştürme sürecini adım adım inceleyeceğiz. Araç seçiminden resolver optimizasyonuna kadar her aşamayı pratikte ne işe yaradığıyla birlikte ele alacağız.

Neden Doğrudan GraphQL, Neden Wrapper Değil?

Önce bir kavramsal netlik sağlayalım. İki farklı yaklaşım var:

  • REST üzerine GraphQL wrapper: Mevcut REST endpoint’lerinizi GraphQL schema’sıyla sarmalıyorsunuz. Kolay ama yavaş, çünkü altında REST var.
  • Doğrudan veritabanı bağlantısı: GraphQL resolver’ları doğrudan MySQL’e bağlanıyor. Daha fazla iş ama çok daha performanslı.

İkinci yolu öneriyorum. Çünkü wrapper yaklaşımı sadece problemi erteliyor. Özellikle N+1 sorgu problemleri REST katmanında zaten var, GraphQL ekleyince daha da kötüleşiyor.

Ortam Hazırlığı ve Araç Seçimi

Node.js ekosisteminde en yaygın kullanılan kombinasyon şu şekilde: Apollo Server + Prisma ya da Apollo Server + Knex.js. Ben bu yazıda Knex.js tercih edeceğim çünkü mevcut SQL sorgularınızı Knex’e taşımak Prisma’ya taşımaktan çok daha az acı verici.

mkdir mysql-graphql-api && cd mysql-graphql-api
npm init -y
npm install apollo-server graphql knex mysql2 dataloader dotenv
npm install -D nodemon

Proje yapısı şöyle olacak:

mkdir -p src/{resolvers,schema,loaders,db}
touch src/index.js src/db/knex.js src/schema/typeDefs.js
touch .env

.env dosyasına veritabanı bilgilerini ekleyin:

DB_HOST=localhost
DB_PORT=3306
DB_USER=api_user
DB_PASSWORD=guclu_bir_sifre
DB_NAME=ecommerce_db
PORT=4000

Veritabanı Bağlantısı Kurma

Knex konfigürasyonu oldukça düz bir süreç. Ama dikkat edilmesi gereken bir nokta var: connection pool ayarları. Özellikle yoğun GraphQL sorguları paralel veritabanı bağlantıları açabiliyor. Bunu kontrol altında tutmak gerekiyor.

// src/db/knex.js
const knex = require('knex');
require('dotenv').config();

const db = knex({
  client: 'mysql2',
  connection: {
    host: process.env.DB_HOST,
    port: process.env.DB_PORT,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME,
    charset: 'utf8mb4',
  },
  pool: {
    min: 2,
    max: 10,
    acquireTimeoutMillis: 30000,
    idleTimeoutMillis: 30000,
  },
  asyncStackTraces: process.env.NODE_ENV !== 'production',
});

// Bağlantıyı test et
db.raw('SELECT 1')
  .then(() => console.log('MySQL bağlantısı başarılı'))
  .catch((err) => {
    console.error('MySQL bağlantı hatası:', err.message);
    process.exit(1);
  });

module.exports = db;

max: 10 değerini MySQL sunucunuzun max_connections değeriyle orantılı tutun. Çok fazla servis aynı veritabanına bağlanıyorsa bu sayıyı düşürmeniz gerekebilir.

Schema Tasarımı: MySQL Tablolarından GraphQL Tiplerine

Şöyle bir MySQL şeması varsayalım:

-- Mevcut tablolar
CREATE TABLE users (
  id INT PRIMARY KEY AUTO_INCREMENT,
  email VARCHAR(255) UNIQUE NOT NULL,
  full_name VARCHAR(255),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE products (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(255) NOT NULL,
  price DECIMAL(10,2) NOT NULL,
  stock_quantity INT DEFAULT 0,
  category_id INT,
  FOREIGN KEY (category_id) REFERENCES categories(id)
);

CREATE TABLE orders (
  id INT PRIMARY KEY AUTO_INCREMENT,
  user_id INT NOT NULL,
  total_amount DECIMAL(10,2),
  status ENUM('pending','confirmed','shipped','delivered') DEFAULT 'pending',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

Bu yapıyı GraphQL schema’sına dönüştürmek için typeDefs.js dosyasını hazırlayalım:

// src/schema/typeDefs.js
const { gql } = require('apollo-server');

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

  type Product {
    id: ID!
    name: String!
    price: Float!
    stockQuantity: Int!
    category: Category
    inStock: Boolean!
  }

  type Category {
    id: ID!
    name: String!
    products: [Product]
  }

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

  enum OrderStatus {
    PENDING
    CONFIRMED
    SHIPPED
    DELIVERED
  }

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

  type Query {
    user(id: ID!): User
    users(limit: Int, offset: Int): [User]
    product(id: ID!): Product
    products(categoryId: ID, inStock: Boolean, limit: Int): [Product]
    order(id: ID!): Order
    userOrders(userId: ID!, status: OrderStatus): [Order]
  }

  type Mutation {
    createOrder(userId: ID!, items: [OrderItemInput!]!): Order
    updateOrderStatus(orderId: ID!, status: OrderStatus!): Order
  }

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

module.exports = typeDefs;

Burada dikkat etmeniz gereken birkaç nokta var. MySQL’deki snake_case alan adlarını GraphQL’de camelCase‘e çevirin. full_name MySQL’de nasıl duruyor, GraphQL’de fullName olarak kullanıcıya sunuluyor. Bu dönüşümü resolver’larda yapacağız. Ayrıca computed field’lar ekleyebilirsiniz: inStock ve orderCount gibi. Bu alanlar veritabanında kolona karşılık gelmiyor ama resolver seviyesinde hesaplanıyor.

Resolver Yazımı ve N+1 Problemine Karşı DataLoader

Resolver’ları yazmak kolay. Asıl mesele N+1 sorgu problemi. GraphQL’in doğası gereği ilişkili verileri çekerken her kayıt için ayrı sorgu atma tehlikesi var. Bunu DataLoader ile çözüyoruz.

// src/loaders/index.js
const DataLoader = require('dataloader');
const db = require('../db/knex');

// Kullanıcıları toplu yükle
const createUserLoader = () =>
  new DataLoader(async (userIds) => {
    const users = await db('users').whereIn('id', userIds);
    const userMap = {};
    users.forEach((u) => (userMap[u.id] = u));
    return userIds.map((id) => userMap[id] || null);
  });

// Ürünleri toplu yükle
const createProductLoader = () =>
  new DataLoader(async (productIds) => {
    const products = await db('products').whereIn('id', productIds);
    const productMap = {};
    products.forEach((p) => (productMap[p.id] = p));
    return productIds.map((id) => productMap[id] || null);
  });

// Kategorilere göre ürünleri yükle
const createProductsByCategoryLoader = () =>
  new DataLoader(async (categoryIds) => {
    const products = await db('products').whereIn('category_id', categoryIds);
    return categoryIds.map((catId) =>
      products.filter((p) => p.category_id === catId)
    );
  });

module.exports = {
  createUserLoader,
  createProductLoader,
  createProductsByCategoryLoader,
};

Şimdi resolver’ları yazalım:

// src/resolvers/index.js
const db = require('../db/knex');

const resolvers = {
  Query: {
    user: async (_, { id }) => {
      const user = await db('users').where({ id }).first();
      return user || null;
    },

    users: async (_, { limit = 20, offset = 0 }) => {
      return db('users').limit(limit).offset(offset).orderBy('created_at', 'desc');
    },

    product: async (_, { id }, { loaders }) => {
      return loaders.product.load(id);
    },

    products: async (_, { categoryId, inStock, limit = 50 }) => {
      let query = db('products').limit(limit);
      if (categoryId) query = query.where({ category_id: categoryId });
      if (inStock === true) query = query.where('stock_quantity', '>', 0);
      if (inStock === false) query = query.where({ stock_quantity: 0 });
      return query;
    },

    userOrders: async (_, { userId, status }) => {
      let query = db('orders').where({ user_id: userId });
      if (status) query = query.where({ status: status.toLowerCase() });
      return query.orderBy('created_at', 'desc');
    },
  },

  User: {
    fullName: (parent) => parent.full_name,
    createdAt: (parent) => parent.created_at?.toISOString(),
    orders: async (parent, _, { loaders }) => {
      return db('orders').where({ user_id: parent.id });
    },
    orderCount: async (parent) => {
      const result = await db('orders')
        .where({ user_id: parent.id })
        .count('id as count')
        .first();
      return parseInt(result.count, 10);
    },
  },

  Product: {
    stockQuantity: (parent) => parent.stock_quantity,
    inStock: (parent) => parent.stock_quantity > 0,
    category: async (parent, _, { loaders }) => {
      if (!parent.category_id) return null;
      return db('categories').where({ id: parent.category_id }).first();
    },
  },

  Order: {
    user: async (parent, _, { loaders }) => {
      return loaders.user.load(parent.user_id);
    },
    totalAmount: (parent) => parent.total_amount,
    createdAt: (parent) => parent.created_at?.toISOString(),
    status: (parent) => parent.status.toUpperCase(),
    items: async (parent) => {
      return db('order_items').where({ order_id: parent.id });
    },
  },

  OrderItem: {
    unitPrice: (parent) => parent.unit_price,
    product: async (parent, _, { loaders }) => {
      return loaders.product.load(parent.product_id);
    },
  },

  Mutation: {
    createOrder: async (_, { userId, items }) => {
      // Transaction kullanmak şart
      return db.transaction(async (trx) => {
        // Stok kontrolü
        for (const item of items) {
          const product = await trx('products')
            .where({ id: item.productId })
            .first();
          if (!product || product.stock_quantity < item.quantity) {
            throw new Error(`Yetersiz stok: Ürün ID ${item.productId}`);
          }
        }

        // Toplam tutarı hesapla
        let totalAmount = 0;
        const enrichedItems = [];
        for (const item of items) {
          const product = await trx('products').where({ id: item.productId }).first();
          const lineTotal = product.price * item.quantity;
          totalAmount += lineTotal;
          enrichedItems.push({ product, item, lineTotal });
        }

        // Sipariş oluştur
        const [orderId] = await trx('orders').insert({
          user_id: userId,
          total_amount: totalAmount,
          status: 'pending',
        });

        // Sipariş kalemlerini ekle ve stokları düş
        for (const { product, item } of enrichedItems) {
          await trx('order_items').insert({
            order_id: orderId,
            product_id: item.productId,
            quantity: item.quantity,
            unit_price: product.price,
          });
          await trx('products')
            .where({ id: item.productId })
            .decrement('stock_quantity', item.quantity);
        }

        return trx('orders').where({ id: orderId }).first();
      });
    },

    updateOrderStatus: async (_, { orderId, status }) => {
      await db('orders')
        .where({ id: orderId })
        .update({ status: status.toLowerCase() });
      return db('orders').where({ id: orderId }).first();
    },
  },
};

module.exports = resolvers;

Ana Server Dosyası

// src/index.js
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./schema/typeDefs');
const resolvers = require('./resolvers');
const {
  createUserLoader,
  createProductLoader,
} = require('./loaders');
require('dotenv').config();

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: () => ({
    // Her request için yeni loader instance oluştur
    // Bu kritik, loader'lar request-scoped olmalı
    loaders: {
      user: createUserLoader(),
      product: createProductLoader(),
    },
  }),
  formatError: (error) => {
    console.error('GraphQL Hatası:', error.message);
    // Production'da stack trace'i gizle
    if (process.env.NODE_ENV === 'production') {
      return { message: error.message };
    }
    return error;
  },
  introspection: process.env.NODE_ENV !== 'production',
  playground: process.env.NODE_ENV !== 'production',
});

server.listen({ port: process.env.PORT || 4000 }).then(({ url }) => {
  console.log(`GraphQL API hazir: ${url}`);
});

Mevcut REST API ile Geçiş Dönemi Yönetimi

Prodüksiyonda bir gecede REST’ten GraphQL’e geçmek intihar niteliğinde. Gerçekçi bir geçiş planı şöyle görünür:

  • Birinci aşama: GraphQL endpoint’i eklenir, REST endpoint’leri çalışmaya devam eder. İki API paralel işler.
  • İkinci aşama: Yeni özellikler sadece GraphQL üzerinden geliştirilir. Frontend ekibi kademeli olarak geçmeye başlar.
  • Üçüncü aşama: REST endpoint’leri birer birer deprecate edilir. Monitoring ile hangi endpoint’lerin hala kullanıldığı takip edilir.
  • Dördüncü aşama: Kullanım sıfıra düşen REST endpoint’leri kapatılır.

Bu süreci hızlandırmak için nginx seviyesinde iki API’yi aynı domain altında farklı path’larla sunabilirsiniz: /api/v1/ REST için, /graphql yeni API için.

Performans Optimizasyonları

Birkaç pratik öneri:

  • Query complexity limiting: Kötü niyetli ya da dikkatsizce yazılmış GraphQL sorguları sunucuyu çökertebilir. graphql-query-complexity paketi ile maximum complexity değeri belirleyin.
  • Depth limiting: Çok derin nested sorgular N+1 probleminin DataLoader’ı bile zorlayacak versiyonlarını yaratabilir. graphql-depth-limit ile maksimum 5-6 seviye derinlik sınırı koyun.
  • Response caching: Sık değişmeyen veriler için Apollo Server’ın @cacheControl direktiflerini kullanın. Özellikle kategori listesi gibi statik veriler için etkili.
  • MySQL indeks kontrolü: GraphQL devreye girdikten sonra slow query log’u aktif edin. Resolver’lardan gelen yeni sorgu kombinasyonları bazen hiç beklemediğiniz alanlara indeks ihtiyacı yaratıyor.

Slow query log için MySQL konfigürasyonu:

# /etc/mysql/conf.d/slow-query.cnf
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1

Güvenlik Katmanı

Production’a almadan önce birkaç güvenlik önlemi şart:

  • Introspection’ı kapatın: Schema yapısını dışarıya açmak istemezsiniz. introspection: false ayarı production için.
  • Rate limiting: express-rate-limit veya nginx ile IP başına istek sınırı koyun.
  • Input validation: Mutation’lardaki parametreleri doğrulayın. GraphQL type sistemi bir miktar koruma sağlasa da iş mantığı validasyonu resolver’da yapılmalı.
  • SQL injection: Knex parametrik sorgular kullandığı için bu konuda büyük ölçüde koruma altındasınız. Ama ham SQL yazmanız gerekiyorsa db.raw() içinde binding kullanmayı unutmayın.

Sonuç

Mevcut MySQL veritabanını GraphQL’e taşımak aslında göründüğü kadar zorlu değil. Kritik olan noktalar şunlar: DataLoader olmadan kesinlikle production’a çıkmayın, N+1 problemi GraphQL’in en sık karşılaşılan tuzağı. Transaction kullanmayı unutmayın, özellikle stok ve sipariş gibi birden fazla tabloyu etkileyen işlemlerde veri tutarlılığı kritik. Schema tasarımına zaman ayırın çünkü bir kez dışarıya açtığınız tipler ve alan adları geri almak çok pahalı oluyor.

REST API’yi bir gecede kapatmak yerine paralel çalıştırarak kademeli geçiş yapın. Hem teknik riski azaltırsınız hem de ekibin alışma sürecini rahatlatırsınız. Son olarak, monitoring’i ihmal etmeyin. GraphQL devreye girdikten sonra veritabanı sorgu sayısında ani değişiklikler, beklenmedik yavaş sorgular görebilirsiniz. Bunları erken yakalamak için slow query log ve Apollo Studio’nun tracing özelliğini birlikte kullanmak iyi bir kombinasyon.

Bir yanıt yazın

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