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ünler3 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 alabilirdinKarar Matrisi
| Kriter | REST | GraphQL |
|---|---|---|
| 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=trueDizin Yapısı
src/main/resources/
├── graphql/
│ └── schema.graphqls ← Schema dosyası
├── application.propertiesSchema-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 listeID→ 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ı..graphqlde 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
@Controllerkullan,@RestControllerdeğil.@RestControllerresponse'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 sorgu10 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:
@BatchMappingmetodunun dönüş tipiMap<ParentType, ChildType>(tek ilişki) veyaMap<ParentType, List<ChildType>>(çoklu ilişki) olmalı. ParametreList<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
errorsarray'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=/graphiqlTarayı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
datahemerrorskontrol 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 —
.graphqlsdosyasında schema tanımla, Java'da@QueryMapping,@MutationMapping,@SchemaMappingile resolver yazN+1 problemi GraphQL'in en büyük tuzağı —
@BatchMappingile tüm ilişkileri tek sorguda çöz, her field için ayrı sorgu atmaError handling REST'ten farklı çalışır — HTTP 200 döner, hatalar
errorsarray'inde taşınır.DataFetcherExceptionResolverAdapterile global hata yönetimi yapSecurity field seviyesinde yapılabilir —
@PreAuthorizeile belirli field'ları role bazlı koru. Production'da GraphiQL ve introspection'ı kapatREST'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
AI Asistan
Sorularını yanıtlamaya hazır