GraphQL Şema Evrimi: Geriye Uyumlu API Değişiklikleri Nasıl Yapılır
Üretim ortamında çalışan bir GraphQL API’sini geliştirmek, REST’e kıyasla çok daha fazla disiplin gerektiriyor. Neden mi? Çünkü GraphQL’de istemciler tam olarak neye ihtiyaç duyduklarını sorguda belirtiyorlar ve siz schema’nızı dikkatsizce değiştirdiğinizde, o sorguları yazan tüm ekipler anında etkileniyor. Bunu ilk kez üretimde yaşadığınızda, “breaking change nedir?” sorusunun cevabını çok acı bir şekilde öğreniyorsunuz.
Bu yazıda sıfırdan başlayarak schema evolution’ı, yani schema’nızı geriye uyumlu şekilde nasıl büyüteceğinizi anlatacağım. Örnekler gerçek projelerden alınmış, teorik değil.
Breaking Change Nedir, Neden Bu Kadar Önemlidir?
GraphQL’in en büyük avantajlarından biri, istemcilerin ihtiyaç duydukları veriyi tam olarak tanımlayabilmeleri. Ama bu avantaj, schema değişikliklerinin etkisini de maksimize ediyor.
Şu senaryoyu düşünün: Bir e-ticaret uygulamasında mobil ekip, web ekibi ve üçüncü parti bir entegrasyon ortağınız var. Hepsi aynı GraphQL endpoint’ini kullanıyor. Siz User tipindeki fullName alanını displayName olarak rename ettiğinizde, bu üç ekibin tüm sorgularını aynı anda kırıyorsunuz.
REST’te versiyonlama nispeten kolaydı: /v1/users, /v2/users. GraphQL’de ise bu yaklaşım hem anti-pattern hem de pratik değil. Bunun yerine schema’yı kontrollü şekilde evriltmek gerekiyor.
Temel Breaking Change Kategorileri
Önce neyin breaking change olduğunu netleştirelim:
Type-level breaking changes:
- Bir type’ı tamamen silmek
- Bir type’ın adını değiştirmek
- Bir type’ı başka bir type ile replace etmek
Field-level breaking changes:
- Var olan bir field’ı silmek
- Bir field’ın adını değiştirmek
- Bir field’ın tipini değiştirmek (örneğin
String‘denInt‘e) - Non-nullable olmayan bir field’ı nullable yapmak (aslında bu geriye uyumlu olabilir, dikkat)
- Nullable olan bir field’ı non-nullable yapmak (bu kesinlikle breaking)
Argument-level breaking changes:
- Var olan bir argümanı silmek
- Zorunlu yeni bir argüman eklemek
- Var olan bir argümanın tipini değiştirmek
Bunların hiçbirini yapmayacak mısınız? Tabii ki yapacaksınız. Ama nasıl yaptığınız her şeyi değiştiriyor.
Deprecation: En İyi Silahınız
GraphQL, built-in deprecation mekanizmasıyla geliyor. Bu mekanizmayı doğru kullanmak schema evolution’ın temelidir.
type User {
id: ID!
email: String!
fullName: String @deprecated(reason: "Use displayName instead. Will be removed after 2024-06-01.")
displayName: String!
firstName: String
lastName: String
}
Bir field’ı deprecate ettiğinizde:
- Mevcut sorgular çalışmaya devam eder
- GraphQL Playground ve introspection araçları field’ı deprecated olarak işaretler
- İstemci ekipler geçiş için zaman kazanır
Ama deprecation’ı işaret koyup unutmak değil, aktif olarak yönetmek gerekiyor. Ben projelerimde her deprecated field için bir JIRA ticket açar, removal tarihini schema’ya yazarım, ve bu tarihe yaklaşırken usage analytics’e bakarım.
Additive Changes: Her Zaman Güvenli
En güvenli evolution stratejisi, additive changes kullanmak. Yani var olana dokunmadan yeni şeyler eklemek.
# Önceki schema
type Product {
id: ID!
name: String!
price: Float!
}
# Sonraki schema - yeni field'lar eklendi, eskiler korundu
type Product {
id: ID!
name: String!
price: Float!
discountedPrice: Float
currency: String
inventory: ProductInventory
}
type ProductInventory {
available: Int!
reserved: Int
warehouse: String
}
Bu değişiklik tamamen güvenli. Mevcut sorgular etkilenmiyor, yeni sorgular yeni field’ları kullanabiliyor.
Peki ya zorunlu bir field eklemek istiyorsanız? Hiçbir zaman direkt olarak non-nullable field eklemeyin. Önce nullable olarak ekleyin, migration tamamlandıktan sonra non-nullable yapabilirsiniz (bu bile dikkat ister).
Alan Adı Değişiklikleri: Alias Pattern
Bir field’ın adını değiştirmek zorunda kaldığınızda, alias pattern kullanın. Bu pattern özellikle resolver katmanında uygulanır.
// Apollo Server örneği
const resolvers = {
User: {
// Eski field - hala çalışıyor
fullName: (parent) => {
return `${parent.firstName} ${parent.lastName}`;
},
// Yeni field - aynı veriyi döndürüyor
displayName: (parent) => {
return `${parent.firstName} ${parent.lastName}`;
},
},
};
Schema tarafında:
type User {
id: ID!
email: String!
# Eski - deprecate edildi
fullName: String @deprecated(reason: "Use displayName. Removal: 2024-09-01")
# Yeni
displayName: String!
}
Her iki field da çalışıyor, aynı veriyi döndürüyor. İstemciler kendi zamanlamalarında geçiş yapıyor. Siz analytics’ten hangi istemcilerin hala eski field’ı kullandığını görüp onları uyarıyorsunuz.
Tip Değişiklikleri: En Riskli Alan
String‘i Int yapamazsınız, Float‘ı String yapamazsınız. Bunlar kesin breaking change. Ama çözüm yolu var.
Gerçek hayat örneği: Bir projede orderId başlangıçta Int olarak tanımlanmıştı. Sistem büyüdükçe UUID’ye geçmemiz gerekti. Direkt değiştirsek on binlerce mobil uygulamada sorun çıkardı.
type Order {
# Eski - integer ID, deprecated
id: Int @deprecated(reason: "Use orderId (String UUID). Removal: 2024-12-01")
# Yeni - UUID string
orderId: ID!
customerId: ID!
total: Float!
status: OrderStatus!
}
Resolver tarafında her ikisini de besliyorsunuz:
const resolvers = {
Order: {
id: (parent) => {
// Eski integer ID'yi legacy mapping table'dan çekiyorsunuz
return parent.legacyIntId || null;
},
orderId: (parent) => {
return parent.uuid;
},
},
};
Bu yaklaşım zahmetli ama zorunlu. Migration döneminde hem eski hem yeni formatı destekliyorsunuz.
Input Type Evolution
Input type’larda değişiklik yapmak, output type’lara göre daha da riskli. Çünkü mutation’larda kullanılıyorlar ve bir mutation’ın inputu değişirse, o mutation’ı çağıran her istemci etkileniyor.
# Önceki input
input CreateUserInput {
email: String!
password: String!
name: String!
}
# Hatalı yaklaşım - zorunlu alan eklemek direkt breaking
input CreateUserInput {
email: String!
password: String!
name: String!
phoneNumber: String! # BU BREAKING CHANGE
}
# Doğru yaklaşım - optional olarak ekle
input CreateUserInput {
email: String!
password: String!
name: String!
phoneNumber: String # nullable - güvenli
}
Input type’ı rename etmek gerekiyorsa:
# Her ikisini de schema'da tutun
input CreateUserInput {
email: String!
password: String!
name: String! @deprecated(reason: "Use firstName and lastName")
firstName: String
lastName: String
phoneNumber: String
}
Resolver tarafında backward compatibility:
const resolvers = {
Mutation: {
createUser: async (_, { input }) => {
// Legacy uyumluluk - name field'ı hala gelebilir
let firstName = input.firstName;
let lastName = input.lastName;
if (input.name && !firstName && !lastName) {
const parts = input.name.split(" ");
firstName = parts[0];
lastName = parts.slice(1).join(" ") || "";
}
return await userService.create({
email: input.email,
password: input.password,
firstName,
lastName,
phoneNumber: input.phoneNumber,
});
},
},
};
Schema Stitching ve Federation ile Evolution
Mikroservis mimarisinde Apollo Federation kullanıyorsanız, schema evolution daha karmaşık ama aynı zamanda daha yönetilebilir hale geliyor. Her servis kendi schema’sını kontrol ediyor.
# User servisi - users.graphql
extend type Query {
user(id: ID!): User
users(filter: UserFilter): [User!]!
}
type User @key(fields: "id") {
id: ID!
email: String!
displayName: String!
# Deprecated, profile.fullName kullan
fullName: String @deprecated(reason: "Use profile.fullName")
profile: UserProfile
}
type UserProfile {
fullName: String!
avatar: String
bio: String
}
Federation’da bir servisin schema’sını değiştirirken diğer servislerin extend ettiği type’lara dikkat etmek gerekiyor. @key field’larını asla değiştirmeyin, bu tüm federation’ı kırar.
Introspection ile Değişiklik Takibi
Schema değişikliklerini otomatik olarak tespit etmek için CI/CD pipeline’ınıza schema comparison ekleyin.
# graphql-inspector kullanımı
npm install -g @graphql-inspector/cli
# İki schema'yı karşılaştır
graphql-inspector diff old-schema.graphql new-schema.graphql
# Çıktı örneği:
# ✖ Field 'User.fullName' was removed
# ✔ Field 'User.displayName' was added
# ⚠ Field 'User.name' is deprecated
CI pipeline entegrasyonu:
#!/bin/bash
# schema-check.sh
OLD_SCHEMA="./schemas/current.graphql"
NEW_SCHEMA="./schemas/next.graphql"
# Breaking change var mı kontrol et
RESULT=$(graphql-inspector diff $OLD_SCHEMA $NEW_SCHEMA --fail-on-all-breaking)
if [ $? -ne 0 ]; then
echo "Breaking changes detected! Schema deployment blocked."
echo "$RESULT"
exit 1
fi
echo "Schema is backward compatible. Proceeding with deployment."
Bu script’i GitHub Actions’a ekleyin:
# .github/workflows/schema-check.yml içinde kullanım
name: Schema Validation
on: [pull_request]
jobs:
schema-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check Schema Compatibility
run: |
npm install -g @graphql-inspector/cli
bash ./scripts/schema-check.sh
Field Usage Analytics
Hangi field’ların hala kullanıldığını bilmeden deprecation sürecini yönetemezsiniz. Apollo Studio bunun için mükemmel araçlar sunuyor ama self-hosted çözüm de mümkün.
// Custom field usage tracking middleware
const fieldUsagePlugin = {
requestDidStart() {
return {
executionDidStart() {
return {
willResolveField({ info }) {
const fieldName = `${info.parentType.name}.${info.fieldName}`;
const isDeprecated =
info.parentType.getFields()[info.fieldName]?.deprecationReason;
if (isDeprecated) {
// Metrics'e gönder
metrics.increment("graphql.deprecated_field_usage", {
field: fieldName,
operation: info.operation.name?.value || "anonymous",
});
}
},
};
},
};
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [fieldUsagePlugin],
});
Bu tracking sayesinde şunu görebiliyorsunuz: “Bu deprecated field’ı hala kullanan üç istemci var, biri iOS uygulaması, biri web frontend, biri de eski bir cron job.” Onları fix etmeden field’ı kaldırmıyorsunuz.
Enum Evolution
Enum’lar özel dikkat gerektiriyor. Var olan bir enum değerini silmek veya değiştirmek breaking change. Yeni değer eklemek ise teoride güvenli ama pratikte istemciler exhaustive switch kullanıyorsa sorun çıkabilir.
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
# Yeni eklendi - istemciler bu değeri handle etmeli
RETURNED
# Deprecated
FAILED @deprecated(reason: "Use CANCELLED instead")
}
Enum değerlerini kaldırmak için yine alias pattern:
enum PaymentMethod {
CREDIT_CARD
DEBIT_CARD
# BANK_TRANSFER yerine geçti
WIRE_TRANSFER @deprecated(reason: "Use BANK_TRANSFER")
BANK_TRANSFER
CRYPTO
}
Null Safety ve Non-Nullable Geçişleri
String olan bir field’ı String! yapmak, teoride istemci sorgularını kırmaz (istemciler null gelebileceğini düşünüyordu, artık kesinlikle geliyor). Ama pratikte istemcilerin null check’leri kalktığında beklenmedik sorunlar çıkabilir.
Tersine, String! olan bir field’ı String yapmak ise kesinlikle dikkatli olunması gereken bir değişiklik. Resolver’ınız artık null dönebiliyorsa, non-null alan bekleyen istemciler sorun yaşar.
type Product {
id: ID!
name: String!
# Bazı ürünlerin SKU'su olmayabileceği anlaşıldı
# Non-null'dan nullable'a geçiş - istemcilere önceden haber verin
sku: String # Önceden String! idi
description: String
}
Bu değişikliği yapmadan önce tüm istemcilere haber verin ve geçiş için yeterli süre tanıyın.
Gerçek Dünya Checklist’i
Schema değişikliği yapmadan önce şu soruları sorun:
- Bu değişiklik mevcut sorguları kırar mı?
- Kaç istemci bu field veya type’ı kullanıyor?
- Deprecation’dan removal’a kaç gün/hafta/ay geçecek?
- İstemci ekiplerini nasıl haberdar edeceğim?
- Rollback planım var mı?
- Analytics’te bu değişikliğin etkisini ölçebilecek miyim?
Ayrıca şunu da ekleyeyim: Schema değişikliklerini tek seferde büyük adımlarla değil, küçük ve kontrollü adımlarla yapın. “Bu release’de beş tane breaking change yapalım ama deprecation ile halledelim” yaklaşımı kaos yaratır. Her değişikliği ayrı değerlendirin, ayrı planlayın.
Sonuç
GraphQL schema evolution’ı bir kez doğru oturduğunda, API geliştirmek gerçekten keyifli hale geliyor. Temel prensipleri özetleyeyim:
- Asla direkt silme veya rename yapma: Önce deprecate et, geçiş süresi ver, sonra kaldır.
- Additive changes tercih et: Yeni field, yeni type eklemek her zaman güvenli.
- Zorunlu field ekleme: Hiçbir zaman direkt non-nullable olarak ekleme, önce nullable başla.
- CI/CD’ye schema check ekle: Breaking change’leri deployment öncesinde yakala.
- Field usage takip et: Neyin kullanıldığını bilmeden neyi kaldırabileceğini bilemezsin.
- İstemci ekiplerle iletişim kur: Teknik çözümler iletişimsizliği karşılamaz.
REST’ten GraphQL’e geçiş yapan ekiplerin en çok zorlandığı konu bu oluyor. REST’te “v2 endpoint açtım, herkes kendi zamanında geçsin” kolaylığı yok artık. Ama disciplinli bir deprecation ve evolution stratejisiyle bu kısıtlamayı bir avantaja çevirebilirsiniz: İstemcileriniz artık “API değişti, ne zaman geçeceğiz?” diye sormak yerine, kendi hızlarında, breaking olmayan değişiklikler içinde ilerliyorlar.
Schema’nızı bir sözleşme olarak görün ve o sözleşmeyi saygıyla yönetin.
