← Kursa Dön
📄 Text · 25 min

GraphQL ile Spring Boot

Giriş — REST'in Sınırları

Bir mobil uygulama geliştiriyorsun. Ana sayfada kullanıcı profili, son siparişleri ve önerilen ürünleri göstermen gerekiyor. REST API ile:

GET /api/users/42                    → Kullanıcı bilgisi (ama adres, telefon, TC no da geliyor — lazım değil)
GET /api/users/42/orders?limit=5     → Son 5 sipariş (ama her siparişin tüm detayları geliyor)
GET /api/products/recommended        → Önerilen ürünler

3 ayrı istek. Üstelik her birinden ihtiyacından fazla veri geliyor (over-fetching). Ya da tersine, sipariş listesinde ürün adlarını görmek istiyorsun ama sipariş endpoint'i sadece productId döndürüyor — ürün adı için tekrar istek atman gerekiyor (under-fetching).

GraphQL bu iki problemi kökünden çözer: tam olarak ne istediğini belirtirsin, tam olarak o gelir. Tek istek, tek cevap.

# Tek bir GraphQL query ile her şeyi al
query {
  user(id: 42) {
    name
    email
    orders(last: 5) {
      id
      total
      items {
        product {
          name
          price
        }
      }
    }
    recommendedProducts(limit: 10) {
      name
      price
      imageUrl
    }
  }
}

Tek istek, sadece ihtiyacın olan field'lar, iç içe ilişkiler — hepsi bir arada.


REST vs GraphQL — Ne Zaman Hangisi?

Over-fetching ve Under-fetching

Over-fetching: API'den ihtiyacından fazla veri alırsın.

// GET /api/users/42 — sadece isim ve email lazım ama her şey geliyor
{
  "id": 42,
  "name": "Ahmet",
  "email": "ahmet@example.com",
  "phone": "+905551234567",     // gereksiz
  "address": "Istanbul, ...",    // gereksiz
  "tcKimlik": "123...",          // gereksiz ve tehlikeli
  "createdAt": "2024-...",       // gereksiz
  "preferences": { ... }         // gereksiz
}

Under-fetching: Bir endpoint yetmez, ikinci istek atman gerekir.

GET /api/orders/100       → { "productId": 55, ... }
GET /api/products/55      → { "name": "Laptop", ... }
// İki istek attın, tek istekle alabilirdin

Karar Matrisi

KriterRESTGraphQL
Basit CRUD✅ Doğal uyum❌ Overkill
Farklı client'lar (web, mobil, IoT)❌ Her client'a ayrı endpoint✅ Her client istediğini alır
İç içe ilişkiler❌ Çoklu istek veya custom endpoint✅ Tek query'de nested resolve
Dosya upload✅ Multipart doğal❌ Ek kütüphane gerekir
Cache✅ HTTP cache (ETag, 304)❌ Zor (POST her zaman)
Real-time❌ Polling veya WebSocket ayrı✅ Subscription built-in
Öğrenme eğrisi✅ Düşük❌ Schema, resolver, DataLoader...
Tooling✅ Swagger/OpenAPI yaygın✅ GraphiQL, introspection

Kısa kural: Basit, az ilişkili CRUD → REST. Karmaşık ilişkiler, farklı client'lar, esnek sorgulama → GraphQL.

💡 İpucu: REST ve GraphQL birlikte kullanılabilir. Aynı uygulamada basit CRUD endpoint'leri REST, karmaşık data-fetching senaryoları GraphQL ile sunabilirsin. İkisi birbirinin düşmanı değil.


Spring for GraphQL Kurulumu

Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>

<!-- Test için -->
<dependency>
    <groupId>org.springframework.graphql</groupId>
    <artifactId>spring-graphql-test</artifactId>
    <scope>test</scope>
</dependency>

<!-- Web desteği (zaten var muhtemelen) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Konfigürasyon

# application.properties

# GraphQL endpoint (varsayılan: /graphql)
spring.graphql.path=/graphql

# GraphiQL (interactive query builder) — sadece dev'de aç
spring.graphql.graphiql.enabled=true
spring.graphql.graphiql.path=/graphiql

# Schema dosyası lokasyonu (varsayılan: classpath:/graphql/**)
spring.graphql.schema.locations=classpath:graphql/

# Introspection (schema keşfi) — production'da kapat
spring.graphql.schema.introspection.enabled=true

Dizin Yapısı

src/main/resources/
├── graphql/
│   └── schema.graphqls       ← Schema dosyası
├── application.properties

Schema-First Approach

Spring for GraphQL schema-first yaklaşımı kullanır. Önce GraphQL schema'nı tanımlarsın (.graphqls dosyası), sonra Java'da resolver'ları yazarsın.

schema.graphqls — Type System

# src/main/resources/graphql/schema.graphqls

# === Temel Tipler ===

type Post {
    id: ID!
    title: String!
    content: String!
    slug: String!
    published: Boolean!
    createdAt: String!
    updatedAt: String
    author: Author!           # İlişki — lazy resolve edilecek
    comments: [Comment!]!     # İlişki — liste
    commentCount: Int!        # Computed field
}

type Author {
    id: ID!
    name: String!
    email: String!
    bio: String
    posts: [Post!]!           # İlişki — bir yazarın tüm postları
    postCount: Int!
}

type Comment {
    id: ID!
    content: String!
    createdAt: String!
    author: Author!
    post: Post!
}

# === Query (okuma işlemleri) ===

type Query {
    # Tek post getir
    post(id: ID!): Post

    # Slug ile post getir
    postBySlug(slug: String!): Post

    # Tüm postları listele (pagination ile)
    posts(page: Int = 0, size: Int = 10): PostPage!

    # Yazar getir
    author(id: ID!): Author

    # Arama
    searchPosts(keyword: String!): [Post!]!
}

# === Mutation (yazma işlemleri) ===

type Mutation {
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post!
    deletePost(id: ID!): Boolean!
    createComment(postId: ID!, input: CreateCommentInput!): Comment!
}

# === Subscription (real-time) ===

type Subscription {
    commentAdded(postId: ID!): Comment!
}

# === Input Tipleri ===

input CreatePostInput {
    title: String!
    content: String!
    authorId: ID!
}

input UpdatePostInput {
    title: String
    content: String
    published: Boolean
}

input CreateCommentInput {
    content: String!
    authorId: ID!
}

# === Pagination ===

type PostPage {
    content: [Post!]!
    totalElements: Int!
    totalPages: Int!
    currentPage: Int!
    hasNext: Boolean!
    hasPrevious: Boolean!
}

Schema dosyasının kuralları:

  • ! → non-null (zorunlu)

  • [Post!]! → null olmayan, null eleman içermeyen liste

  • ID → benzersiz tanımlayıcı (String olarak serialize edilir)

  • input → mutation parametreleri için (type'dan farklı)

  • Query, Mutation, Subscription → özel root tipler

⚠️ Dikkat: Schema dosyasının adı .graphqls (s harfi ile) olmalı. .graphql de kabul edilir ama Spring Boot convention'ı .graphqls.


@QueryMapping — Query Resolver

Schema'daki Query tipinin her field'ı için bir Java metodu yazarsın:

@Controller  // @RestController değil, @Controller!
public class PostController {

    private final PostService postService;

    public PostController(PostService postService) {
        this.postService = postService;
    }

    // Query.post(id: ID!): Post
    @QueryMapping
    public Post post(@Argument Long id) {
        return postService.findById(id)
            .orElse(null);  // null dönerse GraphQL'de null olarak gelir
    }

    // Query.postBySlug(slug: String!): Post
    @QueryMapping
    public Post postBySlug(@Argument String slug) {
        return postService.findBySlug(slug)
            .orElseThrow(() -> new PostNotFoundException(slug));
    }

    // Query.posts(page: Int, size: Int): PostPage!
    @QueryMapping
    public PostPage posts(@Argument int page, @Argument int size) {
        Page<Post> postPage = postService.findAll(PageRequest.of(page, size));

        return new PostPage(
            postPage.getContent(),
            postPage.getTotalElements(),
            postPage.getTotalPages(),
            postPage.getNumber(),
            postPage.hasNext(),
            postPage.hasPrevious()
        );
    }

    // Query.searchPosts(keyword: String!): [Post!]!
    @QueryMapping
    public List<Post> searchPosts(@Argument String keyword) {
        return postService.search(keyword);
    }
}
@Controller
public class AuthorController {

    private final AuthorService authorService;

    public AuthorController(AuthorService authorService) {
        this.authorService = authorService;
    }

    // Query.author(id: ID!): Author
    @QueryMapping
    public Author author(@Argument Long id) {
        return authorService.findById(id)
            .orElseThrow(() -> new AuthorNotFoundException(id));
    }
}

💡 İpucu: GraphQL controller'larda @Controller kullan, @RestController değil. @RestController response'u JSON olarak serialize eder, ama GraphQL kendi serialization'ını yapar.

Metot Adı = Schema Field Adı

Spring for GraphQL, metot adını schema'daki field adıyla eşleştirir:

  • @QueryMapping + public Post post(...)Query.post

  • @QueryMapping + public List<Post> searchPosts(...)Query.searchPosts

Farklı isim kullanmak istersen:

@QueryMapping(name = "post")
public Post getPostById(@Argument Long id) {
    return postService.findById(id).orElse(null);
}

@MutationMapping — Create, Update, Delete

@Controller
public class PostMutationController {

    private final PostService postService;

    public PostMutationController(PostService postService) {
        this.postService = postService;
    }

    // Mutation.createPost(input: CreatePostInput!): Post!
    @MutationMapping
    public Post createPost(@Argument CreatePostInput input) {
        return postService.create(input);
    }

    // Mutation.updatePost(id: ID!, input: UpdatePostInput!): Post!
    @MutationMapping
    public Post updatePost(@Argument Long id, @Argument UpdatePostInput input) {
        return postService.update(id, input);
    }

    // Mutation.deletePost(id: ID!): Boolean!
    @MutationMapping
    public boolean deletePost(@Argument Long id) {
        return postService.delete(id);
    }
}

Input DTO'ları

// CreatePostInput — schema'daki input tipine karşılık gelir
public record CreatePostInput(
    String title,
    String content,
    Long authorId
) {}

public record UpdatePostInput(
    String title,
    String content,
    Boolean published
) {}

public record CreateCommentInput(
    String content,
    Long authorId
) {}

Service Katmanı

@Service
@Transactional
public class PostService {

    private final PostRepository postRepository;
    private final AuthorRepository authorRepository;

    public PostService(PostRepository postRepository, AuthorRepository authorRepository) {
        this.postRepository = postRepository;
        this.authorRepository = authorRepository;
    }

    public Post create(CreatePostInput input) {
        Author author = authorRepository.findById(input.authorId())
            .orElseThrow(() -> new AuthorNotFoundException(input.authorId()));

        Post post = new Post();
        post.setTitle(input.title());
        post.setContent(input.content());
        post.setSlug(slugify(input.title()));
        post.setAuthor(author);
        post.setPublished(false);
        post.setCreatedAt(LocalDateTime.now());

        return postRepository.save(post);
    }

    public Post update(Long id, UpdatePostInput input) {
        Post post = postRepository.findById(id)
            .orElseThrow(() -> new PostNotFoundException(id));

        if (input.title() != null) {
            post.setTitle(input.title());
            post.setSlug(slugify(input.title()));
        }
        if (input.content() != null) {
            post.setContent(input.content());
        }
        if (input.published() != null) {
            post.setPublished(input.published());
        }
        post.setUpdatedAt(LocalDateTime.now());

        return postRepository.save(post);
    }

    public boolean delete(Long id) {
        if (!postRepository.existsById(id)) {
            throw new PostNotFoundException(id);
        }
        postRepository.deleteById(id);
        return true;
    }
}

GraphQL İle Kullanım

# Post oluştur
mutation {
  createPost(input: {
    title: "Spring Boot ile GraphQL"
    content: "Bu yazıda GraphQL'i Spring Boot ile..."
    authorId: "1"
  }) {
    id
    title
    slug
    createdAt
    author {
      name
    }
  }
}

# Post güncelle
mutation {
  updatePost(id: "5", input: {
    published: true
  }) {
    id
    title
    published
    updatedAt
  }
}

# Post sil
mutation {
  deletePost(id: "5")
}

@SchemaMapping — Nested Field Resolver

Schema'daki ilişkisel field'lar (Post.author, Post.comments, Author.posts) için resolver yazarsın. Bu resolver'lar sadece o field istendiğinde çalışır — lazy loading gibi.

@Controller
public class PostFieldResolver {

    private final AuthorService authorService;
    private final CommentService commentService;

    public PostFieldResolver(AuthorService authorService, CommentService commentService) {
        this.authorService = authorService;
        this.commentService = commentService;
    }

    // Post.author: Author! — sadece author field'ı istendiğinde çalışır
    @SchemaMapping(typeName = "Post", field = "author")
    public Author author(Post post) {
        return authorService.findById(post.getAuthorId())
            .orElseThrow();
    }

    // Post.comments: [Comment!]! — sadece comments istendiğinde çalışır
    @SchemaMapping(typeName = "Post", field = "comments")
    public List<Comment> comments(Post post) {
        return commentService.findByPostId(post.getId());
    }

    // Post.commentCount: Int! — computed field
    @SchemaMapping(typeName = "Post", field = "commentCount")
    public int commentCount(Post post) {
        return commentService.countByPostId(post.getId());
    }
}

@Controller
public class AuthorFieldResolver {

    private final PostService postService;

    public AuthorFieldResolver(PostService postService) {
        this.postService = postService;
    }

    // Author.posts: [Post!]!
    @SchemaMapping(typeName = "Author", field = "posts")
    public List<Post> posts(Author author) {
        return postService.findByAuthorId(author.getId());
    }

    // Author.postCount: Int!
    @SchemaMapping(typeName = "Author", field = "postCount")
    public int postCount(Author author) {
        return postService.countByAuthorId(author.getId());
    }
}

Kısa yol: @SchemaMapping yerine metot adı ve parametre tipi ile otomatik eşleştirme:

@Controller
public class PostFieldResolver {

    private final AuthorService authorService;

    // typeName = parametre tipi (Post), field = metot adı (author)
    @SchemaMapping
    public Author author(Post post) {
        return authorService.findById(post.getAuthorId()).orElseThrow();
    }
}

Query Örneği — Nested Resolve

query {
  post(id: "1") {
    title           # Post resolver'dan
    author {        # SchemaMapping: Post.author
      name          # Author field'ı
      postCount     # SchemaMapping: Author.postCount
    }
    comments {      # SchemaMapping: Post.comments
      content
      author {      # SchemaMapping: Comment.author
        name
      }
    }
    commentCount    # SchemaMapping: Post.commentCount
  }
}

Her nested field sadece istendiğinde resolve edilir. author istenmezse authorService.findById() hiç çağrılmaz.


N+1 Problemi ve @BatchMapping

Problem

10 post listeliyorsun. Her post'un yazarını da istiyorsun:

query {
  posts(page: 0, size: 10) {
    content {
      title
      author {    # Her post için ayrı sorgu → N+1!
        name
      }
    }
  }
}

@SchemaMapping ile her post için ayrı authorService.findById() çağrılır:

SELECT * FROM posts LIMIT 10;           -- 1 sorgu
SELECT * FROM authors WHERE id = 1;     -- +1
SELECT * FROM authors WHERE id = 2;     -- +1
SELECT * FROM authors WHERE id = 1;     -- +1 (aynı yazar, tekrar!)
SELECT * FROM authors WHERE id = 3;     -- +1
...                                     -- Toplam: 1 + 10 = 11 sorgu

10 post için 11 sorgu. 100 post için 101 sorgu. Bu N+1 problemi.

Çözüm: @BatchMapping

@Controller
public class PostFieldResolver {

    private final AuthorService authorService;

    public PostFieldResolver(AuthorService authorService) {
        this.authorService = authorService;
    }

    // @SchemaMapping yerine @BatchMapping kullan
    // Tüm post'ların author'larını TEK SEFERDE çeker
    @BatchMapping(typeName = "Post", field = "author")
    public Map<Post, Author> author(List<Post> posts) {
        // Benzersiz author ID'lerini topla
        Set<Long> authorIds = posts.stream()
            .map(Post::getAuthorId)
            .collect(Collectors.toSet());

        // TEK sorgu ile tüm author'ları çek
        Map<Long, Author> authorMap = authorService.findByIds(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        // Her post'u kendi author'ı ile eşleştir
        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                post -> authorMap.get(post.getAuthorId())
            ));
    }

    // Comments için batch
    @BatchMapping(typeName = "Post", field = "comments")
    public Map<Post, List<Comment>> comments(List<Post> posts) {
        List<Long> postIds = posts.stream()
            .map(Post::getId)
            .toList();

        // TEK sorgu ile tüm comment'leri çek
        List<Comment> allComments = commentService.findByPostIds(postIds);

        // Post'a göre grupla
        Map<Long, List<Comment>> commentsByPostId = allComments.stream()
            .collect(Collectors.groupingBy(Comment::getPostId));

        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                post -> commentsByPostId.getOrDefault(post.getId(), List.of())
            ));
    }
}

Artık:

SELECT * FROM posts LIMIT 10;                        -- 1 sorgu
SELECT * FROM authors WHERE id IN (1, 2, 3);         -- 1 sorgu (batch)
SELECT * FROM comments WHERE post_id IN (1,2,...,10); -- 1 sorgu (batch)
-- Toplam: 3 sorgu (10 yerine!)

⚠️ Dikkat: @BatchMapping metodunun dönüş tipi Map<ParentType, ChildType> (tek ilişki) veya Map<ParentType, List<ChildType>> (çoklu ilişki) olmalı. Parametre List<ParentType> — Spring tüm parent'ları toplar ve tek çağrıda gönderir.

Repository Desteği

public interface AuthorRepository extends JpaRepository<Author, Long> {
    // IN query ile batch fetch
    List<Author> findByIdIn(Collection<Long> ids);
}

public interface CommentRepository extends JpaRepository<Comment, Long> {
    List<Comment> findByPostIdIn(Collection<Long> postIds);
}

Connection-Based Pagination

GraphQL dünyasında sayfalama genellikle cursor-based (connection pattern) ile yapılır. Bu, Relay specification'ından gelen bir standarttır:

# Schema'ya ekle
type Query {
    postsConnection(first: Int, after: String, last: Int, before: String): PostConnection!
}

type PostConnection {
    edges: [PostEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
}

type PostEdge {
    node: Post!
    cursor: String!
}

type PageInfo {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
    startCursor: String
    endCursor: String
}
@Controller
public class PostConnectionController {

    private final PostService postService;

    @QueryMapping
    public PostConnection postsConnection(
            @Argument Integer first,
            @Argument String after,
            @Argument Integer last,
            @Argument String before) {

        int limit = (first != null) ? first : (last != null) ? last : 10;
        Long cursorId = (after != null) ? decodeCursor(after) : null;

        List<Post> posts;
        boolean hasNext;
        boolean hasPrevious;

        if (cursorId != null) {
            // Cursor'dan sonraki kayıtlar
            posts = postService.findAfterId(cursorId, limit + 1);
            hasPrevious = true;
            hasNext = posts.size() > limit;
            if (hasNext) posts = posts.subList(0, limit);
        } else {
            posts = postService.findAll(limit + 1);
            hasPrevious = false;
            hasNext = posts.size() > limit;
            if (hasNext) posts = posts.subList(0, limit);
        }

        List<PostEdge> edges = posts.stream()
            .map(post -> new PostEdge(post, encodeCursor(post.getId())))
            .toList();

        PageInfo pageInfo = new PageInfo(
            hasNext,
            hasPrevious,
            edges.isEmpty() ? null : edges.get(0).cursor(),
            edges.isEmpty() ? null : edges.get(edges.size() - 1).cursor()
        );

        long totalCount = postService.count();

        return new PostConnection(edges, pageInfo, totalCount);
    }

    private String encodeCursor(Long id) {
        return Base64.getEncoder().encodeToString(("cursor:" + id).getBytes());
    }

    private Long decodeCursor(String cursor) {
        String decoded = new String(Base64.getDecoder().decode(cursor));
        return Long.parseLong(decoded.replace("cursor:", ""));
    }
}
# Kullanım
query {
  postsConnection(first: 5) {
    edges {
      node {
        title
        author { name }
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

# Sonraki sayfa
query {
  postsConnection(first: 5, after: "Y3Vyc29yOjU=") {
    edges {
      node { title }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

💡 İpucu: Basit projeler için offset-based pagination (page/size) yeterlidir. Connection pattern daha çok büyük veri setleri ve infinite scroll senaryolarında kullanılır. GraphQL standartlarına uygunluk istiyorsan connection pattern tercih et.


Error Handling

GraphQLError ile Özel Hatalar

// Custom exception
public class PostNotFoundException extends RuntimeException {
    private final Long postId;

    public PostNotFoundException(Long postId) {
        super("Post not found: " + postId);
        this.postId = postId;
    }

    public Long getPostId() { return postId; }
}
// Global exception handler
@Component
public class GraphQLExceptionHandler implements DataFetcherExceptionResolverAdapter {

    @Override
    protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {

        if (ex instanceof PostNotFoundException e) {
            return GraphqlErrorBuilder.newError(env)
                .message("Post bulunamadı: " + e.getPostId())
                .errorType(ErrorType.NOT_FOUND)
                .extensions(Map.of("postId", e.getPostId()))
                .build();
        }

        if (ex instanceof AccessDeniedException) {
            return GraphqlErrorBuilder.newError(env)
                .message("Bu işlem için yetkiniz yok")
                .errorType(ErrorType.FORBIDDEN)
                .build();
        }

        if (ex instanceof ConstraintViolationException e) {
            return GraphqlErrorBuilder.newError(env)
                .message("Doğrulama hatası: " + e.getMessage())
                .errorType(ErrorType.BAD_REQUEST)
                .build();
        }

        // Bilinmeyen hatalar — detay verme, güvenlik riski
        return GraphqlErrorBuilder.newError(env)
            .message("Beklenmeyen bir hata oluştu")
            .errorType(ErrorType.INTERNAL_ERROR)
            .build();
    }
}

GraphQL hata yanıtı:

{
  "data": {
    "post": null
  },
  "errors": [
    {
      "message": "Post bulunamadı: 999",
      "locations": [{"line": 2, "column": 3}],
      "path": ["post"],
      "extensions": {
        "classification": "NOT_FOUND",
        "postId": 999
      }
    }
  ]
}

⚠️ Dikkat: GraphQL'de HTTP status kodu her zaman 200'dür (partial data + errors). Hata olsa bile 200 döner. Hata bilgisi errors array'inde taşınır. Bu, REST'ten farklı bir yaklaşımdır — client tarafında buna göre handle etmelisin.


Input Validation

Spring'in @Valid annotation'ı GraphQL mutation'larında da çalışır:

// Input DTO — validation annotation'ları ile
public record CreatePostInput(
    @NotBlank(message = "Başlık boş olamaz")
    @Size(min = 3, max = 200, message = "Başlık 3-200 karakter olmalı")
    String title,

    @NotBlank(message = "İçerik boş olamaz")
    @Size(min = 10, message = "İçerik en az 10 karakter olmalı")
    String content,

    @NotNull(message = "Yazar ID zorunlu")
    Long authorId
) {}
@Controller
public class PostMutationController {

    @MutationMapping
    public Post createPost(@Argument @Valid CreatePostInput input) {
        // Validation geçerse buraya gelir
        return postService.create(input);
    }
}

Validation hatası response'u:

{
  "data": { "createPost": null },
  "errors": [
    {
      "message": "Başlık boş olamaz",
      "extensions": { "classification": "BAD_REQUEST" }
    }
  ]
}

GraphiQL — Interactive Query Builder

GraphiQL, GraphQL API'ni tarayıcıda test etmeni sağlayan interaktif bir araçtır. Schema'yı otomatik keşfeder, otomatik tamamlama yapar ve sonuçları gösterir.

# application.properties — sadece dev ortamında aç
spring.graphql.graphiql.enabled=true
spring.graphql.graphiql.path=/graphiql

Tarayıcıda http://localhost:8080/graphiql adresine git. Sol tarafta query yaz, ortadaki çalıştır butonuna bas, sağ tarafta sonucu gör.

# GraphiQL'de dene
query {
  posts(page: 0, size: 3) {
    content {
      title
      author {
        name
      }
      commentCount
    }
    totalElements
    hasNext
  }
}

Schema sekmesinde tüm type'ları, query'leri ve mutation'ları görebilirsin. Otomatik tamamlama (Ctrl+Space) ile field adlarını ve parametreleri keşfedebilirsin.

⚠️ Dikkat: Production'da GraphiQL'i kapat. spring.graphql.graphiql.enabled=false. Ayrıca introspection'ı da kapatmayı düşün: spring.graphql.schema.introspection.enabled=false. Aksi halde herkes API schema'nı keşfedebilir.


Security — Field-Level Güvenlik

Spring Security ile GraphQL field'larını koruyabilirsin:

@Controller
public class AuthorFieldResolver {

    // Herkes görebilir
    @SchemaMapping
    public String name(Author author) {
        return author.getName();
    }

    // Sadece admin görebilir
    @PreAuthorize("hasRole('ADMIN')")
    @SchemaMapping(typeName = "Author", field = "email")
    public String email(Author author) {
        return author.getEmail();
    }

    // Kullanıcı kendi bilgilerini görebilir
    @PreAuthorize("#author.id == authentication.principal.id or hasRole('ADMIN')")
    @SchemaMapping(typeName = "Author", field = "bio")
    public String bio(Author author) {
        return author.getBio();
    }
}
// Mutation güvenliği
@Controller
public class PostMutationController {

    @PreAuthorize("isAuthenticated()")
    @MutationMapping
    public Post createPost(@Argument @Valid CreatePostInput input) {
        return postService.create(input);
    }

    @PreAuthorize("hasRole('ADMIN') or @postService.isOwner(#id, authentication.principal.id)")
    @MutationMapping
    public boolean deletePost(@Argument Long id) {
        return postService.delete(id);
    }
}
// Security konfigürasyonu
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/graphql").permitAll()  // Auth, GraphQL seviyesinde
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

Yetkisiz erişim response'u:

{
  "data": {
    "author": {
      "name": "Ahmet",
      "email": null       // Yetkisiz — null döner
    }
  },
  "errors": [
    {
      "message": "Bu işlem için yetkiniz yok",
      "path": ["author", "email"],
      "extensions": { "classification": "FORBIDDEN" }
    }
  ]
}

💡 İpucu: GraphQL'de partial data + errors döner. Yani bir query'nin bazı field'ları başarılı, bazıları hatalı (yetkisiz) olabilir. Client tarafında hem data hem errors kontrol et.


Subscription — Real-Time GraphQL

WebSocket üzerinden real-time veri akışı:

<!-- WebSocket desteği -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
@Controller
public class CommentSubscriptionController {

    @SubscriptionMapping
    public Flux<Comment> commentAdded(@Argument Long postId) {
        return commentEventPublisher.getCommentStream()
            .filter(comment -> comment.getPostId().equals(postId));
    }
}
@Component
public class CommentEventPublisher {

    private final Sinks.Many<Comment> sink = Sinks.many().multicast().onBackpressureBuffer();

    public void publish(Comment comment) {
        sink.tryEmitNext(comment);
    }

    public Flux<Comment> getCommentStream() {
        return sink.asFlux();
    }
}
// Comment oluşturulduğunda event yayınla
@Service
public class CommentService {

    private final CommentRepository commentRepository;
    private final CommentEventPublisher eventPublisher;

    @Transactional
    public Comment createComment(Long postId, CreateCommentInput input) {
        Comment comment = new Comment();
        comment.setPostId(postId);
        comment.setContent(input.content());
        comment.setAuthorId(input.authorId());
        comment.setCreatedAt(LocalDateTime.now());

        Comment saved = commentRepository.save(comment);

        // Real-time event yayınla
        eventPublisher.publish(saved);

        return saved;
    }
}

Client tarafında subscription:

subscription {
  commentAdded(postId: "1") {
    id
    content
    createdAt
    author {
      name
    }
  }
}

⚠️ Dikkat: Subscription, WebSocket bağlantısı gerektirir. Her subscription aktif bir bağlantı tutar. Çok sayıda subscription varsa memory ve bağlantı yönetimine dikkat et. Production'da bağlantı limiti, heartbeat ve timeout ayarlarını yapılandır.


Gerçek Dünya: Blog API — Tam Örnek

Tüm kavramları bir araya getiren eksiksiz bir Blog API:

Entity'ler

@Entity
@Table(name = "posts")
@Getter @Setter @NoArgsConstructor
public class Post {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    @Column(unique = true, nullable = false)
    private String slug;

    private boolean published;

    @Column(name = "author_id", nullable = false)
    private Long authorId;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        slug = title.toLowerCase()
            .replaceAll("[^a-z0-9\\s-]", "")
            .replaceAll("\\s+", "-");
    }
}

@Entity
@Table(name = "authors")
@Getter @Setter @NoArgsConstructor
public class Author {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
    private String bio;
}

@Entity
@Table(name = "comments")
@Getter @Setter @NoArgsConstructor
public class Comment {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    @Column(name = "post_id", nullable = false)
    private Long postId;

    @Column(name = "author_id", nullable = false)
    private Long authorId;

    private LocalDateTime createdAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }
}

Query + Mutation Controller

@Controller
@Slf4j
public class BlogGraphQLController {

    private final PostService postService;
    private final AuthorService authorService;
    private final CommentService commentService;

    public BlogGraphQLController(PostService postService,
                                  AuthorService authorService,
                                  CommentService commentService) {
        this.postService = postService;
        this.authorService = authorService;
        this.commentService = commentService;
    }

    // === Queries ===

    @QueryMapping
    public Post post(@Argument Long id) {
        log.debug("GraphQL query: post(id={})", id);
        return postService.findById(id).orElse(null);
    }

    @QueryMapping
    public PostPage posts(@Argument int page, @Argument int size) {
        log.debug("GraphQL query: posts(page={}, size={})", page, size);
        return postService.findAllPaged(page, size);
    }

    @QueryMapping
    public Author author(@Argument Long id) {
        return authorService.findById(id).orElse(null);
    }

    @QueryMapping
    public List<Post> searchPosts(@Argument String keyword) {
        return postService.search(keyword);
    }

    // === Mutations ===

    @MutationMapping
    public Post createPost(@Argument @Valid CreatePostInput input) {
        log.info("GraphQL mutation: createPost(title={})", input.title());
        return postService.create(input);
    }

    @MutationMapping
    public Post updatePost(@Argument Long id, @Argument UpdatePostInput input) {
        log.info("GraphQL mutation: updatePost(id={})", id);
        return postService.update(id, input);
    }

    @MutationMapping
    public boolean deletePost(@Argument Long id) {
        log.info("GraphQL mutation: deletePost(id={})", id);
        return postService.delete(id);
    }

    @MutationMapping
    public Comment createComment(@Argument Long postId,
                                  @Argument @Valid CreateCommentInput input) {
        log.info("GraphQL mutation: createComment(postId={})", postId);
        return commentService.create(postId, input);
    }

    // === Nested Resolvers (Batch) ===

    @BatchMapping(typeName = "Post", field = "author")
    public Map<Post, Author> postAuthors(List<Post> posts) {
        Set<Long> authorIds = posts.stream()
            .map(Post::getAuthorId)
            .collect(Collectors.toSet());

        Map<Long, Author> authorMap = authorService.findByIds(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                post -> authorMap.get(post.getAuthorId())
            ));
    }

    @BatchMapping(typeName = "Post", field = "comments")
    public Map<Post, List<Comment>> postComments(List<Post> posts) {
        List<Long> postIds = posts.stream().map(Post::getId).toList();

        Map<Long, List<Comment>> grouped = commentService.findByPostIds(postIds)
            .stream()
            .collect(Collectors.groupingBy(Comment::getPostId));

        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                post -> grouped.getOrDefault(post.getId(), List.of())
            ));
    }

    @BatchMapping(typeName = "Post", field = "commentCount")
    public Map<Post, Integer> commentCounts(List<Post> posts) {
        List<Long> postIds = posts.stream().map(Post::getId).toList();

        Map<Long, Long> counts = commentService.countByPostIds(postIds);

        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                post -> counts.getOrDefault(post.getId(), 0L).intValue()
            ));
    }

    @BatchMapping(typeName = "Comment", field = "author")
    public Map<Comment, Author> commentAuthors(List<Comment> comments) {
        Set<Long> authorIds = comments.stream()
            .map(Comment::getAuthorId)
            .collect(Collectors.toSet());

        Map<Long, Author> authorMap = authorService.findByIds(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return comments.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                comment -> authorMap.get(comment.getAuthorId())
            ));
    }
}

Tam Bir Query Senaryosu

# Blog ana sayfası — tek query'de her şeyi al
query BlogHomePage {
  posts(page: 0, size: 5) {
    content {
      id
      title
      slug
      createdAt
      author {
        name
      }
      commentCount
    }
    totalElements
    totalPages
    hasNext
  }
}

# Post detay sayfası — yorumlar dahil
query PostDetail($slug: String!) {
  postBySlug(slug: $slug) {
    title
    content
    createdAt
    updatedAt
    author {
      name
      bio
      postCount
    }
    comments {
      content
      createdAt
      author {
        name
      }
    }
    commentCount
  }
}

# Yorum ekleme
mutation AddComment($postId: ID!, $content: String!, $authorId: ID!) {
  createComment(postId: $postId, input: {
    content: $content,
    authorId: $authorId
  }) {
    id
    content
    createdAt
    author {
      name
    }
  }
}

Bu tek query'de: 5 post, her post'un yazarı, yorum sayısı, toplam sayfa bilgisi — hepsi tek HTTP isteği ile geliyor. REST ile 5 + 5 + 5 = 15 istek atman gerekirdi.


Özet

  • GraphQL, over-fetching ve under-fetching problemlerini çözer — client tam olarak istediği veriyi alır, fazlasını almaz

  • Spring for GraphQL schema-first yaklaşım kullanır — .graphqls dosyasında schema tanımla, Java'da @QueryMapping, @MutationMapping, @SchemaMapping ile resolver yaz

  • N+1 problemi GraphQL'in en büyük tuzağı — @BatchMapping ile tüm ilişkileri tek sorguda çöz, her field için ayrı sorgu atma

  • Error handling REST'ten farklı çalışır — HTTP 200 döner, hatalar errors array'inde taşınır. DataFetcherExceptionResolverAdapter ile global hata yönetimi yap

  • Security field seviyesinde yapılabilir — @PreAuthorize ile belirli field'ları role bazlı koru. Production'da GraphiQL ve introspection'ı kapat

  • REST'in yerini almaz, tamamlar — basit CRUD için REST, karmaşık ilişkiler ve esnek sorgulama için GraphQL kullan. İkisi aynı uygulamada birlikte yaşayabilir