Apollo Server ile PostgreSQL Veritabanı Bağlantısı Nasıl Kurulur

GraphQL API geliştiriyorsun ve bir noktada mutlaka şu soruyla yüzleşiyorsun: “Peki bu veriler nereden gelecek?” İşte tam bu noktada Apollo Server ile PostgreSQL’i bir araya getirmek, production-ready bir backend’in temel taşlarından birini oluşturuyor. Bu yazıda, sıfırdan başlayıp gerçek bir veritabanı bağlantısı kurmanın tüm detaylarını ele alacağız.

Neden Apollo Server + PostgreSQL?

GraphQL dünyasında Apollo Server, en olgun ve yaygın kullanılan çözümlerden biri. PostgreSQL ise ilişkisel veritabanları arasında özellikle karmaşık sorgular, JSON desteği ve güvenilirlik açısından açık ara öne çıkıyor. Bu ikilinin birleşimi, özellikle şu senaryolarda çok işe yarıyor:

  • Birden fazla tablodan ilişkisel veri çekmen gerektiğinde
  • REST API’nin yetersiz kaldığı esnek sorgulama ihtiyaçlarında
  • Real-time veya subscription tabanlı sistemlerde
  • Mikroservis mimarisinde merkezi bir data layer oluştururken

Önemli bir not: Apollo Server direkt olarak PostgreSQL ile konuşmuyor. Araya bir ORM veya query builder giriyor. Bu yazıda node-postgres (pg) ve Knex.js kullanacağız. İkisi de production ortamlarında sıklıkla tercih edilen, olgun araçlar.

Ortamı Hazırlamak

Önce temel bağımlılıkları yükleyelim. Node.js 18+ kullandığını varsayıyorum:

mkdir graphql-postgres-demo && cd graphql-postgres-demo
npm init -y
npm install @apollo/server graphql pg knex dotenv
npm install --save-dev typescript @types/node @types/pg ts-node nodemon
npx tsc --init

PostgreSQL tarafında da bir veritabanı ve kullanıcı oluşturalım:

# PostgreSQL'e bağlan
psql -U postgres

# Veritabanı ve kullanıcı oluştur
CREATE DATABASE blog_db;
CREATE USER blog_user WITH ENCRYPTED PASSWORD 'guclu_sifre_123';
GRANT ALL PRIVILEGES ON DATABASE blog_db TO blog_user;

# Çık
q

# Veritabanına bağlanıp tabloları oluştur
psql -U blog_user -d blog_db

Örnek şemamızı oluşturalım. Gerçek dünya senaryosu olarak basit bir blog sistemi kullanacağız:

psql -U blog_user -d blog_db -c "
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  username VARCHAR(50) UNIQUE NOT NULL,
  email VARCHAR(100) UNIQUE NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE posts (
  id SERIAL PRIMARY KEY,
  title VARCHAR(200) NOT NULL,
  content TEXT,
  published BOOLEAN DEFAULT false,
  author_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE comments (
  id SERIAL PRIMARY KEY,
  body TEXT NOT NULL,
  post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
  user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
  created_at TIMESTAMP DEFAULT NOW()
);

INSERT INTO users (username, email) VALUES 
  ('ahmet_k', '[email protected]'),
  ('zeynep_y', '[email protected]');

INSERT INTO posts (title, content, published, author_id) VALUES
  ('GraphQL Nedir?', 'GraphQL, Facebook tarafından geliştirilen...', true, 1),
  ('PostgreSQL İpuçları', 'Production ortamında dikkat edilmesi gerekenler...', true, 2);
"

Proje Yapısı ve Veritabanı Bağlantısı

Proje dizin yapımız şöyle olacak:

src/
├── db/
│   ├── connection.ts
│   └── migrations/
├── graphql/
│   ├── typeDefs.ts
│   ├── resolvers/
│   │   ├── index.ts
│   │   ├── userResolvers.ts
│   │   └── postResolvers.ts
├── context.ts
└── index.ts

.env dosyasını oluşturalım:

cat > .env << 'EOF'
DB_HOST=localhost
DB_PORT=5432
DB_NAME=blog_db
DB_USER=blog_user
DB_PASSWORD=guclu_sifre_123
DB_POOL_MIN=2
DB_POOL_MAX=10
PORT=4000
NODE_ENV=development
EOF

Şimdi veritabanı bağlantı katmanını kuralım. Knex ile connection pool yönetimi çok daha sağlıklı oluyor:

cat > src/db/connection.ts << 'EOF'
import knex from 'knex';
import dotenv from 'dotenv';

dotenv.config();

const db = knex({
  client: 'pg',
  connection: {
    host: process.env.DB_HOST || 'localhost',
    port: Number(process.env.DB_PORT) || 5432,
    database: process.env.DB_NAME,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    ssl: process.env.NODE_ENV === 'production' 
      ? { rejectUnauthorized: false } 
      : false,
  },
  pool: {
    min: Number(process.env.DB_POOL_MIN) || 2,
    max: Number(process.env.DB_POOL_MAX) || 10,
    // Bağlantı zaman aşımı: 30 saniye
    acquireTimeoutMillis: 30000,
    // Boşta bekleme süresi: 10 dakika
    idleTimeoutMillis: 600000,
  },
  // Sorgu logları için (sadece development ortamında)
  debug: process.env.NODE_ENV === 'development',
});

// Bağlantıyı test et
export async function testConnection(): Promise<void> {
  try {
    await db.raw('SELECT 1');
    console.log('PostgreSQL bağlantısı başarılı!');
  } catch (error) {
    console.error('PostgreSQL bağlantı hatası:', error);
    process.exit(1);
  }
}

export default db;
EOF

Burada connection pool ayarlarına dikkat et. Production ortamında max: 10 genellikle iyi bir başlangıç noktası, ama yük testleri yaparak optimize etmen gerekiyor. PostgreSQL tarafında da max_connections parametresine bak, varsayılan değer 100 ve her servis için ayrı pool hesaplamak gerekiyor.

Context Katmanı: Resolver’lara Veritabanı Erişimi

Apollo Server’da context, her GraphQL isteğinde resolver’lara geçilen ortak bir objedir. Veritabanı bağlantısını buradan geçirmek en temiz yol:

cat > src/context.ts << 'EOF'
import { Knex } from 'knex';
import db from './db/connection';

export interface MyContext {
  db: Knex;
  user?: {
    id: number;
    username: string;
  };
}

// Her request için context oluştur
export async function createContext({ req }: { req: any }): Promise<MyContext> {
  // JWT token varsa kullanıcıyı doğrula
  const token = req.headers.authorization?.split('Bearer ')[1];
  
  let currentUser;
  if (token) {
    try {
      // Gerçek uygulamada JWT verify burada yapılır
      // const decoded = jwt.verify(token, process.env.JWT_SECRET);
      // currentUser = await db('users').where({ id: decoded.userId }).first();
      currentUser = undefined;
    } catch (e) {
      currentUser = undefined;
    }
  }

  return {
    db,
    user: currentUser,
  };
}
EOF

GraphQL Schema Tanımlaması

TypeDefs dosyamızı oluşturalım:

cat > src/graphql/typeDefs.ts << 'EOF'
import { gql } from 'graphql-tag';

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

  type Post {
    id: ID!
    title: String!
    content: String
    published: Boolean!
    author: User!
    comments: [Comment!]
    createdAt: String!
    updatedAt: String!
  }

  type Comment {
    id: ID!
    body: String!
    post: Post!
    user: User!
    createdAt: String!
  }

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

  type Mutation {
    createUser(username: String!, email: String!): User!
    createPost(title: String!, content: String, authorId: ID!): Post!
    publishPost(id: ID!): Post!
    addComment(postId: ID!, userId: ID!, body: String!): Comment!
    deletePost(id: ID!): Boolean!
  }
`;
EOF

Resolver’ları Yazmak: Asıl İş Burada

Resolver’ları modüler tutmak önemli. Önce user resolver’larını yazalım:

cat > src/graphql/resolvers/userResolvers.ts << 'EOF'
import { MyContext } from '../../context';

export const userResolvers = {
  Query: {
    users: async (_: any, __: any, { db }: MyContext) => {
      const users = await db('users')
        .select('*')
        .orderBy('created_at', 'desc');
      return users.map(u => ({
        ...u,
        createdAt: u.created_at,
      }));
    },

    user: async (_: any, { id }: { id: string }, { db }: MyContext) => {
      const user = await db('users')
        .where({ id: parseInt(id) })
        .first();
      
      if (!user) {
        throw new Error(`${id} ID'li kullanıcı bulunamadı`);
      }

      return { ...user, createdAt: user.created_at };
    },
  },

  Mutation: {
    createUser: async (
      _: any,
      { username, email }: { username: string; email: string },
      { db }: MyContext
    ) => {
      // Email kontrolü
      const existing = await db('users').where({ email }).first();
      if (existing) {
        throw new Error('Bu email adresi zaten kullanılıyor');
      }

      const [newUser] = await db('users')
        .insert({ username, email })
        .returning('*');

      return { ...newUser, createdAt: newUser.created_at };
    },
  },

  // Field resolver: User'ın postlarını getir
  User: {
    posts: async (parent: any, _: any, { db }: MyContext) => {
      const posts = await db('posts')
        .where({ author_id: parent.id })
        .orderBy('created_at', 'desc');

      return posts.map(p => ({
        ...p,
        createdAt: p.created_at,
        updatedAt: p.updated_at,
      }));
    },
  },
};
EOF

Post resolver’larında daha karmaşık sorgular var:

cat > src/graphql/resolvers/postResolvers.ts << 'EOF'
import { MyContext } from '../../context';

interface CreatePostArgs {
  title: string;
  content?: string;
  authorId: string;
}

export const postResolvers = {
  Query: {
    posts: async (
      _: any,
      { published }: { published?: boolean },
      { db }: MyContext
    ) => {
      let query = db('posts').select('*');
      
      // Opsiyonel filtre
      if (published !== undefined) {
        query = query.where({ published });
      }

      const posts = await query.orderBy('created_at', 'desc');

      return posts.map(p => ({
        ...p,
        createdAt: p.created_at,
        updatedAt: p.updated_at,
      }));
    },

    post: async (_: any, { id }: { id: string }, { db }: MyContext) => {
      const post = await db('posts')
        .where({ id: parseInt(id) })
        .first();

      if (!post) {
        throw new Error(`${id} ID'li post bulunamadı`);
      }

      return {
        ...post,
        createdAt: post.created_at,
        updatedAt: post.updated_at,
      };
    },
  },

  Mutation: {
    createPost: async (
      _: any,
      { title, content, authorId }: CreatePostArgs,
      { db }: MyContext
    ) => {
      // Author kontrolü
      const author = await db('users')
        .where({ id: parseInt(authorId) })
        .first();

      if (!author) {
        throw new Error('Belirtilen kullanıcı bulunamadı');
      }

      const [newPost] = await db('posts')
        .insert({
          title,
          content: content || null,
          author_id: parseInt(authorId),
          published: false,
        })
        .returning('*');

      return {
        ...newPost,
        createdAt: newPost.created_at,
        updatedAt: newPost.updated_at,
      };
    },

    publishPost: async (
      _: any,
      { id }: { id: string },
      { db }: MyContext
    ) => {
      const [updatedPost] = await db('posts')
        .where({ id: parseInt(id) })
        .update({
          published: true,
          updated_at: new Date(),
        })
        .returning('*');

      if (!updatedPost) {
        throw new Error(`${id} ID'li post bulunamadı`);
      }

      return {
        ...updatedPost,
        createdAt: updatedPost.created_at,
        updatedAt: updatedPost.updated_at,
      };
    },

    addComment: async (
      _: any,
      { postId, userId, body }: { postId: string; userId: string; body: string },
      { db }: MyContext
    ) => {
      const [comment] = await db('comments')
        .insert({
          body,
          post_id: parseInt(postId),
          user_id: parseInt(userId),
        })
        .returning('*');

      return { ...comment, createdAt: comment.created_at };
    },

    deletePost: async (
      _: any,
      { id }: { id: string },
      { db }: MyContext
    ) => {
      // Transaction ile sil: önce yorumlar, sonra post
      await db.transaction(async (trx) => {
        await trx('comments').where({ post_id: parseInt(id) }).delete();
        const deleted = await trx('posts').where({ id: parseInt(id) }).delete();
        if (!deleted) {
          throw new Error(`${id} ID'li post bulunamadı`);
        }
      });

      return true;
    },
  },

  Post: {
    author: async (parent: any, _: any, { db }: MyContext) => {
      const user = await db('users')
        .where({ id: parent.author_id })
        .first();
      return { ...user, createdAt: user.created_at };
    },

    comments: async (parent: any, _: any, { db }: MyContext) => {
      const comments = await db('comments')
        .where({ post_id: parent.id })
        .orderBy('created_at', 'asc');

      return comments.map(c => ({ ...c, createdAt: c.created_at }));
    },
  },

  Comment: {
    user: async (parent: any, _: any, { db }: MyContext) => {
      const user = await db('users').where({ id: parent.user_id }).first();
      return { ...user, createdAt: user.created_at };
    },

    post: async (parent: any, _: any, { db }: MyContext) => {
      const post = await db('posts').where({ id: parent.post_id }).first();
      return { ...post, createdAt: post.created_at, updatedAt: post.updated_at };
    },
  },
};
EOF

N+1 Problemini DataLoader ile Çözmek

Dikkatli baktıysan resolver’larda ciddi bir problem var: User.posts veya Post.author field resolver’ları her kayıt için ayrı SQL sorgusu çalıştırıyor. 100 post listelerken 100 ayrı author sorgusu gidiyor. Buna N+1 problemi deniyor ve production’da fark edilmeden sistemi çökertebilir.

DataLoader bu problemi batch’leyerek çözüyor:

npm install dataloader
cat > src/db/loaders.ts << 'EOF'
import DataLoader from 'dataloader';
import { Knex } from 'knex';

export function createLoaders(db: Knex) {
  return {
    // Kullanıcıları toplu yükle
    userLoader: new DataLoader<number, any>(async (userIds) => {
      const users = await db('users')
        .whereIn('id', userIds as number[]);
      
      // DataLoader, sonuçları ID sırasına göre eşleştirmek zorunda
      const userMap = new Map(users.map(u => [u.id, u]));
      return (userIds as number[]).map(id => userMap.get(id) || null);
    }),

    // Bir post'un yorumlarını toplu yükle
    commentsByPostLoader: new DataLoader<number, any[]>(async (postIds) => {
      const comments = await db('comments')
        .whereIn('post_id', postIds as number[])
        .orderBy('created_at', 'asc');

      const commentMap = new Map<number, any[]>();
      (postIds as number[]).forEach(id => commentMap.set(id, []));
      comments.forEach(c => {
        const list = commentMap.get(c.post_id) || [];
        list.push(c);
        commentMap.set(c.post_id, list);
      });

      return (postIds as number[]).map(id => commentMap.get(id) || []);
    }),
  };
}

export type Loaders = ReturnType<typeof createLoaders>;
EOF

Artık context’e loader’ları da ekleyip Post.author resolver’ını güncelliyoruz: context.loaders.userLoader.load(parent.author_id) şeklinde. Bu sayede 100 post için sadece 1 SQL sorgusu gidiyor.

Ana Server Dosyası

cat > src/index.ts << 'EOF'
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './graphql/typeDefs';
import { userResolvers } from './graphql/resolvers/userResolvers';
import { postResolvers } from './graphql/resolvers/postResolvers';
import { createContext } from './context';
import { testConnection } from './db/connection';
import dotenv from 'dotenv';

dotenv.config();

// Resolver'ları birleştir
const resolvers = {
  Query: {
    ...userResolvers.Query,
    ...postResolvers.Query,
  },
  Mutation: {
    ...userResolvers.Mutation,
    ...postResolvers.Mutation,
  },
  User: userResolvers.User,
  Post: postResolvers.Post,
  Comment: postResolvers.Comment,
};

async function startServer() {
  // Önce DB bağlantısını test et
  await testConnection();

  const server = new ApolloServer({
    typeDefs,
    resolvers,
    // Production'da introspection'ı kapat
    introspection: process.env.NODE_ENV !== 'production',
    formatError: (formattedError, error) => {
      // Hassas hata detaylarını gizle
      if (process.env.NODE_ENV === 'production') {
        return { message: formattedError.message };
      }
      return formattedError;
    },
  });

  const port = Number(process.env.PORT) || 4000;

  const { url } = await startStandaloneServer(server, {
    context: createContext,
    listen: { port },
  });

  console.log(`GraphQL server hazır: ${url}`);
}

startServer().catch((err) => {
  console.error('Server başlatılamadı:', err);
  process.exit(1);
});
EOF

package.json‘a script ekle:

npm pkg set scripts.dev="nodemon --exec ts-node src/index.ts"
npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node dist/index.js"

Server’ı başlat ve test et:

npm run dev

# Başka terminalde hızlı test
curl -X POST http://localhost:4000/ 
  -H "Content-Type: application/json" 
  -d '{"query":"{ posts(published: true) { id title author { username } } }"}'

Production’da Dikkat Edilmesi Gerekenler

Gerçek bir production ortamına alırken şunlara mutlaka bakman lazım:

  • Connection pool boyutu: pg_stat_activity sorgusuyla aktif bağlantıları izle, SELECT count(*) FROM pg_stat_activity WHERE datname='blog_db'; komutu durumu gösterir.
  • SSL zorunluluğu: Production PostgreSQL’de SSL olmadan bağlantı kabul etme. ssl: { rejectUnauthorized: true } ayarını devreye al.
  • Query zaman aşımı: Uzun süren sorgular için statement_timeout hem PostgreSQL seviyesinde hem Knex’te ayarlanmalı.
  • Graceful shutdown: Process sonlanırken pool bağlantılarını temizle, process.on('SIGTERM', () => db.destroy()) ekle.
  • Migration yönetimi: Knex’in built-in migration sistemi veya Flyway kullan, SQL şemalarını elle çalıştırma.
  • Error handling: Unhandled promise rejection’ları yakalamak için global handler ekle.
  • Health check endpoint: Load balancer için /health endpoint’i oluştur, hem server hem DB durumunu dönsün.

Sonuç

Apollo Server ile PostgreSQL entegrasyonu, başlangıçta karmaşık görünse de katmanlı bir yaklaşımla oldukça yönetilebilir hale geliyor. Özetlemek gerekirse:

  • Knex.js veritabanı sorgularını tip güvenli ve okunabilir tutuyor
  • Context katmanı veritabanı bağlantısını resolver’lara temiz bir şekilde taşıyor
  • DataLoader N+1 problemini çözerek gerçek anlamda production-ready bir yapı sağlıyor
  • Transaction desteği veri tutarlılığını garanti altına alıyor
  • Environment-based konfigürasyon farklı ortamlar arasında geçişi kolaylaştırıyor

Bir sonraki adımda GraphQL Subscriptions ile real-time özellikler eklemek veya Apollo Federation ile mikroservisler arasında schema birleştirmek mantıklı ilerleyiş noktaları. Ama önce bu temeli sağlam kur, sonra üstüne inşa et.

Bir yanıt yazın

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