Mert Özen Her Satırda Daha İleri
Backend .NET SDK, CLI ve MSBuild — #05

Multi-Targeting ve Conditional Build: TargetFrameworks, #if, Platform API'leri ve Sürümler Arası Kütüphane

Mert Özen 15 Kas 2026 10 dk 7 görüntülenme
Multi-Targeting ve Conditional Build: TargetFrameworks, #if, Platform API'leri ve Sürümler Arası Kütüphane

Bir library'yi birden fazla .NET sürümü için derlemek için TargetFrameworks yazılır, ama Directory.Build.props'ta tek bir TargetFramework varsa proje sessizce tek hedefle derlenir; onu projede boşaltmak gerekir. Bookstore.Contracts'ı net8.0 ve net10.0 için derleyip .NET 10'un NumericOrdering'ine .NET 8 yedeği yazıyor, testleri iki framework'te çalıştırıyor, netstandard2.0'ın bedelini, CA1416 platform kontrollerini ve .NET 8'de kalan bir tüketiciyi gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404) ile derlenip denenmiştir; net8.0 çıktıları .NET 8.0.34 runtime'ında, Reporting örnekleri SDK 11.0.100 ile çalıştırılmıştır.

Önce Tahmin Et

Bookstore'un ortak tiplerini taşıyan Bookstore.Contracts paketini .NET 8'de kalan bir servis de kullanmak istiyor. Proje dosyasına tek satır ekliyoruz:

<TargetFrameworks>net8.0;net10.0</TargetFrameworks>

dotnet build src/Bookstore.Contracts kaç tane dll üretir? Cevap iki olmalı, ama:

  Bookstore.Contracts -> C:\src\Bookstore\artifacts\bin\Bookstore.Contracts\debug_net10.0\Bookstore.Contracts.dll

Yalnız net10.0 derlendi ve SDK hiçbir uyarı vermedi. Sebep, 3. yazıda kurduğumuz Directory.Build.props: orada TargetFramework tek değer olarak set ediliyor. MSBuild, TargetFramework doluysa projeyi tek hedefli kabul eder ve TargetFrameworks'e bakmaz. Bu yazıda, sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) üzerinden bir library'yi birden fazla framework için derlemeyi anlatıyoruz: TargetFrameworks, framework'e göre koşullu kod ve paket referansları, platform kontrolleri ve her framework'te çalışan testler.

TargetFrameworks: Bir Proje, Birden Fazla Build

Çoklu hedefli bir projede MSBuild önce bir dış build (outer build) çalıştırır; o da listedeki her TFM (Target Framework Moniker; net8.0 gibi hedef framework adı) için TargetFramework global property'si set edilmiş ayrı bir iç build (inner build) başlatır. Yani her TFM için proje ayrı değerlendirilir, ayrı derlenir, ayrı çıktı üretir. Ortak dosyadan gelen tek değeri proje dosyasında boşaltınca dış build devreye giriyor:

<PropertyGroup>
  <!-- Directory.Build.props'taki tek TargetFramework'ü boşalt, yoksa TargetFrameworks yok sayılır. -->
  <TargetFramework></TargetFramework>
  <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
  <Version>1.1.0</Version>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
  Bookstore.Contracts -> C:\src\Bookstore\artifacts\bin\Bookstore.Contracts\debug_net10.0\Bookstore.Contracts.dll
  Bookstore.Contracts -> C:\src\Bookstore\artifacts\bin\Bookstore.Contracts\debug_net8.0\Bookstore.Contracts.dll

Artifacts düzeninde pivot klasörüne TFM eklendi (debug_net8.0). dotnet msbuild -getProperty:IsCrossTargetingBuild artık true dönüyor. Bu sorun ortak bir Directory.Build.props kullanan her repoda er geç çıkar; alternatif, ortak dosyada tek değeri hiç yazmamak ve her projeye kendi TFM'sini bırakmak. Bookstore'da projelerin çoğu tek hedefli olduğu için ortak değeri koruyup istisnalarda boşaltmayı seçtik.

Paket de iki framework'ün çıktısını taşıyor. Sürüm numarasını burada elle 1.1.0 yaptık; sürüm üretmenin otomatik yolları Versioning ve Release Management serisinde.

dotnet pack src/Bookstore.Contracts
lib/net10.0/Bookstore.Contracts.dll
lib/net10.0/Bookstore.Contracts.xml
lib/net8.0/Bookstore.Contracts.dll
lib/net8.0/Bookstore.Contracts.xml

NuGet, paketi kullanan projenin TFM'sine en yakın klasörü seçer: net10.0 ve net11.0 projeleri lib/net10.0'ı, net8.0 ve net9.0 projeleri lib/net8.0'ı alır.

Hangi TFM'ler? net8.0, net10.0 ve netstandard2.0

Bir library'nin hedeflerini tüketicileri belirler. net10.0 en yeni API'leri ve en iyi performansı verir; net8.0 eski servisler için gerekir; netstandard2.0 ise yalnız .NET Framework gibi .NET Core olmayan platformlarda da çalışması gereken library'ler için anlamlıdır. .NET 8'in desteği 10 Kasım 2026'da bitti, ama desteği biten bir runtime'da çalışan servisler kurumlarda bir süre daha yaşar; paylaşılan bir library için onları bir sürüm daha desteklemek, geçişi kolaylaştırır.

netstandard2.0'ın bedelini görmek için listeye ekleyip derledik. İlk hata dil sürümünden geldi, çünkü SDK netstandard2.0 için C# 7.3'ü seçiyor ve Directory.Build.props'taki Nullable ayarı onda yok:

CSC : error CS8630: Invalid 'nullable' value: 'Enable' for C# 7.3. Please use language version '8.0' or greater.

LangVersion'ı yükselttiğimizde dilin kendisi tamam, ama derleyicinin beklediği tipler ve BCL (Base Class Library; .NET'in temel tip ve API kütüphanesi) API'leri eksik:

C:\src\Bookstore\src\Bookstore.Contracts\BookSummary.cs(9,39): error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported
C:\src\Bookstore\src\Bookstore.Contracts\IsbnChecksum.cs(6,33): error CS0246: The type or namespace name 'ReadOnlySpan<>' could not be found (are you missing a using directive or an assembly reference?)
...
    7 Error(s)

Polyfill yaklaşımı burada devreye girer: eksik parçaları bir paketle ya da kendi kodunla yalnız eski hedefte tamamlamak. Framework'e göre koşullu PackageReference ile, record'un ihtiyaç duyduğu tipleri kaynak kod olarak üreten PolySharp'ı ve Span tiplerini getiren System.Memory'yi yalnız netstandard2.0'a ekledik:

<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
  <PackageReference Include="PolySharp" PrivateAssets="all" />
  <PackageReference Include="System.Memory" />
</ItemGroup>
C:\src\Bookstore\src\Bookstore.Contracts\Isbn.cs(20,25): error CS8602: Dereference of a possibly null reference.
C:\src\Bookstore\src\Bookstore.Contracts\Isbn.cs(21,53): error CS0117: 'char' does not contain a definition for 'IsAsciiDigit'
...
    5 Error(s)

Dil özellikleri tamamlandı ama iki sorun kaldı. char.IsAsciiDigit .NET 7 ile gelen bir API ve netstandard2.0'da yok. string.IsNullOrWhiteSpace'in nullable attribute'ları da eski BCL'de olmadığı için derleyici kontrolden sonra bile null uyarısı veriyor. Bunları da kendi kodumuzla ya da daha geniş kapsamlı polyfill paketleriyle çözebilirdik, ama Bookstore'da .NET Framework kullanan bir tüketici yok. netstandard2.0'ı listeden çıkardık. Hedef eklemek her seferinde bir maliyet: her TFM ayrı bir derleme, ayrı bir test koşusu ve ayrı bir hata kaynağı.

#if ve Sürüme Özel API'ler

Bookstore'un kataloğunda seri kitaplar var: "Volume 2"nin "Volume 10"dan önce gelmesi gerekiyor, ama sıradan string karşılaştırması "1"i "2"den önce koyar. .NET 10 bunun için CompareOptions.NumericOrdering'i getirdi; .NET 8'de yok. SDK her TFM için NET8_0, NET8_0_OR_GREATER, NET10_0_OR_GREATER gibi derleme sembollerini kendisi tanımlar:

public static class BookTitleComparer
{
    public static IComparer<string> Instance { get; } = Create();

    private static IComparer<string> Create()
    {
#if NET10_0_OR_GREATER
        return StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering | CompareOptions.IgnoreCase);
#else
        return StringComparer.OrdinalIgnoreCase;
#endif
    }
}

İlk sürümde .NET 8 için "en azından derlensin" diyerek sıradan karşılaştırmayı koyduk. Test projesini de iki framework için çalıştırınca fark hemen göründü:

failed Bookstore.Contracts.Tests.BookTitleComparerTests.Sorts_volume_numbers_numerically (11ms)
  from C:\src\Bookstore\artifacts\bin\Bookstore.Contracts.Tests\debug_net8.0\Bookstore.Contracts.Tests.dll (net8.0|x64)
  Assert.Equal() Failure: Collections differ at index 2
                    ↓ (pos 7)
  Expected: "Volume 2"
  Actual:   "Volume 10"
                    ↑ (pos 7)

Aynı kod net10.0'da geçti, net8.0'da kaldı. Testler yalnız net10.0'da çalışsaydı bu hata .NET 8 kullanan servisin production'ına kadar giderdi. .NET 8 için rakam gruplarını sayı olarak karşılaştıran küçük bir NaturalTitleComparer yazdık. Bu sınıfı #if içine gömmek yerine ayrı bir klasöre koyup derlemeye MSBuild koşuluyla dahil ettik:

<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0'))">
  <!-- net10.0 ve sonrasında CompareOptions.NumericOrdering var; yedek kod yalnız eski framework'ler için. -->
  <Compile Remove="Compat/**" />
</ItemGroup>
dotnet msbuild src/Bookstore.Contracts -getItem:Compile -p:TargetFramework=net8.0
dotnet msbuild src/Bookstore.Contracts -getItem:Compile -p:TargetFramework=net10.0
        "Identity": "BookSummary.cs",
        "Identity": "BookTitleComparer.cs",
        "Identity": "Compat/NaturalTitleComparer.cs",
        "Identity": "Isbn.cs",
        "Identity": "IsbnChecksum.cs",
...
        "Identity": "BookSummary.cs",
        "Identity": "BookTitleComparer.cs",
        "Identity": "Isbn.cs",
        "Identity": "IsbnChecksum.cs",

IsTargetFrameworkCompatible, "bu TFM net10.0 ile uyumlu mu?" sorusunu sorar; net11.0 eklendiğinde de doğru çalışır. '$(TargetFramework)' == 'net8.0' gibi eşitlik kontrolleri ise yeni bir hedef eklendiğinde sessizce yanlış dala düşer. #if tek satırlık farklar için iyi; birkaç satırı aşan sürüme özel kodu ayrı dosyaya almak, ana dosyayı okunur tutar. BookTitleComparer.cs'te kalan tek #if de hangi uygulamanın seçildiğini söylüyor.

Testleri Her TFM'de Çalıştırmak

Test projesi de iki hedefli ve aynı TargetFramework boşaltma satırını taşıyor:

<PropertyGroup>
  <TargetFramework></TargetFramework>
  <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
</PropertyGroup>
dotnet test --project tests/Bookstore.Contracts.Tests
C:\src\Bookstore\artifacts\bin\Bookstore.Contracts.Tests\debug_net10.0\Bookstore.Contracts.Tests.dll (net10.0|x64) passed (125ms)
C:\src\Bookstore\artifacts\bin\Bookstore.Contracts.Tests\debug_net8.0\Bookstore.Contracts.Tests.dll (net8.0|x64) passed (128ms)

Test run summary: Passed!
  total: 16
  failed: 0
  succeeded: 16
  skipped: 0

Testlerin sayısı ikiye katlandı, çünkü her test her framework'te ayrı çalıştı. net8.0 testlerini çalıştırmak için makinede .NET 8 runtime'ı olmalı; CI'da SDK kurulumuna bu runtime'ı da eklemek gerekir. Runtime yoksa testler hata verir, sessizce atlanmaz. Bu, bir TFM'de derlenip diğerinde hiç çalıştırılmayan koda karşı en basit önlem.

OS'e Özel TFM ve Platform Kontrolleri

Framework'ün yanında işletim sistemi de hedeflenebilir. net10.0-windows gibi bir TFM, projeye Windows'a özel API'leri açar; WPF (Windows Presentation Foundation; Windows masaüstü arayüz framework'ü) ve Windows Forms projeleri bu yüzden -windows ile biter. Platformu TFM ile değil çalışma anında kontrol etmek de mümkün, ve SDK'daki platform uyumluluğu analyzer'ı (CA1416) bunu derleme sırasında denetler. bookstore-admin'e Windows'a özel bir çağrı eklediğimizde:

static int Beep()
{
    Console.Beep(800, 200);
    return 0;
}
C:\src\Bookstore\tools\Bookstore.Admin\Program.cs(56,5): error CA1416: This call site is reachable on all platforms. 'Console.Beep(int, int)' is only supported on: 'windows'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416) [C:\src\Bookstore\tools\Bookstore.Admin\Bookstore.Admin.csproj]

Analyzer, çağrının Linux'ta ya da macOS'ta da çalışabileceğini fark etti; TreatWarningsAsErrors da onu hataya çevirdi. Çağrıyı OperatingSystem.IsWindows() ile koruyunca uyarı kayboldu, çünkü analyzer bu kontrolü tanıyor:

static int Beep()
{
    if (OperatingSystem.IsWindows())
    {
        Console.Beep(800, 200);
    }

    return 0;
}

Projeyi net10.0-windows'a çevirmek de uyarıyı kaldırır, ama aracın Linux container'ında çalışma imkânını da kaldırır. Bu TFM ile build Windows dışında da alınabiliyor; SDK hedef platformu proje property'lerine yazıyor:

{
  "Properties": {
    "TargetPlatformIdentifier": "Windows",
    "TargetPlatformVersion": "7.0",
    "SupportedOSPlatformVersion": "7.0"
  }
}

Kural: uygulamanın tamamı bir platforma bağlıysa OS'e özel TFM, yalnız birkaç çağrı bağlıysa çalışma anı kontrolü. bookstore-admin her yerde çalışmalı; bu denemeyi koda almadık.

Configuration ve Kendi Derleme Sembollerin

TFM sembollerinin yanında configuration'dan gelen semboller de var. Build sonrası değeri -getProperty ile sorabilirsin:

dotnet build src/Bookstore.Contracts -f net10.0 -getProperty:DefineConstants
dotnet build src/Bookstore.Contracts -f net10.0 -c Release -getProperty:DefineConstants
TRACE;DEBUG
TRACE;RELEASE

TFM sembolleri bu listede görünmüyor; SDK onları derleyiciye ayrıca veriyor. Kendi sembolünü eklerken mevcut listeyi koru: <DefineConstants>$(DefineConstants);BOOKSTORE_DIAGNOSTICS</DefineConstants>. $(DefineConstants); önekini unutursan SDK'nın eklediği sembolleri silersin ve #if DEBUG blokları Debug build'de bile derlenmez.

Derleme sembolleri derleme anında karar verir; aynı paket herkese aynı davranışı götürür. Bir özelliği ortam ya da müşteriye göre açıp kapatmak istiyorsan bu iş derleme sembolünün değil, çalışma anında okunan konfigürasyonun ya da bir feature flag'in (bir özelliği yeniden deploy etmeden açıp kapatan ayar) işi. #if'i platform ve framework farkları için sakla.

Bookstore: .NET 8'de Kalan Export Servisi

Raporlama ekibinin samples/reporting altındaki eski export servisi net8.0 hedefliyor ve muhasebe sistemi için seri kitapları raf sırasıyla dışa aktarıyor. Paketin 1.0.0 sürümüyle restore'u şöyle bitti:

C:\src\Bookstore\samples\reporting\Bookstore.LegacyExport\Bookstore.LegacyExport.csproj : error NU1202: Package Bookstore.Contracts 1.0.0 is not compatible with net8.0 (.NETCoreApp,Version=v8.0). Package Bookstore.Contracts 1.0.0 supports: net10.0 (.NETCoreApp,Version=v10.0) [C:\src\Bookstore\samples\reporting\Bookstore.Reporting.slnx]

Hata mesajı paketin hangi framework'leri desteklediğini açıkça söylüyor. 1.1.0'ı pack edip bir önceki yazıdaki feed'e push ettik ve Reporting'in Directory.Packages.props'unda sürümü yükselttik. İlk restore yine 1.1.0'ı bulamadı: NuGet, feed'in paket listesini HTTP cache'inde tuttuğu için birkaç dakika önce push edilen sürümü henüz görmüyordu. Cache'i atlayarak tekrar denedik:

dotnet restore --no-http-cache
dotnet run --project Bookstore.LegacyExport
dotnet run --project Bookstore.Reporting
  Restored C:\src\Bookstore\samples\reporting\Bookstore.LegacyExport\Bookstore.LegacyExport.csproj (in 33 ms).
  Restored C:\src\Bookstore\samples\reporting\Bookstore.Reporting\Bookstore.Reporting.csproj (in 33 ms).
The Hobbit
volume 1
Volume 2
Volume 10
Contracts 1.1.0.0 on 8.0.34
...
Contracts 1.1.0.0 on 10.0.15

Aynı paket iki servise iki farklı dll ile gitti: export servisi lib/net8.0'daki yedek karşılaştırıcıyla .NET 8.0.34'te, Reporting lib/net10.0'daki NumericOrdering ile .NET 10'da çalışıyor. Sıralama ikisinde de aynı. Version değiştiği için iki lock file da güncellendi ve commit'e girdi.

Üretimde Dikkat

Her ek TFM, CI süresini ve test sayısını artırır. Çok hedefli bir library'de CI'ın her hedefi derleyip test etmesi şart; ama hedef listesini tüketicilere göre düzenli gözden geçir ve kimsenin kullanmadığı hedefi çıkar. .NET 8 hedefini, onu kullanan son servis geçtiğinde kaldırmak bir sonraki büyük sürümün işi olur.

Yedek koddaki davranış farklarını test et. NaturalTitleComparer ASCII (128 karakterlik temel İngilizce karakter seti) rakamlarını sayı olarak karşılaştırıyor ve harfleri ordinal sıralıyor; .NET 10'daki culture'a duyarlı karşılaştırma ise noktalama ve aksanlı harflerde farklı sonuç verebilir. Bookstore'un örneklerinde ikisi aynı sırayı üretiyor; bu farkı bir test ile sabitlemek, bir gün birinin değiştirmesine karşı korur.

Desteği biten bir runtime'ı hedeflemek, o servis için güvenlik yaması gelmeyeceği anlamına gelir. Library'yi .NET 8 için derlemek geçişi kolaylaştırır, ama geçişin yerini tutmaz.

Sık Düşülen Tuzaklar

Directory.Build.props'ta TargetFramework varken projeye TargetFrameworks eklemek. Proje sessizce tek hedefle derlenir; çıktı klasörüne ya da paketin lib klasörüne bakmadan fark etmezsin.

#if'lerle okunmaz hâle gelen kod. Bir method'un içinde üç ayrı #if bloğu varsa, sürüme özel parçayı ayrı dosyaya ya da ayrı bir tipe al ve MSBuild koşuluyla derlemeye dahil et.

Bir TFM'de derlenip diğerinde testi hiç çalışmayan kod. Library çok hedefliyse test projesi de çok hedefli olmalı; yoksa #else dalı production'a test edilmeden gider.

Eşitlikle yazılmış TFM koşulları. '$(TargetFramework)' == 'net10.0' yeni bir hedef eklendiğinde yanlış dala düşer; IsTargetFrameworkCompatible ya da #if NET10_0_OR_GREATER gibi "en az" kontrolleri kullan.

Yeni push edilen sürümü restore'un görmemesine şaşırmak. NuGet'in HTTP cache'i paket listesini bir süre saklar; --no-http-cache ile atla.

Küçük Bir Deneme

★☆☆ Bookstore.Contracts'ın hedeflerine net9.0'ı ekle, dotnet build ve dotnet test çalıştır. Hangi pivot klasörleri oluştu, net9.0 build'inde Compat klasörü derlendi mi?

★★☆ NaturalTitleComparer'a "Volume 02" ile "Volume 2"yi ve "Dune" ile "dune"u karşılaştıran testler ekle; iki framework'te de aynı sonucu aldığından emin ol. Sonuçlar farklıysa hangisini doğru kabul edeceğine karar ver ve testi ona göre yaz.

★★★ netstandard2.0 hedefini geri ekle ve kalan hataları kendi kodunla çöz: char.IsAsciiDigit için Compat klasörüne küçük bir yardımcı yaz ve nullable uyarısı için akışı değiştir. Sonra pakete ve testlere bu hedefin getirdiği ek maliyeti not et.

Bu yazıda bir library'yi birden fazla framework için derledik ve paketledik. Sıradaki yazıda çıktının kendisine bakacağız: aynı bookstore-admin aracını framework-dependent, self-contained, single-file ve Native AOT (Ahead-of-Time; çalışmadan önce makine koduna derleme) olarak publish edip boyutlarını ve başlangıç sürelerini karşılaştıracağız.