Mert Özen Her Satırda Daha İleri
Backend gRPC ve GraphQL — #01

Protocol Buffers ile Contract Tasarlamak: .proto, Field Number'lar ve Geriye Uyumluluk

Mert Özen 14 Kas 2026 16 dk 3 görüntülenme
Protocol Buffers ile Contract Tasarlamak: .proto, Field Number'lar ve Geriye Uyumluluk

gRPC'de contract'ı field adları değil field number'lar taşır; bu yüzden yeni field eklemek güvenli, numarayı ya da tipi değiştirmek sessiz veri bozulmasıdır. Bookstore Inventory'nin inventory.proto'sunu yazıyor; enum'da sıfır kuralını, optional ile presence'ı, para için DecimalValue'yu, reserved'ı ve Grpc.Tools ile C# üretimini gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Grpc.Tools 2.85 (içindeki protoc 35.1) ve Google.Protobuf 3.36 ile derlenip denenmiştir.

Önce Tahmin Et

Inventory service'i yeni sürüme geçti ve stok mesajına warehouse_code diye yeni bir field ekledi. Ordering hâlâ eski .proto dosyasıyla derlenmiş. Ordering bu mesajı alıyor, hiçbir şeye dokunmadan bir başka çağrıyla Inventory'ye geri gönderiyor. Yeni field yolda kaybolur mu?

İkinci soru: bir ekip arkadaşın int32 quantity = 2; satırını "negatifler daha verimli kodlansın" diye sint32 quantity = 2; yapıyor. Eski client 5 gönderiyor. Yeni server ne okur? Hata mı alır, 5 mi görür, başka bir sayı mı?

Üçüncüsü: bir field'ın adını available'dan available_quantity'ye çeviriyorsun, numarası aynı kalıyor. Bu değişiklik kimi kırar?

Cevapların hepsi bu yazıda, gerçek koşulardan çıkan çıktılarla.

Neden gRPC: Contract-First, Binary ve HTTP/2

ASP.NET Core Web API serisinde REST (Representational State Transfer) API'leri yazdık: URL bir kaynağı gösterir, HTTP verb'ü ne yapılacağını söyler, gövde JSON (JavaScript Object Notation) taşır. Contract çoğu zaman koddan sonra gelir; OpenAPI dokümanını controller'lardan üretiriz. gRPC (resmî açılımıyla gRPC Remote Procedure Calls) sırayı tersine çevirir. Önce bir .proto dosyasında service'in method'larını ve mesajlarını tanımlarsın, server ve client kodu bu dosyadan üretilir. Contract kodun yan ürünü değil, kaynağıdır.

İkinci fark wire format'ta. Mesajlar Protocol Buffers (kısaca protobuf) ile binary olarak serialize edilir: field adları değil, field numaraları gider. Üçüncüsü taşıma katmanı: gRPC HTTP/2 üzerinde çalışır, tek TCP bağlantısında birçok çağrıyı aynı anda taşır ve streaming'i doğal olarak destekler. HTTP/2'nin frame ve multiplexing mekaniğini Networking ve HTTP Nasıl Çalışır serisinin HTTP/2 yazısında anlattık; burada yalnız sonucunu kullanıyoruz.

Bu yazının konusu ilk iki madde: contract ve onun zaman içinde nasıl değiştiği. Hangi durumda REST, gRPC ya da GraphQL seçeceğin sorusunu serinin sonunda aynı Bookstore senaryosunu üç stilde ölçerek kapatacağız. Şimdilik akılda tutman gereken tek şey şu: gRPC'nin gücü de riski de contract'tan gelir. Contract'ı yanlış evrilttiğinde derleyici sana hiçbir şey söylemeden veri bozulabilir.

İlk Contract: inventory.proto

Bookstore'da sipariş akışı Ordering'den Inventory'ye üç çağrı yapıyor: stok sorgula, rezerve et, rezervasyonu bırak. Contract'ın tamamı:

syntax = "proto3";

package bookstore.inventory.v1;

option csharp_namespace = "Bookstore.Inventory.V1";

import "google/protobuf/timestamp.proto";
import "bookstore/common/v1/money.proto";

service InventoryService {
  rpc GetStock (GetStockRequest) returns (GetStockResponse);
  rpc ReserveStock (ReserveStockRequest) returns (ReserveStockResponse);
  rpc ReleaseStock (ReleaseStockRequest) returns (ReleaseStockResponse);
}

enum StockStatus {
  STOCK_STATUS_UNSPECIFIED = 0;
  STOCK_STATUS_IN_STOCK = 1;
  STOCK_STATUS_LOW = 2;
  STOCK_STATUS_OUT_OF_STOCK = 3;
}

message GetStockRequest {
  string book_id = 1;
}

message GetStockResponse {
  string book_id = 1;
  int32 available = 2;
  int32 reserved_quantity = 3;
  StockStatus status = 4;
  bookstore.common.v1.Money unit_price = 5;
  google.protobuf.Timestamp updated_at = 6;
  optional int32 reorder_level = 7;
  map<string, int32> warehouse_quantities = 8;
}

message ReservationLine {
  string book_id = 1;
  int32 quantity = 2;
}

message ReserveStockRequest {
  string order_id = 1;
  repeated ReservationLine lines = 2;
}

message ReserveStockResponse {
  string reservation_id = 1;
  google.protobuf.Timestamp expires_at = 2;
}

message ReleaseStockRequest {
  oneof target {
    string reservation_id = 1;
    string order_id = 2;
  }
}

message ReleaseStockResponse {
  int32 released_lines = 1;
}

Birkaç karar bilerek verildi. package bookstore.inventory.v1 protobuf tarafındaki ad alanı; aynı adlı iki mesajın çakışmasını önler ve sonundaki v1 ileride kırıcı bir sürüm gerekirse v2'yi yan yana yayınlamanın yolunu açık tutar. csharp_namespace ise üretilen C# kodunun namespace'ini belirler. Field adları protobuf stil rehberine uygun olarak snake_case; üretilen C# property'leri yine de BookId, ReservedQuantity diye PascalCase gelir.

Her RPC'nin (başka bir makinedeki fonksiyonu yerel gibi çağırma) kendi request ve response mesajı var; ReleaseStock için bile ReleaseStockResponse var, içinde tek bir field olsa da. Sebebi evrim: yarın bu yanıta "kaç kitap serbest kaldı" bilgisini eklemek istediğinde ortak kullanılan bir mesaja dokunmak zorunda kalmazsın.

Sağdaki sayılar field number. Wire'da field'ı tanıyan şey adı değil bu numara; birazdan göreceğimiz bütün uyumluluk kuralları buradan çıkıyor. Geçerli aralık 1 ile 536.870.911 arası, 19000–19999 protobuf'ın kendi kullanımına ayrılmış. 1–15 arası numaralar wire'da tek byte'lık etiketle, 16–2047 arası iki byte'la yazılır; o yüzden en sık doldurulan field'lara küçük numaraları vermek mantıklı.

Enum, Presence ve Default Değerler

proto3'te enum'ların ilk değeri sıfır olmak zorunda. Bunu unutup enum'u doğrudan anlamlı bir değerle başlatırsan build şu hatayla durur:

Protos/stock.proto(6,27): error : The first enum value must be zero for open enums.

Kural keyfî değil. proto3'te her field'ın bir default değeri var ve enum'ların default'u ilk değer, yani sıfır. Sıfır değeri "gönderilmedi" anlamına geldiği için ona iş anlamı yüklememek gerekiyor; protobuf rehberi bu yüzden STOCK_STATUS_UNSPECIFIED gibi enum adıyla başlayan bir isim öneriyor. Eğer sıfıra STOCK_STATUS_IN_STOCK deseydin, hiç doldurulmamış bir yanıt "stokta var" diye okunurdu.

Hatadaki "open enums" ifadesi ayrıca önemli. proto3 enum'ları açıktır: tanımadığın bir sayı gelirse reddedilmez, olduğu gibi saklanır. C# tarafında enum zaten bir sayı olduğu için yeni sürümün eklediği 4 değeri eski client'ta şöyle görünüyor:

status: 4, defined: False
{ "bookId": "b-42", "status": 4 }

Yani client kodundaki her switch bilinmeyen bir değeri karşılayacak bir default dalına ihtiyaç duyar.

Default değerlerin bir bedeli de presence. proto3'te düz bir scalar field için "sıfır gönderildi" ile "hiç gönderilmedi" wire'da aynı şeydir: sıfır değer serialize edilmez. Bazen bu fark önemlidir. reorder_level sıfır ise "stok bitene kadar uyarma" anlamına gelir, hiç yoksa "bu kitap için ayar yapılmadı" demektir. optional anahtar kelimesi tam bunun için var; üretilen class'a HasReorderLevel ve ClearReorderLevel() ekler:

var empty = new GetStockResponse();
var zero = new GetStockResponse { Available = 0, ReorderLevel = 0 };

Console.WriteLine($"empty: {empty.CalculateSize()} bytes, HasReorderLevel={empty.HasReorderLevel}");
Console.WriteLine($"zero:  {zero.CalculateSize()} bytes, HasReorderLevel={zero.HasReorderLevel}");
Console.WriteLine($"zero hex: {Convert.ToHexString(zero.ToByteArray())}");
empty: 0 bytes, HasReorderLevel=False
zero:  2 bytes, HasReorderLevel=True
zero hex: 3800

Available = 0 wire'a hiç yazılmadı. ReorderLevel = 0 ise iki byte tuttu: 38 etiket (field 7, varint), 00 değer. Message tipindeki field'lar (unit_price, updated_at) zaten presence taşır; C#'ta doldurulmamışsa null gelirler. Yani optional'ı yalnız sıfırın anlamlı olduğu scalar'larda kullanman yeterli.

Well-Known Type'lar ve Para İçin DecimalValue

protobuf'ın scalar type'ları double, float, tamsayı aileleri, bool, string ve bytes'tan ibaret. Tarih, süre ve null olabilen değerler için Google'ın hazır mesajları, yani well-known type'lar var: google.protobuf.Timestamp C#'ta DateTime/DateTimeOffset'e, Duration TimeSpan'e dönüşür. Int32Value, StringValue gibi wrapper'lar üretilen kodda doğrudan int?, string olarak görünür. Empty ise parametre ya da dönüş değeri olmayan çağrılar için. Timestamp her zaman UTC (Coordinated Universal Time) tutar; Timestamp.FromDateTime'a Kind'ı UTC olmayan bir DateTime verirsen exception alırsın, bu yüzden FromDateTimeOffset daha rahat.

Asıl eksik decimal. Fiyatı double ile taşımak yuvarlama hatası demek. Stringe gömmek ise formatı belirsiz bırakır: bir client "249.90", Türkçe kültürle çalışan bir başkası "249,90" yazar. Microsoft'un gRPC dokümanı da bunu çözmek için tam kısmı ve 10⁻⁹ cinsinden kesirli kısmı ayrı tutan bir mesaj öneriyor. Bookstore'un ortak contract'ı:

syntax = "proto3";

package bookstore.common.v1;

option csharp_namespace = "Bookstore.Common.V1";

// 249.90 -> units = 249, nanos = 900000000
// -1.50  -> units = -1,  nanos = -500000000
message DecimalValue {
  int64 units = 1;
  sfixed32 nanos = 2;
}

message Money {
  string currency_code = 1; // ISO 4217: "TRY", "EUR"
  DecimalValue amount = 2;
}

nanos için sfixed32 seçildi çünkü değer çoğunlukla yüz milyonlar düzeyinde ve negatif olabiliyor; sabit dört byte burada varint'ten kısa. Para birimi kodu ISO (International Organization for Standardization) 4217'deki üç harfli koddur. Üretilen class partial olduğu için dönüşümü yanına ekliyoruz:

namespace Bookstore.Common.V1;

public partial class DecimalValue
{
    private const decimal NanoFactor = 1_000_000_000m;

    public static DecimalValue FromDecimal(decimal value)
    {
        long units = decimal.ToInt64(decimal.Truncate(value));
        decimal nanos = (value - units) * NanoFactor;

        // 9 basamaktan fazlası sessizce kesilmesin
        if (nanos != decimal.Truncate(nanos))
        {
            throw new ArgumentOutOfRangeException(nameof(value), value, "More than 9 fractional digits.");
        }

        return new DecimalValue { Units = units, Nanos = decimal.ToInt32(nanos) };
    }

    public decimal ToDecimal() => Units + Nanos / NanoFactor;
}

Implicit operator yerine açık method'lar kullandım: dokuz basamaktan uzun bir kesir geldiğinde dönüşümün kod okurken görünmeyen bir yerde sessizce yuvarlanmasını istemiyorum. Çıktı:

249.90 -> units=249, nanos=900000000 -> 249.9
-1.5 -> units=-1, nanos=-500000000 -> -1.5
0.000000001 -> units=0, nanos=1 -> 0.000000001
More than 9 fractional digits. (Parameter 'value')
Actual value was 0.1234567891.

İlk satıra dikkat: 249.90 gidip 249.9 olarak döndü. Değer aynı, ama decimal'ın taşıdığı scale (sondaki sıfır) kayboldu. Fatura üzerinde "249,90" yazdırıyorsan formatı ekranda ver, scale'e güvenme. Bunun yerine Google'ın ortak tiplerinden google.type.Money'yi de kullanabilirsin (Google.Api.CommonProtos paketinde; currency_code, units, nanos aynı mesajda). Biz kendi mesajımızda kaldık çünkü DecimalValue'yu para dışında, örneğin indirim oranında da kullanacağız.

Koleksiyonlar için iki araç var. repeated C#'ta RepeatedField<T> olur, map<string, int32> ise MapField<string, int>. İkisinin de setter'ı yoktur; Lines.Add(...) ya da object initializer içinde { ["IST-1"] = 8 } ile doldurursun. oneof ise "bunlardan en fazla biri" demek. ReleaseStock'a ya rezervasyon id'si ya da sipariş id'si veriliyor; ikisini birden set edersen sonuncusu kazanır:

var release = new ReleaseStockRequest { ReservationId = "r-1001" };
release.OrderId = "o-77";
Console.WriteLine($"{release.TargetCase}: {release}");
OrderId: { "orderId": "o-77" }

Server tarafında TargetCase üzerinden switch kurmak, iki ayrı nullable field'a bakıp "ikisi de doluysa ne yapacağım" diye düşünmekten çok daha temiz.

Wire Format: Field Number Neden Bu Kadar Önemli?

Aynı GetStockResponse'u (bir GUID, iki sayı, enum, fiyat, tarih ve iki depolu map) hem binary hem protobuf'ın JSON formatıyla serialize ettim:

protobuf: 91 bytes
json:     305 bytes
{ "bookId": "3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13", "available": 12, "reservedQuantity": 3, "status": "STOCK_STATUS_IN_STOCK", "unitPrice": { "currencyCode": "TRY", "amount": { "units": "249", "nanos": 900000000 } }, "updatedAt": "2026-11-14T09:30:00Z", "warehouseQuantities": { "IST-1": 8, "ANK-1": 4 } }
0A2433663263396131652D376234642D

Binary'nin ilk 16 byte'ı mekanizmayı özetliyor. 0A etiket: field numarası 1, üç bit sola kaydırılıp wire type 2 (length-delimited) ile birleştirilmiş, yani (1 << 3) | 2. 24 uzunluk, onaltılıkta 36; GUID'in string hâlinin karakter sayısı. Ardından gelen 33 66 32 63 doğrudan "3f2c"'nin ASCII kodları. Field adı hiçbir yerde yok. JSON çıktısında da iki ilginç detay var: int64 olan units string olarak yazılmış ("249"), çünkü JavaScript'in sayıları 2⁵³'ten büyük tamsayıları güvenle taşıyamaz; enum ise adıyla gitmiş. Bu iki kural tarayıcı ve JSON transcoding konusunu işleyeceğimiz yazıda tekrar karşımıza çıkacak.

Bu yüzden okuyan taraf yalnız numaraya ve wire type'a bakar. İsim değişse binary okuyucu fark etmez; numara değişse ya da aynı numaraya başka bir tip konsa okuyucu yanlış şeyi okur, çoğu zaman hata vermeden.

Geriye Uyumluluk Kuralları

Baştaki soruları denemek için Inventory mesajının iki sürümünü yan yana koydum:

// v1: Inventory'nin ilk sürümü
message StockV1 {
  string book_id = 1;
  int32 available = 2;
  int32 status = 4;
}

// v2: field eklendi, biri yeniden adlandırıldı
message StockV2 {
  string book_id = 1;
  int32 available_quantity = 2;
  int32 status = 4;
  string warehouse_code = 9;
}

Yeni server v2 yazıyor, eski client v1 ile okuyup aynen geri yolluyor:

var v2 = new StockV2 { BookId = "b-42", AvailableQuantity = 7, Status = 1, WarehouseCode = "IST-1" };
byte[] fromNewServer = v2.ToByteArray();

StockV1 oldClient = StockV1.Parser.ParseFrom(fromNewServer);
Console.WriteLine($"v1 reads v2: {oldClient}");

// eski client mesajı değiştirmeden geri yollar; bilinmeyen field kaybolur mu?
byte[] roundTripped = oldClient.ToByteArray();
StockV2 backOnServer = StockV2.Parser.ParseFrom(roundTripped);
Console.WriteLine($"after v1 round-trip: {backOnServer}");
v1 reads v2: { "bookId": "b-42", "available": 7, "status": 1 }
after v1 round-trip: { "bookId": "b-42", "availableQuantity": 7, "status": 1, "warehouseCode": "IST-1" }

İlk sorunun cevabı: kaybolmuyor. Eski client warehouse_code'u tanımıyor ama unknown field olarak saklıyor ve tekrar serialize ederken geri yazıyor. Bu yüzden yeni field eklemek güvenli. İki şartı var: mesajı field field yeni bir object'e kopyalarsan (DTO'ya map edip geri üretirsen) unknown field'lar düşer; JSON'a çevirdiğinde de düşerler.

Rename'in binary'de sorunsuz olduğunu da gördük: v1 available, v2 available_quantity diyor, 7 değeri sağ salim geçti. Ama JSON'da isim yazılır. Eski client'ın JSON'unu yeni şemayla okumaya çalıştığımda:

InvalidProtocolBufferException: Unknown field: available
ignore unknown: { "bookId": "b-42", "status": 1 }

Varsayılan parser reddediyor. WithIgnoreUnknownFields(true) ile hoşgörülü yaptığında ise daha kötüsü oluyor: available sessizce düşüyor ve stok sıfır görünüyor. Üçüncü sorunun cevabı bu. Rename binary client'ları kırmaz, ama JSON konuşan her şeyi kırar: JSON transcoding ile yayınladığın REST endpoint'leri, mesajı JSON olarak saklayıp sonra geri okuyan araçlar, JSON fixture'larıyla çalışan testler.

Tip değişikliği ise binary'nin kendisini bozabilir. Aynı numaraya farklı tipler koyup int32 ile yazılmış bir quantity'yi okuttum:

int32 5  as int64:  5
int32 -1 as int64:  -1
int32 5  as sint32: -3
int32 5  as string: ''
int32 -1 size: 17 bytes, int32 5 size: 8 bytes

İkinci sorunun cevabı: -3, hatasız. int32, int64, uint32, uint64 ve bool aynı varint kodlamasını kullandığı için birbirleriyle uyumlu (sığmayan değer kırpılır). sint32 ise negatif sayıları verimli tutmak için ZigZag kodlaması kullanır; aynı byte'ları ZigZag ile çözünce 5, -3 oluyor. string'e çevirdiğimizde wire type uyuşmadığı için C# parser'ı değeri unknown field'a atıyor ve Quantity boş kalıyor. Son satır da sint'in neden var olduğunu gösteriyor: negatif bir int32 on byte'lık varint'e dönüşüyor.

Field silmek ise ancak numarayı kilitlemekle güvenli. reserved ile numarayı ve adı ayırırsın, biri yanlışlıkla tekrar kullanırsa protoc derlemeyi durdurur:

message StockItem {
  reserved 3;
  reserved "warehouse";

  string book_id = 1;
  int32 available = 2;
  string location = 3;
  string warehouse = 4;
}
Protos/stock.proto(11,12): error : Field "location" uses reserved number 3.
Protos/stock.proto(11,12): error : Suggested field numbers for bookstore.inventory.v1.StockItem: 5
Protos/stock.proto(17,10): error : Field name "warehouse" is reserved.

Kuralları tek yerde toplarsak:

DeğişiklikBinary clientJSON client
Yeni field eklemek (yeni numara)GüvenliGüvenli (eski parser katıysa reddedebilir)
Field silip numarayı reserved yapmakGüvenliGüvenli
Field'ı yeniden adlandırmakGüvenliKırıcı
int32 → int64Güvenli (büyük değer kırpılabilir)Dikkat: int64 JSON'da string
int32 → sint32, string → sayıSessizce bozarKırıcı
Numarayı değiştirmek ya da yeniden kullanmakSessizce bozarDuruma göre
Enum'a yeni değer eklemekGüvenli, eski client sayıyı görürEski client adı tanımaz

Bu tabloyu event tarafında da göreceksin; Message Queue'larla Çalışmak serisinin mesaj contract'ları yazısında aynı problemi broker'daki mesajlar için konuştuk. Fark şu: gRPC'de iki taraf aynı anda çalışırken uyuşmazlık anında yüzeye çıkar, queue'da ise eski mesajlar günlerce bekleyebilir.

Grpc.Tools ile C# Kodu Üretmek

.proto dosyasından C# üretimini Grpc.Tools paketi yapar; içinde protoc derleyicisi ve gRPC eklentisi gelir, build sırasında çalışır. Bookstore'da contract'lar solution kökündeki protos/ klasöründe duruyor. Inventory server tarafını, Ordering client tarafını üretiyor:

<!-- Bookstore.Inventory.csproj -->
<ItemGroup>
    <Protobuf Include="..\..\protos\bookstore\**\*.proto" ProtoRoot="..\..\protos" GrpcServices="Server" />
</ItemGroup>

<!-- Bookstore.Ordering.csproj -->
<ItemGroup>
    <Protobuf Include="..\..\protos\bookstore\**\*.proto" ProtoRoot="..\..\protos" GrpcServices="Client" />
</ItemGroup>

GrpcServices dört değer alır: Server, Client, Both (varsayılan) ve yalnız mesajları üreten None. ProtoRoot, import "bookstore/common/v1/money.proto" satırının nereden çözüleceğini söyler; vermezsen import'lar bulunamaz. Inventory'de Grpc.AspNetCore paketi Grpc.Tools ve Google.Protobuf'ı zaten getiriyor. Ordering'de Grpc.Net.ClientFactory, Google.Protobuf ve Grpc.Tools'u ayrıca ekliyoruz (Grpc.Tools'u PrivateAssets="all" ile, çünkü yalnız build zamanında lazım).

Üretilen dosyalar obj/Debug/net10.0/bookstore/inventory/v1/ altına iner: Inventory.cs mesajları, InventoryGrpc.cs service tarafını içerir. Her mesaj IMessage<T> ve IBufferMessage'ı implement eden sealed partial bir class; statik Parser ve Descriptor property'leri, her field için BookIdFieldNumber gibi sabitler ve optional field'lar için Has/Clear çiftleri taşır. InventoryGrpc.cs'te Inventory projesinde yalnız InventoryService.InventoryServiceBase (override edeceğin abstract class), Ordering projesinde yalnız InventoryService.InventoryServiceClient var. Bu dosyaları elle düzenleme; her build'de yeniden yazılır. Ek davranışı DecimalValue'da yaptığımız gibi partial class'la ekle.

Contract'ı paylaşmak

Ordering'in Inventory'nin contract'ını nereden aldığı organizasyonel bir karar. Üç yaygın yol var. Birincisi kopya: her consumer .proto dosyasını kendi projesine kopyalar; bağımsızdır ama kopyalar zamanla ayrışır ve kimse hangisinin doğru olduğunu bilmez. İkincisi bizim yaptığımız gibi ortak bir klasör ya da ayrı bir contract repository'si; tek kaynak var, ama consumer'ların hepsi aynı repository'ye ya da bir submodule'e bağlanır. Üçüncüsü .proto dosyalarını (ya da üretilmiş kodu) bir NuGet paketi olarak yayınlamak; sürümlenir, consumer kendi hızında yükseltir, ama yalnız .NET consumer'lar için doğal. Polyglot bir ekipte .proto dosyalarının kendisini paylaşmak, üretilmiş C# kodunu paylaşmaktan her zaman daha iyi: Go ya da TypeScript client'ı aynı dosyadan kendi kodunu üretebilir.

Protobuf Editions'a kısa not

protobuf ekibi syntax = "proto3" yerine edition = "2023" gibi sürümlenmiş "edition"lara geçiyor; proto2 ve proto3 farkları dosya ve field düzeyindeki feature ayarlarına dönüşüyor. Grpc.Tools 2.85'in içindeki protoc 35.1 ile denedim: edition = "2023" ve edition = "2024" C# için sorunsuz derleniyor, "2025" ise Unknown edition "2025" hatası veriyor. Önemli bir fark hemen göze çarpıyor: edition 2023'te scalar field'lar varsayılan olarak explicit presence taşıyor, yani int32 available = 2; için de HasAvailable üretiliyor. Bu seride proto3 ile devam ediyoruz; çevrendeki araçların (JSON transcoding, gRPC-Web client üreticileri, başka dillerin plugin'leri) editions desteğini kontrol etmeden geçiş yapma.

Üretimde Dikkat

Breaking change'i CI'da yakala. İnsan gözüyle review'da int32'nin sint32'ye dönüşmesini kaçırmak çok kolay. Buf CLI'ın buf breaking komutu gibi araçlar .proto dosyalarının iki sürümünü karşılaştırıp numara değişikliğini, tip değişikliğini, silinip reserve edilmemiş field'ı pull request aşamasında raporlar.

Deploy sırası önemli. Yeni field eklediğinde önce server'ı (alanı dolduran tarafı) yayınla, sonra client'ları. Bir field'ı kullanımdan kaldırırken tersini yap: önce client'lar okumayı bıraksın, sonra server yazmayı bıraksın, en son numara reserved olsun. Kırıcı bir değişiklik kaçınılmazsa bookstore.inventory.v2 paketini yan yana yayınla ve iki sürümü bir süre birlikte sun.

Mesaj boyutunu ölç. Binary format küçük, ama sınırsız değil: ASP.NET Core'un gRPC server'ı varsayılan olarak 4 MB'tan büyük gelen mesajı reddeder. repeated bir field'a sayfalama olmadan binlerce satır koymak bu sınıra yaklaşır; stok listesi gibi büyüyen veriler için streaming ya da sayfalama düşün. Limitleri ve streaming'i ilerideki yazılarda ayrıntılı ele alıyoruz.

Contract'ı storage modelinden ayır. GetStockResponse'u Entity Framework Core (EF Core) entity'si gibi kullanmak cazip, ama veritabanı şeması değiştikçe contract da değişmek zorunda kalır. İkisinin arasına küçük bir mapping katmanı koy.

Sık Düşülen Tuzaklar

Tarih ve parayı formatı tanımsız string'e gömmek. "14.11.2026" ya da "249,90" göndermek, her client'ın aynı kültürle parse edeceğini varsaymak demek. Tarih için Timestamp, para için DecimalValue/Money kullan. GUID'de durum biraz farklı: string olarak taşımak pratik ve okunabilir (bizim örneğimizde 38 byte), bytes olarak 18 byte'a iner ama loglarda ve JSON'da base64 görünür. Hangisini seçersen seç, id'yi contract'ta opak bir değer olarak tanımla ve Guid.Parse'ı kodun her yerine dağıtmak yerine sınırda bir kez yap.

Enum'a sonradan sıfır değeri eklemeye çalışmak. İlk sürümde STOCK_STATUS_IN_STOCK = 0 yazdıysan, ileride UNSPECIFIED'ı sıfıra koyamazsın: o numara zaten yayında ve eski client'lar sıfırı "stokta var" diye okuyor. Doğru düzen ilk günden _UNSPECIFIED = 0.

Numara yeniden kullanmak. Silinen field'ın numarasını yeni bir field'a vermek, eski client'ın gönderdiği veriyi yeni field'ın içine okumak demek. Her silmede reserved.

Unknown field'ları DTO'da kaybetmek. Bir gateway mesajı kendi modeline map edip yeniden ürettiğinde, tanımadığı field'lar yok olur. Ara katman mesajı olduğu gibi iletmiyorsa contract'ın evrimini yavaşlatır.

Her field'ı optional yapmak. "Ne olur ne olmaz" diye her şeye presence eklemek, her okumada Has kontrolü demek. Sıfırın gerçekten ayrı bir anlam taşıdığı field'larla sınırla.

Küçük Bir Deneme

★☆☆ GetStockResponse'a google.protobuf.Duration reservation_ttl = 9; ekle, bir değer verip binary boyutunun kaç byte arttığını ölç ve ToTimeSpan() ile geri oku.

★★☆ StockV2'den warehouse_code'u sil, numarayı reserved yapmadan yerine int32 warehouse_id = 9; ekle. Eski sürümün yazdığı byte'ları yenisiyle oku ve ne olduğunu açıkla; ardından reserved 9; ekleyip protoc'un hatasını gör.

★★★ DecimalValue için, units ve nanos'un işaretleri uyuşmadığında (units = 1, nanos = -5) exception fırlatan bir doğrulama yaz ve decimal.MaxValue gibi sınır değerlerle dönüşümü test et.

Contract hazır, ama henüz kimse bu method'ları çağırmıyor. Bir sonraki yazıda Inventory'yi ASP.NET Core'da gerçek bir gRPC service'i olarak ayağa kaldırıyor, Ordering'den GrpcChannel ve client factory ile çağırıyor; deadline, iptal ve RpcException ile hataların nasıl taşındığını görüyoruz.