YazılarYayımlandı

C# 15 Native Union — API Sözleşmelerini Daha Açık Tanımlamak

Bir API’nin aynı alan için birden fazla veri tipi döndürmesi gerektiğinde C# tarafında genellikle object özel bir wrapper ya da harici bir union kütüphanesi kullanıyoruz. Mesela K8s’teki…

Hiç bir zafere çiçekli yollardan gidilmez. Cennet bile ölüm ister!

Bir API’nin aynı alan için birden fazla veri tipi döndürmesi gerektiğinde C# tarafında genellikle object özel bir wrapper ya da harici bir union kütüphanesi kullanıyoruz. Mesela K8s’teki maxUnavailable benzeri bir değer hem sayı hem de yüzde olarak ifade edilebilir.

2 veya "25%"

C# 15 öncesinde bunun en kısa karşılığı object kullanmak olabilir.

app.MapGet("/max-unavailable", () => (object)"25%");

Bu çözüm çalışıyor fakat contract gereğinden fazla açık kalıyor. Endpoint’in int veya string döndürmesini beklerken yanlışlıkla bool, DateTime ya da başka bir nesne dönmesini type sistem engellemiyor. Daha güvenli bir yol aradığımız zaman da basit bir contract için ek kod maliyeti ortaya çıkıyor. C# 15 ile gelen native union desteği bu modeli doğrudan dil seviyesinde tanımlamamıza imkan veriyor:

public union IntOrString(int, string)

Bu tanım sayesinde endpoint’in dönüş tiği de artık açıkça ifade edilebiliyor.

app.MapGet(
    "/max-unavailable/{asPercentage:bool}",
    IntOrString (bool asPercentage) =>
    {
        if (asPercentage)
            return "25%";

        return 2;
    });

Bu tanım sayesinde int ve string dışında farklı bir tip döndürmeye çalıştığımız da hata daha uygulama çalışmadan ortaya çıkıyor. Native union’ın bence asıl önemli tarafı yalnızca daha az kod yazılması değil. Sözleşme sınırlarının type system içinde tanımlanmış olmasıdır. Bu durum switch kullanımında da kendisini gösteriyor.

static string Describe(IntOrString value) => value switch
{
    int count => $"{count} instance",
    string percentage => percentage
};

Union’a yeni bir case eklendiğinde ve mevcut kod bu ihtimali ele almadığında compiler eksik case için uyarı verebiliyor. Böylece sözleşme değişikliğinin etkisi yalnızca runtime’da değil, kod derlenirken de görülebiliyor. JSON tarafında union için ilave bir wrapper veya discriminator üretilmiyor. Aktif case hangi tipteyse doğrudan o değer yazılıyor:

2 veya "25%"

Bu sadelik yazma tarafında geçerli. Okuma tarafında ise durum değişiyor. Aynı IntOrString union’ını bir request body olarak kullandığımızda ASP.NET Core’un web JSON ayarları sayıların string’den de okunmasına izin veriyor. Bu durumda “2” değeri hem int hem de string case’ine eşleşebiliyor ve deserialization için custom bir JsonTypeClassifier yazmak gerekiyor. Yani int | string sözleşmesi dönüş tipi olarak ek kod istemezken, giriş tipi olarak kullanıldığında bu kadar zahmetsiz değil. OpenAPI tarafında ise bu sözleşme anyOf olarak ifade ediliyor:

anyOf:
  - type: integer
    format: int32
  - type: string

Burada union ile closed hierarchy arasındaki ayrımı da doğru yapmak gerekiyor. Birbiriyle ilişkili olmayan tipleri veya değiştiremeyeceğimiz discriminator’sız bir JSON sözleşmesini modellemek istiyorsak union daha doğal bir seçenek. Mesela;

int | string

Ancak kendi kontrolümüzde olan ve ortak bir anlam taşıyan modeller için closed hierarchy daha doğru olabilir:

PaymentInitiated
PaymentAuthorized
PaymentFailed

Bu modeller aynı domain kavramına (Payment) farklı durumlarını temsil ettiği için ortak bir base type ve discriminator kullanmak sözleşmeyi daha anlaşılır hale getirebilir. Native union her senaryo için kullanılacak yeni bir varsayılan değil. Resmi dokümandan anladığım bazı sınırları var. Bunlar:

  • Query string, route, header ve fırm binding için desteklenmiyor
  • Birden fazla case JSON object tipindeyse deserialization sırasında classifier gerekiyor. Yapıları farklıysa built-in JsonUnionTypeStructuralClassifier property adlarına bakarak ayırt edilebiliyor. Yapıları ayırt edilemiyorsa custom classifier yazamak gerekiyor.
  • Verdiğin int | string örneğinde olduğu gibi primitive case’ler bile request body tarafında belirsizliğe düşebiliyor.
  • Union’a yeni bir case eklemek contract değişikliği oluşturacağından api’yi tüketenlerinde değişiklik yapmasını gerektirebilir.
  • SignalR desteği System.Text.Json protokolüyle sınırlı
  • C# 15 ve .NET 11 dönemine ait olduğu için production ortamda kullanılan bir yapıdan ziyade değerlendirme aşamasında ki bir feature olarak düşünmek gerek.

Yine de önemli bir kazanım sağlıyor. Bir API’nin kabul ettiği veya döndürdüğü alternatifleri yorumlarla, runtime kontrolleriyle ya da gereğinden fazla kodla değil bunun yerine doğrudan tip sistemiyle ifade ediyor.

Peki siz bu tür sözleşmeleri bugün nasıl modelliyorsunuz?

Buraya kadar okuyup zaman ayırdığınız için teşekkür ederim. Geri dönüşleriniz benim için değerlidir.

Kaynak:

  1. https://devblogs.microsoft.com/dotnet/unions-and-closed-hierarchies-in-aspnetcore/