Kırıcı değişiklik (breaking change), API'yi mevcut hâliyle kullanan bir istemcinin hiçbir kod değişikliği yapmadan çalışmayı bırakmasına yol açan değişikliktir. Bu serinin dayandığı tasarım kaynağı dört tipik örneği sayar: bir alanı kaldırmak, bir alanı yeniden adlandırmak, bir alanın tipini değiştirmek ve yeni bir zorunlu alan eklemek. İsteğe bağlı yeni bir alan eklemek ise geriye uyumlu (backward compatible) kabul edilir ve aynı sürümde yapılabilir. Kırıcı değişiklik gerektiğinde yeni bir sürüm açılır.

Kural kısa ama uygulaması incelik ister: aynı değişiklik bir istemci için zararsız, başka bir istemci için kırıcı olabilir; ayrıca alan adları kadar davranışlar da sözleşmenin parçasıdır. Bu yazıda değişiklik türlerini çalışan Pydantic ve FastAPI örnekleriyle sınıflandırıyor, RFC 8259'un JSON birlikte çalışabilirlik notlarının nerede devreye girdiğini ve bir alanı kırmadan nasıl değiştirebileceğinizi gösteriyoruz. Sürüm numarasının URL'de mi başlıkta mı taşınacağı API versiyonlama yazısının konusu; serinin tamamı REST API tasarımı öğrenme rehberinde.

Hangi değişiklik kırıcıdır?

Hangi değişiklik kırıcıdır? Yanıttan alan kaldırmak, Alanı yeniden adlandırmak, Alan tipini değiştirmek, İsteğe yeni zorunlu alan eklemek

Bir değişikliğin kırıcı olup olmadığını anlamanın en pratik yolu, "v1 belgesine bakarak yazılmış bir istemci bu değişiklikten sonra da doğru çalışır mı?" diye sormaktır. Kaynakta sayılan dört durum ve uyumlu durum şöyle özetlenebilir:

DeğişiklikKırıcı mı?Neden
Yanıttan alan kaldırmakEvetO alanı okuyan istemci değeri bulamaz
Alanı yeniden adlandırmakEvetEski adı arayan istemci için alan silinmiş gibidir
Alan tipini değiştirmekEvetSayı bekleyen kod metinle karşılaşır
İsteğe yeni zorunlu alan eklemekEvetEski istemcilerin istekleri doğrulamadan geçemez
Yanıta isteğe bağlı alan eklemekGenellikle hayırBilinmeyen alanı yok sayan istemci etkilenmez
İsteğe bağlı yeni istek alanıHayırGöndermeyen istemci için varsayılan kullanılır

Yeniden adlandırma örneğiyle somutlaştıralım. v1 yanıtı şudur:

json
{"id": 1, "name": "Ayşe Demir"}

Alan adını full_name yapmak, eski istemci açısından name alanının silinmesi ile aynıdır; bu yüzden bu biçim ancak yeni bir sürümde, örneğin /v2/kullanicilar altında sunulabilir:

json
{"id": 1, "full_name": "Ayşe Demir"}

Buna karşılık mevcut alanlara dokunmadan isteğe bağlı bir email alanı eklemek, kaynağın ifadesiyle geriye uyumludur ve /v1/kullanicilar adresinde kalabilir:

json
{"id": 1, "name": "Ayşe Demir", "email": "ayse@example.com"}

İstemcinin gözünden: aynı değişiklik herkes için aynı değil

"Alan eklemek uyumludur" kuralı, istemcinin bilmediği alanları yok saydığı varsayımına dayanır. Her istemci böyle yazılmaz. Aşağıdaki örnekte bir istemci v1 yanıtını iki farklı Pydantic modeliyle okuyor: biri bilinmeyen alanları yok sayıyor (Pydantic'in varsayılanı), diğeri extra="forbid" ile reddediyor. Dört farklı sunucu yanıtının sonucu:

python
from pydantic import BaseModel, ConfigDict, ValidationError

class KullaniciV1(BaseModel):          # istemcinin v1'e göre yazdığı model
    id: int
    name: str

class KatiKullaniciV1(KullaniciV1):    # bilinmeyen alanı reddeden istemci
    model_config = ConfigDict(extra="forbid")

yanitlar = {
    "v1 aynen": {"id": 1, "name": "Ayşe"},
    "opsiyonel alan eklendi": {"id": 1, "name": "Ayşe", "email": "ayse@example.com"},
    "alan yeniden adlandırıldı": {"id": 1, "full_name": "Ayşe"},
    "tip değişti": {"id": "usr_1", "name": "Ayşe"},
}

for model in (KullaniciV1, KatiKullaniciV1):
    for ad, govde in yanitlar.items():
        try:
            model.model_validate(govde)
            sonuc = "OK"
        except ValidationError as e:
            sonuc = e.errors()[0]["type"]
        print(f"{model.__name__:16} {ad:26} {sonuc}")
# çıktı: KullaniciV1      v1 aynen                   OK
# çıktı: KullaniciV1      opsiyonel alan eklendi     OK
# çıktı: KullaniciV1      alan yeniden adlandırıldı  missing
# çıktı: KullaniciV1      tip değişti                int_parsing
# çıktı: KatiKullaniciV1  v1 aynen                   OK
# çıktı: KatiKullaniciV1  opsiyonel alan eklendi     extra_forbidden
# çıktı: KatiKullaniciV1  alan yeniden adlandırıldı  missing
# çıktı: KatiKullaniciV1  tip değişti                int_parsing

Hoşgörülü istemci yalnızca gerçekten kırıcı olan iki durumda, yeniden adlandırma (missing) ve tip değişikliğinde (int_parsing) hata verdi. Katı istemci ise isteğe bağlı alan eklenmesine de extra_forbidden ile tepki gösterdi. Bundan çıkan ders iki yönlüdür. API tarafında, belgenize "yanıtlara zamanla yeni alanlar eklenebilir, istemciler bilmedikleri alanları yok saymalıdır" cümlesini açıkça yazın; uyumluluk sözü ancak bu beklentiyle anlamlıdır. İstemci tarafında ise yanıt modellerini bilinmeyen alanları reddedecek biçimde kurmayın.

Tip değişikliği satırında ilginç bir ayrıntı da var: Pydantic'in esnek modu "1" gibi sayıya çevrilebilen bir metni tamsayı olarak kabul ederdi, ama "usr_1" çevrilemediği için hata oldu. Yani bazı tip değişiklikleri bazı istemcilerde tesadüfen çalışabilir; bu, onları uyumlu yapmaz. Sözleşme belgede yazan tiptir.

İstek tarafı: yeni zorunlu alan

Kırıcı değişiklikler yalnızca yanıtlarda olmaz. Sunucunun kabul ettiği istek gövdesine zorunlu bir alan eklemek, o alanı hiç bilmeyen eski istemcilerin tüm isteklerini doğrulama hatasına düşürür. Aynı sipariş modelinin iki hâlini karşılaştıralım:

python
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()

class SiparisZorunlu(BaseModel):
    urun_id: int
    adet: int
    teslimat_notu: str              # sonradan zorunlu yapıldı: kırıcı

class SiparisOpsiyonel(BaseModel):
    urun_id: int
    adet: int
    teslimat_notu: str | None = None  # opsiyonel eklendi: uyumlu

@app.post("/a/siparisler", status_code=201)
def olustur_a(s: SiparisZorunlu):
    return s.model_dump()

@app.post("/b/siparisler", status_code=201)
def olustur_b(s: SiparisOpsiyonel):
    return s.model_dump()

eski_istemci = {"urun_id": 5, "adet": 2}
c = TestClient(app)
r = c.post("/a/siparisler", json=eski_istemci)
print(r.status_code, r.json()["detail"][0]["type"], r.json()["detail"][0]["loc"])
# çıktı: 422 missing ['body', 'teslimat_notu']
r = c.post("/b/siparisler", json=eski_istemci)
print(r.status_code, r.json())
# çıktı: 201 {'urun_id': 5, 'adet': 2, 'teslimat_notu': None}

Zorunlu alan eklenen uç nokta, eski istemcinin isteğini 422 ile reddetti; FastAPI hatanın yerini ["body", "teslimat_notu"] olarak gösterdi. Aynı alanı varsayılan değerli ve isteğe bağlı eklediğimizde eski istek 201 ile kabul edildi. Kural şu: yeni istek alanları isteğe bağlı ve makul bir varsayılanla gelir; bir alanı gerçekten zorunlu yapmak gerekiyorsa bu yeni bir sürümün işidir. Doğrulama hatalarının biçimi ve 400 ile 422 farkı için istek doğrulama yazısına bakın.

Ters yön de önemlidir: sunucunun bir istek alanı için kabul ettiği değerleri daraltmak (örneğin daha önce 500 karaktere izin verilen bir alanı 100 karaktere düşürmek) eski istemcilerin geçerli isteklerini reddetmeye başlar ve bu da kırıcıdır. Kabul edilen aralığı genişletmek ise genellikle uyumludur.

Davranış değişiklikleri de sözleşmenin parçası

Davranış değişiklikleri de sözleşmenin parçası: Durum kodunu değiştirmek, Hata gövdesinin biçimini değiştirmek, Varsayılan değerleri değiştirmek, Enum değerlerini kaldırmak veya...

Tasarım kaynağı, yeni sürüm gerektiren durumları sayarken alan değişikliklerinin yanında "davranış değiştirmeyi" de anar. JSON şeması aynı kalsa bile istemcinin dayandığı davranış değişebilir. Aşağıdaki örnekler bu yazının tasarım çıkarımlarıdır; her biri, eski istemcinin yazdığı bir varsayımı bozduğu için dikkat ister:

  • Durum kodunu değiştirmek: Başarılı oluşturmada 200 yerine 201 döndürmeye başlamak, yalnızca 200'ü başarı sayan bir istemciyi bozabilir. Kod seçimi için durum kodu seçimi yazısına bakın.
  • Hata gövdesinin biçimini değiştirmek: Hata mesajını okuyan istemciler yeni biçimi tanımaz.
  • Varsayılan değerleri değiştirmek: Varsayılan sayfa boyutunu veya sıralama yönünü değiştirmek, parametre göndermeyen istemcilerin gördüğü sonucu değiştirir.
  • Enum değerlerini kaldırmak veya anlamını değiştirmek: status alanında "active" artık gelmiyorsa, ona göre dallanan kod çalışmaz. Yeni bir değer eklemek de, bilinmeyen değeri hata sayan istemciler için sorun olabilir; belgede "yeni değerler eklenebilir" diye belirtin.
  • Alanın null olabilmesi: Her zaman dolu gelen bir alanın artık null gelebilmesi, değeri denetlemeden kullanan istemcileri bozar.
  • Kimlik doğrulama veya yetki kuralını sıkılaştırmak: Daha önce izin verilen bir işlemin artık 403 dönmesi istemci açısından bir davranış değişikliğidir.

RFC 8259 açısından güvenli ve riskli değişiklikler

JSON'un kendi tanımı da bazı değişikliklerin neden güvenli, bazılarının neden riskli olduğunu açıklar. RFC 8259, nesne üyelerinin sırasını görünür kılıp kılmadıkları konusunda JSON kütüphanelerinin farklılaştığını ve davranışı üye sırasına bağlı olmayan uygulamaların bu farklardan etkilenmeyeceğini belirtir. Bu yüzden yanıttaki alanların sırasını değiştirmek bir sözleşme değişikliği sayılmamalıdır; istemciler de alan sırasına güvenmemelidir. Aynı RFC, bir nesnedeki adların benzersiz olması gerektiğini, tekrar eden adlarda alıcının davranışının öngörülemez olduğunu söyler; geçiş sırasında aynı ada iki değer koymak bu nedenle bir çözüm değildir.

Sayılar için RFC 8259 önemli bir uyarı yapar: uygulamalar sayıların aralığına ve hassasiyetine sınır koyabilir ve yaygın IEEE 754 çift hassasiyetli sayılarla çalışan yazılımların tam olarak anlaştığı tamsayı aralığı [-(2**53)+1, (2**53)-1] ile sınırlıdır. Bu aralığın dışındaki bir kimlik değeri, sayıyı kayan noktalı olarak okuyan bir istemcide sessizce değişebilir:

python
import json

buyuk = 2**53 + 1
metin = json.dumps({"id": buyuk})
print(metin)
# çıktı: {"id": 9007199254740993}
print(float(json.loads(metin)["id"]) == buyuk)
# çıktı: False
print(int(float(buyuk)))
# çıktı: 9007199254740992

Python tam sayıyı doğru yazdı, ama değeri kayan noktalı sayıya çeviren bir istemci 9007199254740993 yerine 9007199254740992 gördü. Kimlik alanını ileride bu aralığı aşabilecek şekilde tasarlıyorsanız, en baştan metin (string) tipinde vermek, sonradan sayıdan metne geçmenin yaratacağı kırıcı tip değişikliğini önler. Kimlik tipi seçimi alan adlandırma ve tarih formatı yazısında ele alınıyor.

Kırmadan değiştirmek: önce genişlet, sonra daralt

Bir alanı yeniden adlandırmak istediğinizde her zaman hemen yeni sürüm açmanız gerekmez. Mevcut sürümde önce yeni alanı eskisinin yanına eklersiniz; bu, isteğe bağlı alan eklemek olduğu için uyumludur:

json
{"id": 1, "name": "Ayşe Demir", "full_name": "Ayşe Demir"}

Yeni istemciler full_name alanını kullanmaya başlar, belgede name alanı "kullanımdan kalkacak" olarak işaretlenir ve eski alan ancak bir sonraki ana sürümde kaldırılır. Böylece kırıcı adım (alanın silinmesi) tek bir planlı sürüm geçişinde toplanır ve istemcilerin geçiş için zamanı olur. Bu yaklaşımın bir maliyeti vardır: geçiş süresince iki alanın da tutarlı tutulması gerekir. Kullanımdan kaldırmanın HTTP başlıklarıyla nasıl duyurulacağı Deprecation ve Sunset yazısının konusudur.

İki sürümü tek iç modelden üretmek

Yeni sürüm açtığınızda v1 ve v2 aynı veriyi farklı biçimlerde sunar. Veriyi tek bir iç modelde tutup her sürüm için yalnızca dışa dönük temsili ayrı üretmek, iki sürümün zamanla birbirinden habersizce ayrışmasını önler:

python
from dataclasses import dataclass

@dataclass
class Kullanici:            # iç model: veritabanındaki tek gerçek
    id: int
    ad: str
    soyad: str
    email: str | None = None

def v1_temsil(k: Kullanici) -> dict:
    return {"id": k.id, "name": f"{k.ad} {k.soyad}"}

def v2_temsil(k: Kullanici) -> dict:
    return {"id": k.id, "full_name": f"{k.ad} {k.soyad}", "email": k.email}

k = Kullanici(7, "Ayşe", "Demir", "ayse@example.com")
print(v1_temsil(k))
# çıktı: {'id': 7, 'name': 'Ayşe Demir'}
print(v2_temsil(k))
# çıktı: {'id': 7, 'full_name': 'Ayşe Demir', 'email': 'ayse@example.com'}

Bu düzende iş kuralları ve veritabanı erişimi bir kez yazılır; sürümler arasındaki fark, küçük ve test edilebilir dönüşüm işlevlerinde toplanır. v1 temsilinin çıktısını sabitleyen bir test yazmak, birinin v1'i yanlışlıkla değiştirmesine karşı da iyi bir sigortadır: v1 çıktısı değişirse test kırılır ve değişikliğin kırıcı olup olmadığı bilinçli olarak tartışılır.

Kırıcı değişikliği yayından önce yakalamak

Kırıcı değişikliklerin çoğu kötü niyetten değil, fark edilmemekten doğar: bir geliştirici modeldeki alan adını düzeltir, tip ipucunu sıkılaştırır ya da bir alanı varsayılansız bırakır ve bunun yayımlanmış bir sözleşmeyi değiştirdiğini düşünmez. Bunu yakalamanın en somut yolu, sözleşmenin kendisini karşılaştırılabilir bir belge olarak tutmaktır. API'niz için bir OpenAPI belgesi üretiyorsanız (FastAPI bunu otomatik yapar), her yayından önce yeni belgeyi bir öncekiyle karşılaştırmak şu soruları cevaplar: bir yol veya şema özelliği kaldırıldı mı, bir özelliğin type değeri değişti mi, istek şemasının required listesine yeni bir ad eklendi mi, bir yanıt kodu listeden çıktı mı? Bunların her biri bu yazıdaki tabloda kırıcı olarak işaretlenen bir duruma karşılık gelir.

OpenAPI 3.1.1 belirtimi ayrıca işlemler (operation) ve parametreler için deprecated alanı tanımlar; bu alan true olduğunda kullanıcıların o işlemden veya parametreden uzaklaşması beklenir. Önce genişlet, sonra daralt yaklaşımında eski parametreyi bu alanla işaretlemek, değişikliği belgeyi okuyan herkes için görünür kılar. OpenAPI belgesinin elle yazımı OpenAPI 3.1 YAML yazısında anlatılıyor. Belge karşılaştırması uyumsuzluğu gösterir, ama karar yine sizindir: değişiklik gerçekten gerekliyse yeni sürüme taşınır, değilse geri alınır.

Sık yapılan hatalar

Sık yapılan hatalar: "Küçük bir isim düzeltmesi" diye alanı yeniden adlandırmak, Zorunlu istek alanını mevcut sürüme eklemek, Uyumluluk beklentisini yazmamak, Tipi "yalnızca...
  • "Küçük bir isim düzeltmesi" diye alanı yeniden adlandırmak: Eski adı okuyan her istemci için alan silinmiştir.
  • Zorunlu istek alanını mevcut sürüme eklemek: Eski istemcilerin tüm istekleri doğrulamada takılır.
  • Uyumluluk beklentisini yazmamak: İstemcilere bilinmeyen alanları yok saymalarını söylemezseniz, alan eklemek bile bazılarını bozar.
  • Tipi "yalnızca biraz" değiştirmek: Sayıyı metne çevirmek bazı istemcilerde tesadüfen çalışır, bazılarında çalışmaz.
  • Davranış değişikliğini değişiklik saymamak: Varsayılan sıralama, durum kodu veya hata biçimi değişince şema aynı kalsa da istemci bozulur.
  • Büyük sayıları sayı olarak vermek: 2**53 sınırını aşan kimlikler bazı istemcilerde sessizce değişir.
  • Her değişikliğe yeni sürüm: Uyumlu değişiklikler için sürüm açmak, istemcileri gereksiz geçişlere zorlar.

Sık Sorulan Sorular

Yanıta yeni alan eklemek gerçekten güvenli mi?

İstemciler bilinmeyen alanları yok sayıyorsa evet; tasarım kaynağı da isteğe bağlı alan eklemeyi geriye uyumlu sayar. Bu beklentiyi belgede açıkça yazmak, uyumluluk sözünün temelidir.

Alanın sırasını değiştirmek kırıcı mı?

Olmamalı. RFC 8259'a göre davranışı üye sırasına bağlı olmayan uygulamalar birlikte çalışabilir; istemcilerin alan sırasına güvenmemesi gerekir.

Hata düzeltmesi kırıcı değişiklik sayılır mı?

Belgelenen davranışı düzeltmek değil, belgelenmemiş ama istemcilerin dayandığı bir davranışı değiştirmek risklidir. Bu durumda değişikliği önceden duyurmak ve gerekiyorsa geçiş süresi tanımak iyi bir uygulamadır.

Enum alanına yeni bir değer eklemek kırıcı mı?

İstemciye bağlıdır. Bilinmeyen değeri genel bir "diğer" durumu olarak ele alan istemci etkilenmez; her değeri tek tek eşleştirip eşleşmeyende hata veren istemci bozulur. Bu yüzden enum alanlarının ileride yeni değer alabileceğini ilk sürümden itibaren belgede yazmak, bu tür eklemeleri uyumlu sayabilmenin ön koşuludur.

Bir alanı ne zaman gerçekten silebilirim?

Yeni bir ana sürümde. Mevcut sürümde alan kalır, belgede kullanımdan kalkacağı yazılır, yeni sürüm yayımlandıktan sonra eski sürüm duyurulan takvimle emekliye ayrılır.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: REST API tasarımı öğrenme rehberi · API versiyonlama: URL ve header · Deprecation ve Sunset başlıkları · JSON alan adlandırma ve tarih formatı · İstek doğrulama: 400 ve 422