Service Contract'ları ve Versioning: Expand-Contract, Tolerant Reader ve Consumer-Driven Contract Test
Bir değişikliğin breaking olup olmadığı yalnız provider'ın koduna bakarak anlaşılmaz; consumer'ların ne okuduğunu bilmek gerekir, bu yüzden consumer tolerant reader olur, provider kırıcı değişikliği expand-contract ile adımlara böler ve consumer-driven contract test'le deploy'dan önce kimi kıracağını görür. TypeScript consumer ve .NET provider arasında Pact ile gerçek doğrulama çıktıları, Deprecation/Sunset header'ları ve versioning'in service seviyesindeki bedeli.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; contract test'leri PactNet 5.0.1 (provider) ve Node.js 22 üzerinde @pact-foundation/pact 17.1.4 (consumer) ile koşturuldu.
Önce Tahmin Et
Bookstore'da Catalog artık ayrı bir service ve kitabı şu JSON (JavaScript Object Notation) ile döndürüyor: { "id": "...", "title": "Dune", "author": "Frank Herbert", "listPrice": { "amount": 14.90, "currency": "EUR" } }. Catalog ekibi birden fazla yazarlı kitapları desteklemek istiyor ve sprint planında iki madde var. Birincisi author string'ini kaldırıp yerine authors array'ini koymak: "authors": [ { "name": "Frank Herbert" } ]. İkincisi, artık kimsenin kullanmadığını düşündükleri listPrice'ı bu endpoint'ten çıkarıp ayrı bir fiyat endpoint'ine taşımak.
Bu endpoint'i üç service çağırıyor: Ordering sipariş satırına kitap adını ve fiyatı yazıyor, Customer Experience ekibinin Node.js ve TypeScript ile yazdığı Notification sipariş e-postasına kitap adını ve yazarı koyuyor, Angular storefront'un arkasındaki BFF (Backend for Frontend) de detay sayfasını dolduruyor. Hepsi Catalog'dan bağımsız deploy ediliyor. İki değişiklikten hangisi kimi kırar? Catalog ekibi bunu deploy etmeden önce nereden bilebilir: ekip kanalına "bu alanı kullanan var mı?" diye yazarak mı, yoksa pipeline'da kırmızı yanan bir test'ten mi?
Contract'ın İki Yüzü
Bir service'in contract'ı, dışarıya söz verdiği her şeydir: endpoint'ler, request ve response şemaları, status code'lar, header'lar, yayınladığı event'ler, hatta "bu çağrı 300 ms içinde döner" gibi beklentiler. Serinin ilk yazısında microservice'in merkezine information hiding'i koymuştuk; contract, gizlenen iç modelle dışarıya açılan yüz arasındaki sınır. İç modeli istediğin kadar değiştirebilirsin, contract'ı değiştirmek ise başka ekiplerin production'ına dokunmak demek.
Ian Robinson, 2006'da martinfowler.com'da yayımlanan "Consumer-Driven Contracts: A Service Evolution Pattern" yazısında bu sınırı iki taraftan tarif etti. Provider contract, service'in sunduğu her şeyin tam ve otoriter tanımı; tek bir tane var. Consumer contract ise tek bir consumer'ın provider'dan gerçekte beklediği alt küme; consumer sayısı kadar var ve hiçbiri tam değil. Notification'ın Catalog'dan beklediği şey yalnız title ve author; listPrice'ın varlığından haberi bile yok. Consumer-driven contract, bu beklentilerin birleşimi: provider, hiç kimsenin kullanmadığı bir şeyi korumak zorunda değil, ama birinin kullandığı bir şeyi habersizce kıramaz. Önce Tahmin Et'teki sorunun cevabı da buradan çıkıyor: author'ı kaldırmak Notification'ı ve BFF'i kırar, listPrice'ı kaldırmak Ordering'i kırar. Catalog ekibinin bunu bilmesinin güvenilir tek yolu, bu beklentilerin makinenin okuyabileceği bir yerde durması.
Breaking ve Non-Breaking Change
HTTP (Hypertext Transfer Protocol) üzerinden JSON konuşan bir API'de (Application Programming Interface) değişiklikler kabaca şöyle ayrılıyor:
| Değişiklik | Mevcut consumer için | Not |
|---|---|---|
| Response'a yeni alan eklemek | Güvenli | Consumer tolerant reader ise |
| Yeni endpoint, yeni opsiyonel query parametresi | Güvenli | Varsayılan davranış değişmemeli |
| Response'tan alan kaldırmak ya da yeniden adlandırmak | Kırar | Kimse okumuyorsa kırmaz; bunu bilmek gerekir |
| Alanın tipini değiştirmek (string → object) | Kırar | Yeni alan adıyla yapılmalı |
| Request'e zorunlu alan eklemek | Kırar | Eski consumer'lar o alanı göndermiyor |
| Enum'a yeni değer eklemek | Çoğu zaman kırar | Consumer bilinmeyen değeri reddedebilir |
| Status code ya da hata formatını değiştirmek | Kırar | 404 yerine 200 + boş body gibi |
| Alanın anlamını değiştirmek | Sessizce kırar | Hiçbir şema aracı yakalayamaz |
Tablonun iki satırı bir koşula bağlı ve ikisi de aynı soruya çıkıyor: consumer ne yapıyor? Alan eklemek, consumer bilmediği alanı yok sayıyorsa güvenli. Alan kaldırmak, o alanı okuyan consumer yoksa güvenli. Yani bir değişikliğin breaking olup olmadığı yalnız provider'ın koduna bakarak anlaşılmıyor; consumer'ların ne okuduğunu bilmek gerekiyor. Bu yazının geri kalanı bu iki tarafı ele alıyor: consumer'ın toleransı ve provider'ın consumer'ları tanıması.
Tolerant Reader: Consumer'ın Payına Düşen
Martin Fowler'ın 2011'de Tolerant Reader adını verdiği tutum, Jon Postel'in robustness principle'ının ("gönderirken muhafazakâr, alırken toleranslı ol") service'ler arası karşılığı: consumer yalnız ihtiyacı olan alanları okur, gerisine aldırmaz, bilmediği alan geldiğinde hata vermez. Notification'ın Catalog client'ı tam olarak bunu yapıyor:
// Notification yalnız ihtiyacı olan alanları tanımlar (tolerant reader)
export interface BookForEmail {
title: string;
author: string;
}
export async function getBookForEmail(baseUrl: string, bookId: string): Promise<BookForEmail> {
const response = await fetch(`${baseUrl}/api/books/${bookId}`, { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`Catalog returned ${response.status}`);
const body = await response.json();
return { title: body.title, author: body.author };
}
Burada iki karar var. Birincisi, Notification Catalog'un bütün Book modelini değil, kendi ihtiyacı olan iki alanı tanımlıyor; Catalog yarın isbn, pageCount ya da authors eklese bu kod etkilenmiyor. İkincisi, Notification'ın elindeki tip Catalog'un kodundan üretilmiş ortak bir paket değil, kendi tanımı. .NET tarafında ilk refleks contract'ları herkesin referans verdiği bir NuGet paketine koymak olur; Messaging ve Event-Driven Architecture serisinin "Message Contract'ları ve Schema Evolution: CloudEvents, AsyncAPI ve Versioning" yazısında bu paketin nasıl bir deploy bağımlılığına dönüştüğünü ve System.Text.Json'ın bilinmeyen alanları varsayılan olarak yok saydığını gerçek çıktılarla görmüştük. HTTP contract'ları için de aynı ders geçerli: her consumer kendi DTO'sunu (data transfer object) yazar.
Tolerant reader'ın bir sınırı var: consumer'ı yalnız ekleme türü değişikliklere karşı korur. Notification ne kadar toleranslı olursa olsun, author alanı kalktığında body.author undefined döner ve müşteriye "Dune, undefined" yazan bir e-posta gider. Bu da sessiz kırılmanın en kötü türü: exception yok, yalnız yanlış veri.
Expand-Contract: Kırıcı Değişikliği Adımlara Bölmek
author'ı authors'a çevirmek gerçekten gerekiyorsa, bunu tek deploy'da yapmak yerine Danilo Sato'nun 2014'te Parallel Change adıyla anlattığı üç adıma bölersin. Expand: yeni alan eskisinin yanına eklenir, ikisi birlikte doldurulur; eski consumer'lar hiçbir şey fark etmez. Migrate: consumer'lar kendi takvimlerinde yeni alana geçer. Contract: eski alanı okuyan kimse kalmadığında eski alan kaldırılır. Catalog'un expand adımındaki response'u şöyle:
// Expand: eski alan duruyor, yeni alan yanına eklendi
public sealed record BookV2Expand(Guid Id, string Title, string Author, IReadOnlyList<AuthorDto> Authors, Money ListPrice);
Bu, tek değişiklik yerine en az üç deploy ve haftalarca süren bir geçiş demek; bedeli bu. Karşılığında hiçbir an "herkes aynı gece deploy etsin" koordinasyonu gerekmiyor, ki independent deployability'nin ta kendisi bu. Contract adımının ne zaman atılabileceğini bilmek ise ayrı bir problem: eski alanı kimin okuduğunu bilmeden onu silemezsin. Birazdan Pact bu soruyu cevaplayacak.
Bir alan değil de bütün bir endpoint emekliye ayrılacaksa, consumer'lara bunu runtime'da söylemenin standart bir yolu var. IETF'in (Internet Engineering Task Force) Mart 2025'te yayımladığı RFC (Request for Comments) 9745, Deprecation header'ını tanımlıyor; 2019 tarihli RFC 8594'teki Sunset header'ı da kaynağın hangi tarihten sonra yanıt vermeyeceğini söylüyor. Catalog eski fiyat endpoint'ini şöyle işaretliyor:
DateTimeOffset deprecatedAt = new(2026, 12, 1, 0, 0, 0, TimeSpan.Zero);
DateTimeOffset sunsetAt = new(2027, 3, 1, 0, 0, 0, TimeSpan.Zero);
app.MapGet("/api/books/{id:guid}/price", (Guid id, HttpResponse response) =>
{
// RFC 9745: Deprecation, structured field Date (@unix saniye); RFC 8594: Sunset, HTTP-date
response.Headers["Deprecation"] = $"@{deprecatedAt.ToUnixTimeSeconds()}";
response.Headers["Sunset"] = sunsetAt.ToString("R");
response.Headers.Link = "</docs/catalog/price-migration>; rel=\"deprecation\"";
return Results.Ok(new { amount = 14.90m, currency = "EUR" });
});
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 07 Nov 2026 23:45:00 GMT
Server: Kestrel
Transfer-Encoding: chunked
Deprecation: @1796083200
Sunset: Mon, 01 Mar 2027 00:00:00 GMT
Link: </docs/catalog/price-migration>; rel="deprecation"
{"amount":14.90,"currency":"EUR"}
RFC 9745 bu header'ı bir ipucu olarak tanımlıyor: endpoint çalışmaya devam eder, yalnız consumer uyarılmış olur. Sunset tarihi Deprecation tarihinden önce olamaz. Bunun pratik değeri, consumer'ların HTTP client'larında bu header'ı görünce log ya da metric üretmesi; böylece emekliye ayrılan endpoint'i hâlâ kimin çağırdığı Catalog'un trafiğinden değil, consumer'ların kendi dashboard'larından da görülür.
Versioning: Son Çare, İlk Refleks Değil
Service'ler arası contract'larda versioning, expand-contract'ın yetmediği durumlar içindir: alanın anlamı değişiyorsa, response'un yapısı köklü biçimde değişiyorsa, ya da eski ve yeni davranışın aylarca yan yana yaşaması gerekiyorsa. URL (Uniform Resource Locator), query string ya da header ile sürümlemenin mekaniğini ASP.NET Core Web API serisinin "ASP.NET Core Web API Versioning: Asp.Versioning ile URL, Query String ve Header" yazısında gördük; burada önemli olan service seviyesindeki sonucu.
Sam Newman'ın Building Microservices (2. baskı, 2021) kitabının "Implementing Microservice Communication" bölümündeki öneri, aynı service'in içinde eski ve yeni endpoint'i yan yana sunmak: /v1/books/{id} ile /v2/books/{id} aynı deployment'ta, aynı veritabanı üzerinde çalışır, v1 de büyük ihtimalle v2'nin üstüne yazılmış ince bir adapter olur. Alternatifi, Catalog'un iki farklı sürümünü ayrı ayrı deploy edip trafiği sürüme göre route etmek. Bu ikincisi kâğıt üzerinde temiz görünür ama bir bug'ı iki kod tabanında düzeltmek, iki sürümün aynı veriyi farklı yorumlaması ve hangi consumer'ın hangi sürüme gittiğini izlemek gibi bedeller getirir. Newman bunu kısa süreli geçişler dışında önermiyor; ben de önermiyorum.
Versioning'in bir de gizli bedeli var: her yeni sürüm, eskisini emekliye ayırma sorumluluğunu da yanında getirir. v1'i kimin kullandığını bilmiyorsan v1'i hiçbir zaman kapatamazsın ve birkaç yıl sonra Catalog beş sürümü birden taşır. Bu yüzden URL'de sürüm numarası görmek, contract'ı yönettiğin anlamına gelmez; consumer'ların kim olduğunu ve neye dayandığını bilmek gerekir.
Event Contract'ları da Aynı Kurala Tabi
Contract yalnız HTTP endpoint'leri değil; Catalog'un yayınladığı BookPriceChanged de bir contract ve consumer'ları (Ordering'in local kopyası, arama indeksi) Catalog'dan bağımsız deploy ediliyor. Event'lerde geri bildirim HTTP'den de geç gelir: queue'da eski formatta message'lar bekler, DLQ (dead-letter queue) günler önceki bir message'ı yeniden işlemeye gönderebilir. Bu yüzden event'lerde full compatibility (eski ve yeni consumer'ın eski ve yeni veriyi okuyabilmesi), versiyonlu type adları ve schema registry gibi ek araçlar devreye giriyor. Bunların hepsini, enum'a değer eklemenin neden kırdığını ve CloudEvents envelope'unu Messaging ve Event-Driven Architecture serisinin "Message Contract'ları ve Schema Evolution: CloudEvents, AsyncAPI ve Versioning" yazısında ayrıntılı işledik; burada tekrar etmiyorum. Bu yazıdaki ilkeler (tolerant reader, expand-contract, anlam değişikliğinin yeni ad gerektirmesi) event'ler için de aynen geçerli. Pact'in message pact desteği de var, ama aşağıdaki örnek HTTP üzerinden.
Consumer-Driven Contract Test: Pact ile Notification ve Catalog
Consumer'ların beklentilerini makinenin okuyabileceği bir yere koymanın en yaygın aracı Pact. Akış iki adımlı. Önce consumer kendi test'inde, provider'ı taklit eden bir mock server'a karşı çalışır ve ondan ne beklediğini yazar; test geçerse Pact bu beklentileri bir JSON dosyasına (pact file) kaydeder. Sonra provider kendi pipeline'ında bu dosyayı alır, içindeki her request'i gerçek uygulamaya gönderir ve gelen response'un beklentiye uyup uymadığını kontrol eder. İki taraf da birbirini ayağa kaldırmadan test edilir; ortak bir test ortamı gerekmez. Pact'in diller arası çalışması da tam bu senaryo için değerli: consumer TypeScript, provider .NET.
Notification'ın consumer test'i, Vitest ile:
import { PactV4, MatchersV3 } from '@pact-foundation/pact';
import { describe, it, expect } from 'vitest';
import { getBookForEmail } from './catalogClient';
const { like } = MatchersV3;
const pact = new PactV4({ consumer: 'Notification', provider: 'Catalog', dir: './pacts' });
const duneId = '0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11';
describe('Catalog API', () => {
it('returns title and author for an order email', async () => {
await pact
.addInteraction()
.given('book Dune exists', { bookId: duneId })
.uponReceiving('a request for a book used in an order email')
.withRequest('GET', `/api/books/${duneId}`, (b) => b.headers({ Accept: 'application/json' }))
.willRespondWith(200, (b) => b.jsonBody({ title: like('Dune'), author: like('Frank Herbert') }))
.executeTest(async (mockServer) => {
const book = await getBookForEmail(mockServer.url, duneId);
expect(book).toEqual({ title: 'Dune', author: 'Frank Herbert' });
});
});
});
✓ src/catalogClient.pact.test.ts (1 test) 22ms
Test Files 1 passed (1)
Tests 1 passed (1)
Üç ayrıntı önemli. like('Dune'), değerin birebir "Dune" olmasını değil, aynı tipte (string) olmasını istiyor; provider'ın test verisi farklı bir kitap döndürse de contract kırılmaz. Response body'de yalnız title ve author var; Notification listPrice'a dayanmadığı için onu contract'a da yazmıyor. given('book Dune exists') ise bir provider state: provider tarafında bu isimle bir hazırlık adımı çalışacak ve test verisini kuracak. Test geçince pacts/Notification-Catalog.json oluşuyor; içindeki response kısmı, okunur olsun diye satırları sıkıştırılmış hâliyle şöyle:
"response": {
"body": {
"content": { "author": "Frank Herbert", "title": "Dune" },
"contentType": "application/json",
"encoded": false
},
"matchingRules": {
"body": {
"$.author": { "combine": "AND", "matchers": [ { "match": "type" } ] },
"$.title": { "combine": "AND", "matchers": [ { "match": "type" } ] }
}
},
"status": 200
}
Provider tarafında Catalog, bu dosyayı PactNet ile gerçek ASP.NET Core uygulamasına karşı doğruluyor. Örneği tek bir console uygulamasında topladım: uygulama Kestrel'de ayağa kalkıyor, verifier pact file'daki request'i ona gönderiyor:
// Yalnız test ortamında açılan provider state endpoint'i
app.MapPost("/provider-states", (ProviderStateRequest state) =>
{
if (state.State == "book Dune exists")
store.Seed(Guid.Parse(state.Params!["bookId"].GetString()!), "Dune", "Frank Herbert");
return Results.Ok();
});
app.Urls.Add(providerUri.ToString());
await app.StartAsync();
string pactFile = Path.Combine("..", "consumer", "pacts", "Notification-Catalog.json");
try
{
new PactVerifier("Catalog", new PactVerifierConfig())
.WithHttpEndpoint(providerUri)
.WithFileSource(new FileInfo(pactFile))
.WithProviderStateUrl(new Uri(providerUri, "/provider-states"))
.Verify();
Console.WriteLine($"[{mode}] verification passed");
}
catch (PactFailureException ex)
{
Console.WriteLine($"[{mode}] {ex.GetType().Name}: {ex.Message}");
}
Bugünkü Catalog (v1) için doğrulama geçiyor. Önce Tahmin Et'teki ilk değişikliği, yani author'ı kaldırıp authors array'ini koyan sürümü (v2-breaking) çalıştırınca verifier'ın çıktısı:
Verifying a pact between Notification and Catalog
a request for a book used in an order email (0s loading, 180ms verification)
Given book Dune exists
returns a response which
has status code 200 (OK)
includes headers
"Content-Type" with value "application/json" (OK)
has a matching body (FAILED)
Failures:
1) Verifying a pact between Notification and Catalog Given book Dune exists - a request for a book used in an order email
1.1) has a matching body
$ -> Actual map is missing the following keys: author
...
There were 1 pact failures
[v2-breaking] PactVerificationFailedException: Pact verification failed
Hata Catalog'un pipeline'ında, Catalog deploy edilmeden önce ve Notification'ın adıyla birlikte geliyor; "bu alanı kim kullanıyor?" sorusunun cevabı test raporunun içinde. Aynı değişikliği expand-contract'ın expand adımıyla yapan sürüm (v2-expand: author duruyor, authors eklenmiş) geçiyor: [v2-expand] verification passed. Daha öğretici olan üçüncü deneme: response'tan listPrice'ı tamamen çıkaran sürüm de Notification'ın contract'ına karşı geçiyor ([v1-no-price] verification passed), çünkü Notification fiyata dayanmıyor. Bu doğru bir sonuç, ama yalnız Ordering de kendi pact'ini yayınlamışsa güvenli bir sonuç. Consumer-driven contract'ın gücü ve zaafı aynı yerde: provider, yalnız contract yazan consumer'ları korur.
Gerçek bir kurulumda pact file'lar dosya sisteminden değil, bir Pact Broker'dan (açık kaynak Pact Broker ya da barındırılan PactFlow) gelir. Broker hangi consumer sürümünün hangi provider sürümüyle doğrulandığını bir matris olarak tutar ve pact-broker can-i-deploy --pacticipant Catalog --version 2.4.0 --to-environment production sorusuna, o ortamda şu an çalışan consumer sürümlerine bakarak evet ya da hayır der; deploy sonrası record-deployment ile broker'a hangi sürümün nerede olduğu bildirilir. Bunun CI (continuous integration) pipeline'ındaki yeri, serinin "Production'da Microservice" yazısının konusu.
Sık Düşülen Tuzaklar
Contract test'i fonksiyonel test sanmak
Pact, Catalog'un doğru kitabı döndürdüğünü değil, Notification'ın beklediği şekilde döndürdüğünü doğrular. "Stokta yoksa 409 dönmeli" gibi iş kuralları provider'ın kendi test'lerinde kalır. Consumer test'ine her senaryoyu yazmak, provider'ın iç davranışını consumer'ın repository'sine taşır ve her küçük değişiklikte iki ekibi birden kırar.
Matcher yerine birebir değer beklemek
like yerine title: 'Dune' yazarsan provider'ın test verisi tam o kitabı döndürmek zorunda kalır. Contract'a yalnız gerçekten önemli olanı yaz: tip, format, zorunlu alanlar.
Kimse pact yayınlamıyorsa "test geçti" demek
listPrice denemesi bunu gösterdi: contract yazmayan consumer görünmezdir. Pact'i benimsemek bir ekip kararı değil, sistemin kararı; bir consumer'ı dışarıda bırakmak, o consumer için hiçbir koruma olmaması demek.
Expand'i yapıp contract'ı unutmak
author ve authors yan yana yıllarca yaşarsa, yeni consumer'lar hangisini kullanacağını bilemez ve iki alan farklı değerler göstermeye başlayabilir. Contract adımının bir tarihi ve bir sahibi olmalı; Deprecation header'ı ve broker'daki consumer listesi bu tarihi planlamanı sağlar.
Her değişikliği yeni sürümle çözmek
v2, v3, v4 kolay açılır, zor kapanır. Önce değişikliği uyumlu yapmayı dene; yeni sürüm, anlamı değişen contract'lar için son çare.
Küçük Bir Deneme
★☆☆ Notification'ın consumer test'inde author için like('Frank Herbert') yerine 'Frank Herbert' yaz ve provider state'te başka bir yazar seed et. Verifier ne diyor? Sonra like'a geri dön ve farkı açıkla.
★★☆ Ordering için ikinci bir consumer test'i yaz: Ordering title ve listPrice.amount ile listPrice.currency'ye dayanıyor. Aynı provider'a iki pact file verip v1-no-price sürümünü tekrar doğrula. Bu kez hangi consumer'ın adıyla hata alıyorsun?
★★★ author → authors geçişini üç deploy olarak planla: her adımda Catalog ne döndürüyor, Notification ve BFF ne okuyor, hangi pact'ler geçiyor? Contract adımını atabilmek için broker'da hangi sorunun cevabını görmen gerekir?
Contract'lar artık kontrollü değişiyor, ama Angular storefront'un kitap detay sayfası hâlâ Catalog, Inventory ve Ordering'e ayrı ayrı gidiyor. Bir sonraki yazıda bu çağrıların tek bir noktada nasıl buluştuğuna, API Gateway ve Backend for Frontend'e bakacağız: edge'de ne olmalı, ne olmamalı.