Mesaj Contract'ları: Serialization, Versioning ve Geriye Dönük Uyumluluk
Contract'ı domain entity'sinden ayrı record'larla tut, yalnızca opsiyonel alan ekleyerek ilerle, System.Text.Json'da RespectRequiredConstructorParameters ve RespectNullableAnnotations'ı aç, sürümü type header'ında taşı. Varsayılan ayarlarla rename'in 0 TL'lik faturaya dönüşmesi, enum'a eklenen değerin eski consumer'ı kırması ve Schema Registry'nin uyumsuz şemayı 409 ile reddetmesi gerçek çıktılarla.
Sürüm notu: Bu yazıdaki kodlar .NET 10, System.Text.Json 10, Confluent.SchemaRegistry 2.16.0 ve Confluent Schema Registry 8.1.0 ile derlenip denenmiştir.
Önce Tahmin Et
Order API (Application Programming Interface) ekibi OrderPlaced'daki Total alanının adını daha açıklayıcı olsun diye TotalAmount yaptı ve deploy etti. Billing henüz eski sürümde, System.Text.Json'ın JsonSerializerDefaults.Web ayarlarıyla mesajı okuyor. Billing ne görür: bir JsonException mı, yoksa başka bir şey mi? Fatura hangi tutarla kesilir?
İkinci soru: ShippingMethod enum'una SameDay diye yeni bir değer eklendi ve yeni producer bu değeri göndermeye başladı. Enum'ları string olarak serialize eden eski consumer bu mesajı okuyabilir mi?
Contract Nedir, Nerede Durur?
Mesaj contract'ı producer ile consumer arasındaki tek ortak dil: mesajın adı, alanları, tipleri ve anlamı. Bookstore'da Bookstore.Contracts adında, içinde yalnızca record'lar bulunan küçük bir assembly var:
namespace Bookstore.Contracts;
public sealed record OrderPlaced(
Guid OrderId,
Guid CustomerId,
decimal Total,
string Currency,
DateTimeOffset PlacedAt,
IReadOnlyList<OrderPlacedItem> Items)
{
public const string MessageType = "bookstore.order-placed.v1";
}
public sealed record OrderPlacedItem(Guid BookId, int Quantity, decimal UnitPrice);
Bu assembly'yi paylaşmanın (NuGet paketi ya da aynı repository'de proje referansı) iki yüzü var. Avantajı derleyicinin yardımı: alan adı yanlış yazılamaz, refactoring her yere yayılır. Dezavantajı da aynı şey: Order API'deki bir "rename" refactoring'i, Billing'in kodunu derlemeden önce de bozar, ve paylaşılan paket servisleri aynı sürüme kilitleyerek bağımsız deploy'u zorlaştırır. Diğer uç, contract'ı hiç paylaşmamak: her consumer mesajın yalnızca ihtiyaç duyduğu alanlarını kendi record'unda tanımlar. Bu "tolerant reader" yaklaşımı servisleri birbirinden en çok ayıran yol. Pratik bir orta yol: aynı ekibin servisleri arasında paylaşılan contract paketi, ekipler ya da şirketler arası sınırda paylaşılmayan, şemayla (JSON Schema, Avro, AsyncAPI) belgelenen contract. Kavramsal tarafı Messaging ve Event-Driven Architecture serisinin "Message Contract'ları ve Schema Evolution" ve Microservices serisinin "Service Contract'ları ve Versioning" yazılarında; burada .NET kodunda nasıl uygulanacağına bakıyoruz.
Ne olursa olsun bir kuralı çiğneme: domain entity'sini mesaj olarak yayınlama. EF (Entity Framework) Core'un Order entity'si navigation property'leri, iç durumu ve veritabanı şemasına bağlı alanlarıyla mesaja girerse, veritabanında yaptığın her değişiklik bir contract değişikliğine dönüşür ve consumer'lar senin iç modelini öğrenir. OrderPlaced ayrı bir record ve OrderItem entity'si yerine OrderPlacedItem kullanıyor; bu bilinçli.
System.Text.Json Ayarlarını Contract'ın Parçası Yapmak
Serialization ayarları da contract'ın parçası. Producer camelCase yazıp consumer PascalCase beklerse, alanlar sessizce boş kalır. Ayarları tek yerde, contract assembly'sinde tutuyoruz:
public static class MessageJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
RespectRequiredConstructorParameters = true,
RespectNullableAnnotations = true
};
}
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
RespectRequiredConstructorParameters = true,
RespectNullableAnnotations = true)]
[JsonSerializable(typeof(V1.OrderPlaced))]
[JsonSerializable(typeof(V2.OrderPlaced), TypeInfoPropertyName = "OrderPlaced2")]
[JsonSerializable(typeof(V1.OrderShipped))]
public sealed partial class ContractsJsonContext : JsonSerializerContext;
Ayarların her biri bir karar:
JsonSerializerDefaults.Web: camelCase property adları ve büyük küçük harf duyarsız okuma. Mesajı Java ya da Node.js ile yazılmış bir servis de okuyacaksa camelCase en az sürprizli seçim.JsonStringEnumConverter: enum'lar sayı değil isim olarak yazılır.2yerine"Express"hem okunur hem de enum'un sırası değiştiğinde anlamı kaymaz.RespectRequiredConstructorParametersveRespectNullableAnnotations: .NET 9 ile gelen bu iki ayar, eksik zorunlu alanları ve nullable olmayan alanlara gelennull'ı exception'a çevirir. Birazdan neden hayati olduklarını göreceğiz.- Source generation (
JsonSerializerContext): reflection yerine derleme zamanında üretilen serializer. Her mesajda reflection maliyetini ortadan kaldırır, trimming ve Native AOT (Ahead-of-Time) ile uyumludur.
Source generator ile serialize edilmiş OrderPlaced:
{"orderId":"01a1b8de-6a5a-70fd-8aab-7f9dd66cdbe7","customerId":"3f2b8c1e-7d4a-4e9b-9c55-1a2b3c4d5e6f","total":1220.50,"currency":"TRY","placedAt":"2026-11-08T01:55:45+00:00","items":[{"bookId":"0b9d6a4e-1c2f-4a8b-b3d7-5e6f7a8b9c0d","quantity":2,"unitPrice":450.00}]}
Gövdenin dışında her mesaj aynı üç metadata'yı taşır: MessageId (her mesaja bir kez verilir, redelivery'de değişmez), CorrelationId (siparişi ya da isteği izlemek için) ve type (bookstore.order-placed.v1 gibi contract adı ve sürümü). RabbitMQ'da bunlar BasicProperties'in MessageId, CorrelationId ve Type alanları, Azure Service Bus'ta ServiceBusMessage'ın MessageId, CorrelationId property'leri ve ApplicationProperties["type"], Kafka'da mesaj header'ları. Tip bilgisini gövdeye değil metadata'ya koymak, consumer'ın gövdeyi açmadan doğru deserializer'ı seçmesini sağlar.
Additive Değişiklik: OrderPlaced v1'den v2'ye
Notification servisi sipariş onayı e-postası atmak için müşterinin e-posta adresine ihtiyaç duyuyor; şu an bunu her mesajda Customer API'ye sorarak buluyor. OrderPlaced'a e-postayı eklemek istiyoruz. Deneyde iki sürümü aynı anda çalıştırabilmek için onları V1 ve V2 namespace'lerine koyduk; gerçek bir repository'de tek bir OrderPlaced vardır ve iki farklı deploy'u temsil ederler:
// v2: yalnızca sona, opsiyonel bir alan eklendi
public sealed record OrderPlaced(
Guid OrderId,
Guid CustomerId,
decimal Total,
string Currency,
DateTimeOffset PlacedAt,
IReadOnlyList<OrderPlacedItem> Items,
string? CustomerEmail = null);
Eski consumer yeni mesajı, yeni consumer eski mesajı okuyabiliyor mu?
v1 reads v2: 1220.50
v2 reads v1: CustomerEmail=null
İkisi de çalışıyor. System.Text.Json varsayılan olarak bilinmeyen alanları yok sayar; eski consumer customerEmail'i görmezden geldi. Yeni alan nullable ve varsayılan değerli olduğu için yeni consumer, eski mesajda alanın olmamasını hata saymadı. Additive değişikliğin kuralları kısa: yeni alan opsiyonel olsun (nullable ya da varsayılan değerli), eski alanların adı, tipi ve anlamı değişmesin, zorunlu bir alan hiç silinmesin. Consumer tarafındaki kural da şu: yeni alan gelmediğinde ne yapacağını bil. Notification, CustomerEmail null ise eskisi gibi Customer API'ye sormaya devam ediyor.
Kırıcı Değişiklikler: Rename, Null ve Enum
Yazının başındaki ilk soru. Producer artık totalAmount yazıyor, eski consumer total bekliyor:
{"orderId":"01a1b8de-6a5a-70fd-8aab-7f9dd66cdbe7","customerId":"3f2b8c1e-7d4a-4e9b-9c55-1a2b3c4d5e6f","totalAmount":1220...
v1 reads renamed (default options): Total=0
v1 reads renamed (strict options): JsonException: JSON deserialization for type 'Bookstore.Contracts.V1.OrderPlaced' was missing required properties including: 'total'.
Varsayılan ayarlarla exception yok. Eksik constructor parametresi tipin varsayılan değerini alıyor ve Billing 1.220,50 TL'lik siparişe 0 TL'lik fatura kesiyor. Bu, mesajlaşmadaki en pahalı hata türü: sessiz veri bozulması. RespectRequiredConstructorParameters = true ile aynı mesaj JsonException fırlatıyor. Exception can sıkıcıdır ama görünür; mesaj DLQ'ya (Dead Letter Queue) düşer, alarm çalar ve kimse yanlış fatura almaz.
Aynı mantık null için de geçerli. Producer'daki bir hata currency'yi null gönderdiğinde:
v1 reads null currency (default options): Currency=null
v1 reads null currency (strict options): JsonException: The constructor parameter 'Currency' on type 'Bookstore.Contracts.V1.OrderPlaced' doesn't allow null values. Consider updating its nullability annotation. Path: $.currency | LineNumber: 0 | BytePositionInLine: 133.
Varsayılan ayarlarla string Currency (nullable olmayan) bir property'ye null giriyor ve NullReferenceException çok ileride, alakasız bir satırda patlıyor. RespectNullableAnnotations hatayı mesajın sınırında yakalıyor.
İkinci soru, enum'a yeni değer:
{"orderId":"01a1b8de-6a5a-70fd-8aab-7f9dd66cdbe7","method":"Express"}
v1 reads new enum value: JsonException: The JSON value could not be converted to Bookstore.Contracts.V1.OrderShipped. Path: $.method | LineNumber: 0 | BytePositionInLine: 68.
Enum'a değer eklemek, producer açısından masum görünen ama eski consumer'lar için kırıcı bir değişiklik. Üç seçenek var: yeni değeri göndermeden önce bütün consumer'ları güncellemek; contract'ta enum yerine string kullanıp bilinmeyen değeri consumer'ın kendi mantığıyla ele alması; ya da Unknown değerine düşen özel bir converter yazmak. Hangisini seçersen seç, kararı contract'ı tasarlarken ver.
Gerçekten kırıcı bir değişiklik gerekiyorsa (alan adı, tip ya da anlam değişiyorsa) mesajı yerinde değiştirme. Expand-contract sırası şöyle işler: önce producer bir süre iki alanı birden yazar (total ve totalAmount) ya da yeni bir type ile ikinci bir mesaj yayınlar; consumer'lar tek tek yeni alana geçer; hiçbir consumer eski alanı okumadığından emin olunca producer eski alanı bırakır. Queue'da ya da Kafka log'unda bekleyen eski mesajları unutma: retention süresince eski sürüm mesajlar okunmaya devam eder.
Type Header ile Sürüm Seçmek ve Upcasting
Consumer'ın kodunu her sürüm için çatallandırmak yerine, sınırda okuyup içeride tek modelle çalışmak daha temiz:
public static class OrderPlacedReader
{
// type header'ına göre doğru sürümü oku, consumer içeride hep en yeni modelle çalışsın
public static V2.OrderPlaced Read(string type, ReadOnlySpan<byte> body) => type switch
{
"bookstore.order-placed.v2" => JsonSerializer.Deserialize(body, ContractsJsonContext.Default.OrderPlaced2)!,
"bookstore.order-placed.v1" => Upcast(JsonSerializer.Deserialize(body, ContractsJsonContext.Default.OrderPlaced)!),
_ => throw new NotSupportedException($"Unknown message type '{type}'")
};
private static V2.OrderPlaced Upcast(V1.OrderPlaced m) =>
new(m.OrderId, m.CustomerId, m.Total, m.Currency, m.PlacedAt, m.Items, CustomerEmail: null);
}
bookstore.order-placed.v1: total=1220.50 email=null
bookstore.order-placed.v2: total=1220.50 email=ada@example.com
unknown type: NotSupportedException: Unknown message type 'bookstore.order-placed.v3'
Upcasting, eski sürüm mesajı okuma anında yeni modele çevirmek demek; Event Sourcing'de event versioning için de aynı teknik kullanılır. Bilinmeyen bir type geldiğinde exception fırlatmak bilinçli: tahmin etmeye çalışmak yerine mesajı DLQ'ya düşür ve yeni sürümü destekleyen consumer'ı deploy et. Additive değişikliklerde type'ı artırmak zorunlu değil; v1 consumer v2 mesajını zaten okuyabiliyor. Sürüm numarasını, eski consumer'ın yeni mesajı yanlış anlayacağı kırıcı değişikliklere sakla.
Kafka'da Schema Registry ve Compatibility Modları
JSON'da (JavaScript Object Notation) contract kuralı insanların disiplinine bağlı. Kafka ekosisteminde Schema Registry bu disiplini makineye devreder: producer mesajı yazmadan önce şemayı registry'ye kaydeder, registry yeni şemanın eskileriyle uyumlu olup olmadığını kontrol eder ve uyumsuz şemayı reddeder. Mesaja şemanın kendisi değil, birkaç byte'lık şema id'si eklenir. Avro, Protobuf ve JSON Schema destekleniyor. Bookstore'un OrderPlaced'ı için Avro şeması (v1):
{
"type": "record", "name": "OrderPlaced", "namespace": "Bookstore.Contracts",
"fields": [
{ "name": "orderId", "type": "string" },
{ "name": "customerId", "type": "string" },
{ "name": "total", "type": { "type": "bytes", "logicalType": "decimal", "precision": 18, "scale": 2 } },
{ "name": "currency", "type": "string" }
]
}
Para tutarı için double değil, Avro'nun decimal logical type'ı: C#'taki decimal'in karşılığı bu. Uyumluluğu .NET'ten, Confluent.SchemaRegistry paketiyle kontrol ediyoruz:
using CachedSchemaRegistryClient registry = new(new SchemaRegistryConfig { Url = "http://localhost:17240" });
string subject = OrderPlacedSchemas.Subject;
int id = await registry.RegisterSchemaAsync(subject, new Schema(OrderPlacedSchemas.V1, SchemaType.Avro));
Console.WriteLine($"v1 registered with id {id}");
foreach ((string name, string schema) in new[]
{
("v2 with default", OrderPlacedSchemas.V2WithDefault),
("v2 without default", OrderPlacedSchemas.V2WithoutDefault),
("renamed field", OrderPlacedSchemas.Renamed)
})
{
bool ok = await registry.IsCompatibleAsync(subject, new Schema(schema, SchemaType.Avro));
Console.WriteLine($"{name,-20} compatible: {ok}");
}
v1 registered with id 3
v2 with default compatible: True
v2 without default compatible: False
renamed field compatible: False
SchemaRegistryException (409): Schema being registered is incompatible with an earlier schema for subject "bookstore.orders-value", details: [{errorType:'READER_FIELD_MISSING_DEFAULT_VALUE', description:'The field 'totalAmount' at path '/fields/2' in the new schema has no default value and
v2 registered with id 4; versions: 1, 2
Registry'nin varsayılan modu BACKWARD (GET /config bize {"compatibilityLevel":"BACKWARD"} döndü): yeni şema, son şemayla yazılmış veriyi okuyabilmeli. "default": null ile eklenen customerEmail bu kuralı geçiyor; varsayılansız eklenen alan ve yeniden adlandırılan alan geçemiyor, çünkü yeni şemayla okuyan biri eski mesajda o alanı bulamayacak. Rename denemesini kaydetmeye çalıştığımızda registry 409 ile reddetti ve nedeni yazdı. Yani hatalı contract değişikliği production'da Billing'i bozmadan, producer'ın deploy pipeline'ında yakalanıyor.
Diğer modlar: FORWARD (eski şema yeni veriyi okuyabilmeli; önce producer'lar güncellenir), FULL (ikisi birden), bunların yalnızca son sürümle değil bütün geçmişle karşılaştıran _TRANSITIVE varyantları ve NONE. Modu subject bazında ayarlayabilirsin. BACKWARD "önce consumer'lar güncellenir" demek; deploy sırasını buna göre kur.
Üretimde Dikkat
Contract değişikliğini kod incelemesinde ayrı bir sınıf gibi ele al. Bookstore.Contracts'taki bir diff, iki servisin arasındaki sözleşmeyi değiştiriyor; incelemede "eski consumer bunu okuyabilir mi?" sorusunu sor. Bunu otomatikleştirmek için contract testleri, serinin test yazısının konusu.
Bilinmeyen alanları loglamayı düşün. System.Text.Json'ın JsonUnmappedMemberHandling.Disallow ayarı bilinmeyen alanda exception fırlatır; consumer'da bunu açmak tolerant reader'ı bozar, ama test ortamında açmak "producer ne gönderiyor, ben ne okuyorum" farkını erken gösterir.
Mesaj boyutunu izle. Contract büyüdükçe mesaj büyür; Azure Service Bus Standard'da 256 KB sınırı var. Büyük veriyi mesajda değil, bir referansla taşı.
Kişisel veriyi bilinçli ekle. CustomerEmail artık her OrderPlaced'ın içinde ve mesaj nereye giderse gidiyor: Kafka log'unda retention boyunca, DLQ'da, loglarda. KVKK (Kişisel Verilerin Korunması Kanunu) açısından mesajda hangi kişisel verinin taşındığını belgele.
Sık Düşülen Tuzaklar
Domain entity'sini mesaj olarak yayınlamak. İç modelin her değişikliği contract değişikliği olur.
Varsayılan System.Text.Json ayarlarıyla contract okumak. Rename sessizce 0 TL'lik faturaya, null çok uzakta bir NullReferenceException'a dönüşür.
Enum'a değer eklemeyi additive sanmak. Eski consumer'lar exception fırlatır.
Producer ve consumer'da farklı serialization ayarları. Biri PascalCase yazar, diğeri camelCase okur; büyük küçük harf duyarsız okuma bunu bazen gizler, bazen gizlemez.
Kırıcı değişikliği tek deploy'da yapmak. Queue'da bekleyen eski mesajlar ve henüz güncellenmemiş consumer'lar aynı anda bozulur.
Schema Registry'yi NONE modunda çalıştırmak. Registry'nin varlık sebebi olan kontrolü kapatmış olursun.
Küçük Bir Deneme
★☆☆ OrderPlacedItem'a opsiyonel bir string? Title alanı ekle ve v1 consumer'ın yeni mesajı hâlâ okuyabildiğini doğrula.
★★☆ ShippingMethod için, bilinmeyen değerleri Unknown'a çeviren bir JsonConverter<ShippingMethod> yaz. SameDay içeren mesajın artık exception fırlatmadığını göster.
★★★ Subject'in modunu FULL_TRANSITIVE'e çevir (PUT /config/bookstore.orders-value) ve üç şema sürümü üzerinden hangi değişikliklerin hâlâ kabul edildiğini dene. BACKWARD ile sonuçları bir tabloda karşılaştır.
Buraya kadar her broker'ın client'ını doğrudan kullandık: connection, channel, offset, lock ve contract'ı kendimiz yönettik. Bir sonraki yazıda bu işin bir kısmını bir abstraction'a devrediyoruz: MassTransit ile consumer, publish ve send, broker topology convention'ları, aynı kodun RabbitMQ ve Azure Service Bus'ta çalışması ve MassTransit v9'un lisans gerçeği.