Değişikliğe Dayanıklı API: Breaking Change Kuralları, Versioning Stratejileri, Deprecation (RFC 9745) ve Sunset (RFC 8594)
Bir API'yi kırmadan değiştirmenin yolu, additive değişiklikleri sürümsüz yayımlamak, response enum'larını baştan açık tanımlamak ve gerçek breaking change'i yeni bir sürümle, Deprecation ve Sunset header'larıyla duyurarak çıkarmaktır. Bookstore'da yeni bir sipariş durumunun strict bir .NET istemcisini nasıl kırdığını, author alanını authors ile değiştirirken tarihli api-version'ı, RFC 9745 ve RFC 8594 header'larını ve deprecated sürümün kullanım log'unu gerçek yanıtlarla görüyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (ASP.NET Core 10.0.15) ile derlenip denenmiştir; HTTP örnekleri bu uygulamanın gerçek yanıtlarıdır, OpenAPI 3.2 dokümanı Redocly CLI 2.78.0 ve Spectral 6.17.1 ile lint edilmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) bu hafta iki değişiklik canlıya çıktı. Kurumsal siparişlerde kısmi sevkiyat geldi ve siparişin yeni bir durumu oldu. Katalog ekibi de iki yazarlı ilk kitabı ekledi; tek bir author string'i artık yetmiyor, yanına bir authors array'i kondu:
$ curl -s http://127.0.0.1:52318/books/9780060853983 | jq -c .
{"isbn":"9780060853983","title":"Good Omens","author":"Terry Pratchett, Neil Gaiman","authors":["Terry Pratchett","Neil Gaiman"],"category":"fantasy","price":{"amount":329.00,"currency":"TRY"},"inStock":true}
$ curl -s -H "Authorization: Bearer linus" "http://127.0.0.1:52318/institutions/lakeside-school/orders?pageSize=1" | jq -c '.items[] | {id, status, createdAt}'
{"id":"01a1d3b8-5e40-7f2a-9c61-3d8e0b47a915","status":"PartiallyShipped","createdAt":"2026-11-13T09:30:00Z"}
Ertesi sabah Nevin Hanım'ın okulunun entegrasyonu sipariş listesini okuyamaz hâle geliyor. Emre de Angular ekranı artık authors kullandığı için author'ı silen bir PR (pull request; değişikliği ana branch'e almak için açılan istek) açmış. Üç soru: Yeni status değeri bir breaking change mi? Yeni authors alanı? Kimsenin kullanmadığını düşündüğümüz author'ı silmek? Cevaplar yazının sonuna kadar dağılmış durumda, ama ilk ipucu şu: üç değişikliğin cevabı da contract'ın ne söylediğine bağlı.
Breaking Change Ne Demek?
Breaking change, mevcut ve contract'a uygun yazılmış bir istemcinin, kendisi hiç değişmediği hâlde çalışmaz hâle gelmesine yol açan değişiklik. Tanımdaki "contract'a uygun" kısmı önemli: birinci yazıdaki Hyrum's Law gereği, belgelenmemiş davranışa bağımlı istemciler de olacak, ama sorumluluğun nerede başladığını contract belirler. Google'ın AIP'lerinden (API Improvement Proposals; Google'ın numaralı API tasarım kuralları) 180 numaralı olan burada dürüst bir şey söylüyor: belgelenmemiş olsa bile, makul yazılmış kodu kıracak bir davranış değişikliği de uyumsuz sayılır ve bu karar biraz da muhakeme ister. Bookstore'un style guide'ına eklediğimiz sınıflandırma şöyle:
| Değişiklik | Örnek | Sonuç |
|---|---|---|
| Response'a yeni alan | authors eklemek | Additive; tolerant reader için güvenli |
| Request'e yeni opsiyonel alan | Sipariş notu | Additive; varsayılanı eski davranışı korumalı |
| Açık bir enum'a yeni değer | PartiallyShipped | Contract açık olduğunu söylüyorsa additive |
| Alan silmek ya da adını değiştirmek | author'ı kaldırmak | Breaking |
| Tip ya da format değiştirmek | author'ı array yapmak | Breaking |
| Opsiyonel request alanını zorunlu yapmak, validation'ı sıkılaştırmak | 10 haneli ISBN'i (uluslararası standart kitap numarası) artık kabul etmemek | Breaking |
| Varsayılanı değiştirmek | Varsayılan sıralamayı tarihe çevirmek | Breaking |
| Status code ya da problem türünü değiştirmek | 409 yerine 422 | Breaking |
Varsayılan değişikliği listede en kolay gözden kaçan satır. Sayfa boyutunun varsayılanını 10'dan 20'ye çıkarmak kimsenin JSON'unu bozmaz ama "ilk 10 kitabı göster" diye parametre göndermeyen bir ekranın görünümünü değiştirir. Status code değişikliği de aynı türden: hata kodları 04. yazıda söylediğimiz gibi contract'ın parçası ve istemci if (status == 409) yazmış olabilir.
Tolerant Reader ve Açık Enum'lar
Breaking change'in yarısı server'ın, yarısı istemcinin sorumluluğu. Martin Fowler'ın 2011'de tarif ettiği tolerant reader yaklaşımında istemci response'tan yalnız ihtiyaç duyduğu alanları okur, bilmediği alanları yok sayar ve bilmediği değerleri hata saymaz. Zalando rehberi de iki taraflı yazıyor: kural #108 istemcilerin uyumlu eklemeleri kabul edecek şekilde yazılmasını ister, kural #111 OpenAPI dokümanını varsayılan olarak genişletmeye açık sayar. Okulun entegrasyonu ise eylülde şöyle yazılmıştı:
// Eylülde yazılan entegrasyonun bildiği status değerleri.
enum KnownStatus { Pending, Confirmed, Shipped, Delivered, Cancelled }
enum StatusView { Pending, Confirmed, Shipped, Delivered, Cancelled, Other }
record StrictOrder(Guid Id, KnownStatus Status);
record TolerantOrder(Guid Id, string Status);
static class StatusMapping
{
// Tolerant reader: bilinmeyen değer exception yerine Other olur.
public static StatusView From(string status) =>
Enum.TryParse(status, ignoreCase: false, out StatusView view) && Enum.IsDefined(view) && view != StatusView.Other
? view
: StatusView.Other;
}
$ dotnet run --project clients/dotnet-reader
strict: The JSON value could not be converted to StrictOrder. Path: $.items[0].status | LineNumber: 0 | BytePositionInLine: 82.
tolerant: 01a1d3b8-5e40-7f2a-9c61-3d8e0b47a915 PartiallyShipped -> Other
tolerant: 01a1c112-d7c0-706c-8cb9-c18a8fadc1a6 Pending -> Pending
tolerant: 01a1a612-9c80-7e46-8590-67e08b9d2434 Confirmed -> Confirmed
JsonStringEnumConverter bilmediği bir değerde JsonException fırlatıyor ve tek bir yeni sipariş bütün sayfayı okunamaz yapıyor. Tolerant okuma status'u string olarak alıp bilinmeyen değeri "Diğer" diye gösteriyor. Peki bu kimin hatası? Bookstore'un contract'ında OrderStatus ikinci yazıdan beri kapalı bir enum değil, examples listesi ve "yeni değerler eklenebilir, istemciler bilinmeyen değeri kabul etmeli" açıklamasıyla tanımlı. Zalando'nun açık uçlu değer listeleri için önerdiği (kural #112) şekil de bu. Azure rehberi aynı şeyi x-ms-enum içindeki modelAsString: true ile yapıyor ve değer kümesi hiç değişmeyecekse kapalı enum kullanılmasını söylüyor. AIP-180 ise response'taki enum'lara değer eklemeye izin veriyor ama bunun dokümante edilmesini ve dikkatli olunmasını istiyor. Yani ilk sorunun cevabı: contract açısından PartiallyShipped additive bir değişiklik, ama bunu duyurmamız ve eski entegrasyonları bulup uyarmamız yine de gerekiyor. Request tarafında ise enum'lar kapalı kalıyor; status=PartiallyShipped filtresi doğrulanıyor ve bilinmeyen değer 400 alıyor. Bu ayrımı style guide'a BS-52 olarak yazdık.
Önce Additive Yol: authors Alanı
İkinci sorunun cevabı kolay: authors'ı eklemek additive. Zor olan, eski alanla ne yapacağımız. author'ı array'e çevirmek tip değişikliği, silmek ise alan kaldırmak; ikisi de breaking. Bu yüzden ilk adım genişletmek: iki alan yan yana yaşıyor, eski alan authors'tan türetiliyor ("Terry Pratchett, Neil Gaiman") ve contract'ta deprecated olarak işaretleniyor:
author:
type: string
deprecated: true
description: >-
Only in api-version 2026-09-01: the authors joined with ", ". Removed in
2026-11-15; use authors.
authors:
type: array
minItems: 1
items:
type: string
Microservices serisinin "Service Contract'ları ve Versioning: Expand-Contract, Tolerant Reader ve Consumer-Driven Contract Test" yazısında anlatılan expand-contract'ın ilk yarısı bu. İkinci yarısı, yani eski alanı gerçekten silmek, ayrı bir karar. Çoğu zaman en iyi seçenek hiç silmemek: bir alanı response'ta tutmanın maliyeti birkaç byte, silmenin maliyeti kırılan bir entegrasyon. Ama burada author iki yazarlı kitapta yanlış bir model sunuyor: virgülle birleştirilmiş isimler, yazara göre filtreleme ya da yazar sayfasına link verme gibi işleri bozuyor. Emre'nin PR'ı da bu yüzden haklı bir istek. Haklı bir breaking change'i ise kimseyi habersiz kırmadan yayımlamanın yolu versioning.
Versioning Seçenekleri
Bir API'nin birden çok sürümünü aynı anda sunmanın beş yaygın yolu var:
| Yöntem | Örnek | Kim kullanıyor | Bedeli |
|---|---|---|---|
| URL'de major sürüm | /v2/books | Google (AIP-185) | Aynı kitabın iki URL'i olur; sürüm değişince bütün link'ler ve kayıtlı adresler değişir |
| Query parametresi | ?api-version=2026-11-15 | Azure (zorunlu, tarih biçimli) | Her istekte ve her link'te taşınmalı |
| Özel header | X-GitHub-Api-Version: 2022-11-28 | GitHub | Tarayıcıda ve link'te görünmez; cache'ler için Vary gerekir |
| Media type | Accept: application/vnd.bookstore.v2+json | Zalando (#114) | Her resource için ayrı media type; araç desteği zayıf |
| Hiç versioning yok | Yalnız additive değişiklik | Zalando'nun ilk tercihi (#113) | Hatalı bir model sonsuza kadar taşınır |
Seçimde iki soru belirleyici. Birincisi, sürüm neyi kapsıyor: bütün API'yi mi, tek bir resource'u mu? URL ve tarih biçimli sürümler bütün API'yi kapsar; media type sürümü tek bir representation'ı. İkincisi, sürüm gönderilmezse ne olur? Azure bu durumda 400 MissingApiVersionParameter döner, yani istemci sürümü bilerek seçmek zorunda. GitHub ise header gelmezse 2022-11-28 sürümünü kullanır ve yeni sürüm çıktıktan sonra eskisini en az 24 ay destekler. Zalando URL'de sürümü açıkça yasaklar (#115), çünkü URL resource'un kimliğidir ve bir kitabın kimliği sürümle değişmemeli.
Bookstore'un Kararı: Tarihli api-version
Bookstore'un style guide'ı ilk yazıda Azure rehberini temel almıştı; versioning'de de ona yakın kalıyoruz, tek bir farkla. Bir ADR (Architecture Decision Record; mimari karar kaydı) olarak yazdığımız ADR-0004'ün kararı şu: additive değişiklikler sürüm almıyor, breaking change'ler yeni ve tarih adlı bir api-version ile çıkıyor, ama parametre zorunlu değil. Gönderilmezse desteklenen en eski sürüm kullanılıyor. Parametreyi zorunlu yapmak, bugüne kadar hiç göndermemiş bütün istemcileri yayın günü kırardı. Sonuç:
$ curl -i http://127.0.0.1:52318/books/9780060853983
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:10:45 GMT
Server: Kestrel
Content-Language: en
ETag: "1"
Transfer-Encoding: chunked
Api-Version: 2026-09-01
Deprecation: @1794700800
Sunset: Mon, 17 May 2027 00:00:00 GMT
Link: <https://api.example.com/docs/versions/2026-09-01>; rel="deprecation"; type="text/html"
{"isbn":"9780060853983","title":"Good Omens","author":"Terry Pratchett, Neil Gaiman","authors":["Terry Pratchett","Neil Gaiman"],"category":"fantasy","price":{"amount":329.00,"currency":"TRY"},"inStock":true}
$ curl -i "http://127.0.0.1:52318/books/9780060853983?api-version=2026-11-15"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:10:45 GMT
Server: Kestrel
Content-Language: en
ETag: "1"
Transfer-Encoding: chunked
Api-Version: 2026-11-15
{"isbn":"9780060853983","title":"Good Omens","authors":["Terry Pratchett","Neil Gaiman"],"category":"fantasy","price":{"amount":329.00,"currency":"TRY"},"inStock":true}
$ curl -s "http://127.0.0.1:52318/books?api-version=2026-12-01" | jq .
{
"type": "https://api.example.com/problems/unsupported-api-version",
"title": "Unsupported api-version",
"status": 400,
"detail": "api-version 2026-12-01 is not supported; see supportedVersions.",
"instance": "/books",
"supportedVersions": [
"2026-09-01",
"2026-11-15"
],
"traceId": "00-54ee528786ed938557d80e80d1bc62be-7d77932004ec2962-00"
}
$ curl -s "http://127.0.0.1:52318/books?api-version=2026-11-15&fields=isbn,author" | jq -c '{status, errors}'
{"status":400,"errors":{"fields":["unknown-field"]}}
$ curl -s -D - -o /dev/null "http://127.0.0.1:52318/books?q=tolkien&pageSize=1&api-version=2026-11-15" | grep -i -E "^(api-version|link)"
Api-Version: 2026-11-15
Link: </books?q=tolkien&pageSize=1&api-version=2026-11-15&pageToken=CfDJ8AAAAAAAAAAAAAAAAAAAAACvPMXZYNlbqzg40EXjtTmrSsfFYXeHBdAsSCvZulg6nuiJ92H20OpZPI1pP0EaQEczBax-5GBD1GL-ZxE10UshltW0DvLOHG1ro9ecdpeHJz6eXOb4i5t7ggc4WBWf5T3CbB__gq8Zda4wryQEWIHmh4_DYBvVEIJDNCJoDnRGRYeyjSw955tT_heUMNLYeis>; rel="next"
Her response hangi sürümle üretildiğini Api-Version header'ında söylüyor; destek ekibi bir şikâyette ilk buna bakıyor. Yeni sürümde author yok ve fields listesinde de seçilemiyor. Bilinmeyen bir sürüm, desteklenen sürümleri listeleyen bir problem dönüyor. Azure'un davranışı da benzer: tanımadığı değere 400 UnsupportedApiVersionValue döner ve desteklenen sürümleri mesajda listeler. Sayfa link'i api-version'ı kendiliğinden taşıyor, çünkü 05. yazıda Link'i mevcut query'den kurmuştuk. Azure rehberi de istemcinin sürümü nextLink ve operation status URL'leri dahil her istekte göndermesini ister.
Tarih biçimli sürümün URL'deki v2'ye göre bir avantajı daha var: sürüm numarası bir "büyük yeniden yazım" beklentisi yaratmıyor. 2026-11-15 sürümü 2026-09-01'den yalnız bir alanla ayrılıyor; sunucu da yalnız değişen representation için iki ayrı kod yolu taşıyor. Sayfa token'ları ve filtreler sürümden bağımsız. Yanlış giden bir şey de var: sürümü hiç göndermeyen istemciler, eski sürümün sunset tarihinde yine kırılacak. Bu tarihten sonra parametresiz istekler desteklenen en eski sürüme, yani 2026-11-15'e düşecek. Bunu önlemenin yolu o istemcileri sunset'ten önce bulmak.
Deprecation ve Sunset Header'ları
Deprecation ile sunset iki ayrı an. Deprecation "bunu kullanmayı bırak, yerine şunu kullan" demek ve davranışı değiştirmez; IETF'in (internet standartlarını geliştiren kuruluş) RFC'lerinden (Request for Comments; numaralı standart dokümanları) Mart 2025'te yayımlanan 9745 numaralısı bunu açıkça yazar: deprecated bir resource eskisi gibi çalışmaya devam eder. Sunset ise resource'un yanıt vermeyi bırakacağı tarih ve 2019 tarihli RFC 8594'te tanımlı. İki header'ın biçimi farklı: Deprecation bir Structured Field tarihi, yani @ ve ardından Unix zamanı; @1794700800, UTC (eşgüdümlü evrensel saat) ile 15 Kasım 2026 00:00 demek. Sunset ise HTTP'nin klasik tarih biçimini kullanıyor. RFC 9745 sunset tarihinin deprecation tarihinden önce olamayacağını da söylüyor. Link header'ındaki rel="deprecation" geçiş rehberini, RFC 8594'ün tanımladığı rel="sunset" ise sunset politikasını gösteriyor.
Bookstore'da bu header'lar iki yerde. Biri yukarıda gördüğümüz eski sürüm. RFC 9745'e göre header yalnız taşıdığı response'un resource'u için geçerli; daha geniş bir kapsam (burada "bu api-version'daki her şey") ancak dokümante edilirse anlam taşır, biz de bunu ADR-0004'e yazdık. Diğeri, ilk yazıdan beri dondurulmuş duran eski RPC (Remote Procedure Call; uzaktaki bir fonksiyonu çağırır gibi tasarlanmış API) endpoint'i:
$ curl -i -X POST -H "Content-Type: application/json" -d '{"method":"GetBook","params":{"isbn":"9780441172719"}}' http://127.0.0.1:52318/legacy/rpc
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:10:45 GMT
Server: Kestrel
Content-Language: en
Transfer-Encoding: chunked
Deprecation: @1794700800
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://api.example.com/docs/migrations/legacy-rpc>; rel="deprecation"; type="text/html"
Link: <https://api.example.com/docs/sunset-policy>; rel="sunset"; type="text/html"
{"success":true,"data":{"booK_ISBN":"9780441172719","booK_TITLE":"Dune","authoR_NAME":"Frank Herbert","pricE_TL":489.00,"stocK_QTY":14,"supplieR_CODE":"ace-tr"}}
Header'lar tek başına yetmiyor; çoğu istemci response header'larına hiç bakmaz. Zalando bu yüzden deprecation'ı bir süreç olarak tanımlar: deprecation'ı OpenAPI dokümanında işaretlemek (#187), kapatmadan önce tüketicilerin onayını almak (#185), sunset'e kadar kullanımı izlemek (#188) ve header'ları eklemek (#189). Kullanımı izlemek için Bookstore eski sürüme gelen her isteği kimin yaptığıyla birlikte log'luyor. Uygulamanın konsolundan:
info: Bookstore.Deprecation[0]
Deprecated api-version 2026-09-01 used by anonymous for GET /books/9780060853983
info: Bookstore.Deprecation[0]
Deprecated api-version 2026-09-01 used by linus for GET /institutions/lakeside-school/orders
info: Bookstore.Deprecation[0]
Deprecated api-version 2026-09-01 used by nevin for GET /orders
Bu liste Hakan'ın sunset'ten önce arayacağı kurumların listesi. Anonim çağrıları kişiye bağlayamıyoruz; onlar için duyuru, dokümantasyon ve sunset'e yakın bir tarihte kısa süreli planlı kesintiler gibi yöntemler kalıyor. Deprecated API'lerin bir envanterde takip edilmesi ve eski endpoint'lerin güvenlik açısından riski API Güvenliği serisinin yanlış yapılandırma ve envanter yazısında ayrıntılı anlatılıyor. Azure ise RFC 9745'ten önce kendi azure-deprecating header'ını tanımlamıştı; yeni bir API'de standart header'ı kullanmak araçların onu tanıması açısından daha iyi.
Önce Tahmin Et'teki üçüncü sorunun cevabı da burada: author'ı silmek breaking, ama Emre'nin PR'ı reddedilmedi. Alan yalnız yeni sürümden kalktı, eski sürüm altı ay daha author'ı dönüyor ve her response'ta ne zaman kalkacağını söylüyor.
ASP.NET Core'da
Bookstore'daki çözüm bilerek küçük: bir middleware api-version'ı okuyor, desteklenmeyen değere problem dönüyor, seçilen sürümü HttpContext.Items'a koyuyor ve eski sürümde Deprecation, Sunset ve Link header'larını ekliyor. Representation farkı tek satır: author eski sürümde dolu, yeni sürümde null, ve null alanları JSON'a yazmadığımız için (BS-11) alan response'ta görünmüyor. Gerçek bir projede bunun yerine Asp.Versioning library'si URL, query, header ve media type yöntemlerini, sürüm başına endpoint eşlemesini ve api-supported-versions response header'ını hazır verir; kurulumu ve controller'larla kullanımı ASP.NET Core Web API serisinin "ASP.NET Core Web API Versioning: Asp.Versioning ile URL, Query String ve Header" yazısında. Hangi yöntemi seçeceğin ise library'nin değil, bu yazıdaki kararların konusu.
Sık Düşülen Tuzaklar
Her değişiklikte yeni sürüm açmak. Yeni bir alan ya da yeni bir endpoint için sürüm açarsan istemciler hiçbir şey kazanmadan geçiş yapmak zorunda kalır ve sunucu birbirinden az farklı birçok sürüm taşır. Sürüm yalnız breaking change içindir.
Enum'a değer eklemenin güvenli olduğunu varsaymak. Contract enum'un açık olduğunu söylemiyorsa, eski istemcinin deserializer'ı yeni değerde exception fırlatabilir; yukarıdaki JsonException tam olarak buydu. Response enum'larının açık olduğunu baştan contract'a yaz, request enum'larını kapalı tut.
Varsayılanın sessizce değişmesi. Parametresiz isteğin davranışı da contract'tır. "Sürüm gönderilmezse en yenisi" kuralı her yayında sürüm göndermeyen herkesi kırar.
Deprecation'ı yalnız header'a bırakmak. Header'ı okuyan istemci azdır. Kullanım log'u, tüketicilere doğrudan duyuru ve sunset tarihinden önce bir hatırlatma olmadan sunset günü bir kesintiye dönüşür.
Sunset tarihini geçtiği hâlde uygulamamak. Altı ay sonra kapatılacağı söylenen endpoint iki yıl açık kalırsa, bir sonraki duyuruyu kimse ciddiye almaz.
Küçük Bir Deneme
★☆☆ dotnet-reader projesindeki strict okumaya bir değişiklik yapmadan, yalnız JsonSerializerOptions'ı değiştirerek PartiallyShipped'i okuyabilir misin? Okuyabiliyorsan bu değer hangi C# değerine dönüşüyor?
★★☆ Yazma tarafı bu yazıda değişmedi: PUT ve PATCH hâlâ tek bir author string'i alıyor. 2026-11-15 sürümünde authors alan bir yazma contract'ı tasarla. Eski sürümle yazılan iki yazarlı bir kitaba ne olmalı?
★★★ Sunset tarihi geldiğinde eski sürüme gelen isteklere ne döneceğini tasarla: 410 Gone mu, yeni sürüme düşmek mi, bir problem türü mü? Kararını ve gerekçesini bir ADR olarak yaz.
Bu yazıda contract'ın zaman içindeki değişimine baktık. Bir sonraki yazıda aynı anda gelen değişikliklere geçiyoruz: iki kişinin aynı kitabı güncellediği concurrency durumu, ağ hatasında tekrar gönderilen bir siparişin iki kez oluşmaması için Idempotency-Key ve saniyeler süren işler için 202 Accepted ile status monitor.