Boundary'ler: Third-Party Kodu Sarmak ve Learning Test'ler
Ödeme SDK'sı, e-posta kütüphanesi ve cache gibi kontrol etmediğin kodu, kendi ihtiyacının şeklinde bir interface'in ve tek bir adapter'ın arkasına al; tiplerini ve exception'larını içeri sızdırma. Learning test'lerle kütüphanenin davranışını sabitle: MimeKit'in TryParse'ı "ada"yı bile geçerli sayıyor ve 4.17.0'dan 4.18.2'ye yükseltmede 13 test değişmeden geçip transitive güvenlik uyarısı kayboldu.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et: MimeKit Bu Adresleri Kabul Eder mi?
Legacy OrderService'teki static Mailer'ı gerçek bir SMTP (Simple Mail Transfer Protocol) kütüphanesiyle değiştiriyoruz ve MailKit'i (ve onun MIME (Multipurpose Internet Mail Extensions) katmanı MimeKit'i) seçtik. Ekipten biri fırsat bu fırsat CreateCustomer'daki email.Contains("@") kontrolünü de MimeKit'e bırakmak istiyor:
using MimeKit;
// CreateCustomer'daki email.Contains("@") kontrolünün yerine
static bool IsValidEmail(string input) => MailboxAddress.TryParse(input, out _);
string[] inputs = ["ada@example.com", "Ada Lovelace <ada@example.com>", "ada@example", "ada", "ada@@example.com"];
foreach (string input in inputs)
Console.WriteLine($"{input,-32} {IsValidEmail(input)}");
Beş adresten hangileri True döner? Sence bir müşteri kaydı için hangileri geçerli olmalı?
Boundary Nerede? Legacy Koddaki Üç Boundary
MimeKit 4.18.2 ile çıktı:
ada@example.com True
Ada Lovelace <ada@example.com> True
ada@example True
ada True
ada@@example.com False
Beşten dördü geçiyor; yalnız @@ reddediliyor. MimeKit yanlış bir şey yapmıyor: MailboxAddress bir e-posta header'ındaki adresi okumak için tasarlanmış ve RFC (Request for Comments, internet standartlarını tanımlayan doküman serisi) 5322'nin kabul ettiği her şeyi, display name'li biçimi ve domain'siz yerel adresleri de kabul ediyor. Bizim sorumuz ise başka: "bu müşteriye sipariş onayı gönderebilir miyim?" Kütüphanenin cevabı ile bizim sorumuz arasındaki bu boşluk, bu yazının konusu olan boundary'nin ta kendisi.
Robert C. Martin'in (Uncle Bob) Clean Code kitabının (2008; ikinci baskı 2025) boundaries bölümünü James Grenning (Agile Manifesto'nun imzacılarından, embedded TDD (Test-Driven Development) üzerine yazan bir danışman) yazdı ve ana fikri şu: kontrol etmediğin kodla kendi kodunun buluştuğu yerleri bilinçli yönet; o kodun tiplerini ve varsayımlarını sistemin içine yayma. Legacy dosyada üç tane böyle boundary var:
private PaymentClient pc = new PaymentClient("merchant-001", 30);ödeme SDK'sı (Software Development Kit) doğrudanOrderService'in field'ı; SDK'nın"00"başarı koduProcessDataveCancelOrder'da ayrı ayrı yazılmış, kuruş dönüşümü(long)(o.Total * 100)de iki yerde.private Dictionary<string, object> cacheve dışarıyaobjectdönen publicGetFromCache(string key); her çağıran neyin hangi tiple saklandığını tahmin etmek zorunda.- Static
Mailer.Send(to, subject, body, html); test edilemiyor, değiştirilemiyor, hatası yutulabiliyor.
Third-Party Kodu Kendi Interface'inin Arkasına Almak
Ödeme SDK'sı
İlk iş, ödemeyi bizim ihtiyacımızın şekliyle tarif eden bir interface. Vendor'ın ChargeResult'ı, "00"'ı ve long amountMinor'u bu interface'te yok:
public sealed record PaymentRequest(int OrderId, string CardToken, decimal Amount, string Currency);
public abstract record PaymentOutcome
{
public sealed record Approved(string TransactionId) : PaymentOutcome;
public sealed record Declined(string Reason) : PaymentOutcome;
}
// Bizim ihtiyacımızın şekli; vendor'ın değil
public interface IPaymentGateway
{
Task<PaymentOutcome> ChargeAsync(PaymentRequest request, CancellationToken ct = default);
}
Vendor'a dair her bilgi tek bir adapter class'ında toplanıyor:
public sealed class VendorPaymentGateway(PaymentClient client) : IPaymentGateway
{
private const string ApprovedCode = "00";
public Task<PaymentOutcome> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(request.CardToken);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.Amount);
try
{
ChargeResult result = client.Charge(request.CardToken, ToMinorUnits(request.Amount), request.Currency);
PaymentOutcome outcome = result.Code == ApprovedCode
? new PaymentOutcome.Approved(result.TransactionId)
: new PaymentOutcome.Declined($"{result.Message} ({result.Code})");
return Task.FromResult(outcome);
}
catch (Exception ex) when (ex is not ArgumentException)
{
throw new PaymentGatewayException(
$"Charging order {request.OrderId} ({request.Amount:0.00} {request.Currency}) failed.", ex);
}
}
// Kuruş dönüşümü tek yerde
private static long ToMinorUnits(decimal amount) => (long)Math.Round(amount * 100m, MidpointRounding.AwayFromZero);
}
Approved { TransactionId = TX0001 }
Declined { Reason = Do not honor (05) }
Bu GoF'un (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) Adapter pattern'i; mimari ölçekte ports and adapters diye de anılıyor. Kazanç üç yerde. Sağlayıcı değişirse yalnız bu class değişir. OrderPlacement'ın testleri bir önceki yazıdaki FakePaymentGateway ile vendor'a hiç dokunmadan koşar. Ve vendor'ın tutarsızlıkları (birazdan learning test'lerde göreceğiz) tek yerde normalize edilir.
.NET'in kendisi bu yaklaşımı kendi ekosistemi için uyguluyor. ILogger<T> arkasında Console, Serilog ya da OpenTelemetry olabilir; IDistributedCache arkasında Redis ya da SQL (Structured Query Language) Server; TimeProvider sistem saatinin kendisini soyutluyor. En yeni örneği Microsoft.Extensions.AI'daki IChatClient: farklı LLM (Large Language Model) sağlayıcılarının SDK'larını tek bir interface'in arkasına alıyor ve DI (Dependency Injection) container'ına tek bir kayıtla ekleniyor; LLM serisinin "Sağlayıcıdan Bağımsız LLM Kodu: IChatClient, OpenAI Uyumlu /v1 ve DI" yazısı bunu uygulamalı gösteriyor.
E-posta kütüphanesi
Static Mailer'ın yerine aynı mantıkla bir IEmailSender ve MailKit adapter'ı:
public sealed record EmailMessage(string To, string Subject, string Body);
public interface IEmailSender
{
Task SendAsync(EmailMessage message, CancellationToken ct = default);
}
public sealed class MailKitEmailSender(SmtpSettings settings) : IEmailSender
{
public async Task SendAsync(EmailMessage message, CancellationToken ct = default)
{
var mime = new MimeMessage();
mime.From.Add(new MailboxAddress("Bookstore", settings.FromAddress));
mime.To.Add(MailboxAddress.Parse(message.To));
mime.Subject = message.Subject;
mime.Body = new TextPart("plain") { Text = message.Body };
try
{
using var client = new SmtpClient();
await client.ConnectAsync(settings.Host, settings.Port, SecureSocketOptions.StartTlsWhenAvailable, ct);
await client.SendAsync(mime, ct);
await client.DisconnectAsync(quit: true, ct);
}
catch (Exception ex) when (ex is SocketException or IOException or ProtocolException or CommandException)
{
// MailKit tipleri bu class'ın dışına çıkmaz
throw new EmailDeliveryException($"Sending '{message.Subject}' to {message.To} failed.", ex);
}
}
}
Sarmanın yarısı tipler, diğer yarısı exception'lar. SMTP sunucusu ayakta değilken gönderince:
EmailDeliveryException: Sending 'Order #1000' to ada@example.com failed.
inner: System.Net.Sockets.SocketException: Connection refused
Uygulamanın geri kalanı EmailDeliveryException'ı bilir, MailKit'in SmtpCommandException'ını ya da SocketException'ı değil. Kütüphaneyi yarın başka bir sağlayıcının HTTP (Hypertext Transfer Protocol) API'siyle (Application Programming Interface) değiştirdiğinde retry ve log kodu aynı kalır. MimeMessage da interface'te yok; olsaydı MailKit'i değiştirmek her çağıranı değiştirmek demek olurdu.
Dictionary'yi ve Cache'i Sisteme Yaymamak
Grenning'in bölümdeki ilk örneği Java'nın Map'iydi: bir Map'i sistemin içinde dolaştırırsan herkes onu temizleyebilir, içine her şeyi koyabilir ve okurken cast etmek zorunda kalır. Legacy cache field'ı bunun C# hâli. Birisi teklifi double olarak, başka biri decimal olarak okursa:
var legacyCache = new Dictionary<string, object> { ["quote:1"] = 218.0 };
decimal quote = (decimal)legacyCache["quote:1"];
Unable to cast object of type 'System.Double' to type 'System.Decimal'.
object içindeki boxed double yalnız double'a unbox edilebiliyor. IMemoryCache'e geçmek bunu tek başına çözmüyor, sadece hatayı sessizleştiriyor; TryGetValue<decimal> tipi uyuşmayan bir değer bulduğunda exception fırlatmak yerine false dönüyor:
IMemoryCache TryGetValue<decimal>: False
Yani her istek cache'i ıskalıyor ve kimse fark etmiyor. Çözüm cache'i kendi tipinin arkasına almak; anahtarın biçimi, değerin tipi ve ömrü tek yerde:
public sealed class QuoteCache(IMemoryCache cache)
{
private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
public bool TryGet(int customerId, string cartKey, out decimal quote) =>
cache.TryGetValue(Key(customerId, cartKey), out quote);
public void Set(int customerId, string cartKey, decimal quote) =>
cache.Set(Key(customerId, cartKey), quote, Lifetime);
private static string Key(int customerId, string cartKey) => $"quote:{customerId}:{cartKey}";
}
QuoteCache: True 218.00
Martin'in önerisi Map'i hiç kullanmamak değil, onu boundary'nin dışına taşımamak: public API'de Dictionary<string, object> dönmemek ya da almamak. QuoteCache'i kullanan kod Clear() çağıramıyor, yanlış tipte değer koyamıyor ve süre politikasını bilmiyor.
Learning Test'ler: Kütüphaneyi Testle Öğrenmek
Açılıştaki sürprizi production'da değil bir testte öğrenmek mümkündü. Grenning'in önerisi şu: yeni bir kütüphaneyi denemek için zaten küçük deneme kodları yazıyorsun; onları atma, unit test olarak yaz. Kütüphanenin bizim kullandığımız davranışlarını sabitleyen bu testlere learning test deniyor:
// MimeKit'in bizim kullandığımız davranışları; sürüm yükseltmede önce bunlar koşar
public class MimeKitLearningTests
{
[Theory]
[InlineData("ada@example.com")]
[InlineData("Ada Lovelace <ada@example.com>")]
[InlineData("ada@example")]
[InlineData("ada")]
public void TryParse_accepts_addresses_that_are_valid_by_rfc_but_not_for_us(string input)
{
Assert.True(MailboxAddress.TryParse(input, out _));
}
[Fact]
public void Parse_throws_ParseException_not_FormatException()
{
Assert.Throws<ParseException>(() => MailboxAddress.Parse("ada@@example.com"));
}
[Fact]
public void Non_ascii_subject_is_encoded_as_utf8_base64_and_ascii_tail_stays_readable()
{
var message = new MimeMessage { Subject = "Siparişiniz alındı #1042" };
string? header = message.Headers[HeaderId.Subject];
using var stream = new MemoryStream();
message.Headers.WriteTo(stream);
string raw = Encoding.ASCII.GetString(stream.ToArray());
Assert.Equal("Siparişiniz alındı #1042", header);
Assert.Contains("Subject: =?utf-8?b?U2lwYXJpxZ9pbml6IGFsxLFuZMSx?= #1042", raw);
}
}
Aynı yaklaşım ödeme SDK'sı için daha da değerli, çünkü dokümantasyonu çoğu zaman eksik. SDK'nın dört davranışını sabitleyen testler yazınca bir tutarsızlık ortaya çıktı: reddedilen kart "05" koduyla dönüyor, bilinmeyen işlemin iadesi "12" koduyla dönüyor, ama eksik kart token'ı ArgumentNullException fırlatıyor. Ve SDK negatif tutarı hiç doğrulamıyor:
[Fact]
public void Amount_is_in_minor_units_and_is_not_validated()
{
ChargeResult result = _client.Charge("tok_1", -500, "TRY");
Assert.Equal("00", result.Code);
}
Bu testler adapter'daki iki guard'ın, ThrowIfNullOrEmpty ile ThrowIfNegativeOrZero'nun neden orada olduğunu belgeliyor; adapter -5 TRY'lik (Türk lirası) bir isteği SDK'ya hiç göndermeden reddediyor. 13 testin hepsi geçiyor (çıktıdan bir bölüm):
Passed Bookstore.LearningTests.PaymentClientLearningTests.Declined_card_is_a_result_code_not_an_exception [2 ms]
Passed Bookstore.LearningTests.PaymentClientLearningTests.Refund_with_unknown_transaction_returns_code_12_instead_of_throwing [< 1 ms]
Passed Bookstore.LearningTests.PaymentClientLearningTests.Amount_is_in_minor_units_and_is_not_validated [< 1 ms]
Passed Bookstore.LearningTests.PaymentClientLearningTests.Missing_card_token_throws_instead_of_returning_a_code [< 1 ms]
Passed Bookstore.LearningTests.MimeKitLearningTests.TryParse_accepts_addresses_that_are_valid_by_rfc_but_not_for_us(input: "ada") [< 1 ms]
Passed Bookstore.LearningTests.MimeKitLearningTests.Parse_throws_ParseException_not_FormatException [< 1 ms]
Passed Bookstore.LearningTests.MimeKitLearningTests.Non_ascii_subject_is_encoded_as_utf8_base64_and_ascii_tail_stays_readable [11 ms]
Learning test'in ikinci getirisi sürüm yükseltmede. Testleri ilk yazdığımda MimeKit 4.17.0 kullanıyordum ve build her seferinde şunu söylüyordu:
t08.csproj : warning NU1902: Package 'System.Security.Cryptography.Pkcs' 10.0.0 has a known moderate severity vulnerability, https://github.com/advisories/GHSA-2fmv-rv2c-59x7
t08.csproj : warning NU1903: Package 'System.Security.Cryptography.Pkcs' 10.0.0 has a known high severity vulnerability, https://github.com/advisories/GHSA-846f-f764-76mx
Açık MimeKit'in kendisinde değil, onun getirdiği transitive bir paketteydi. 4.18.2'ye yükseltince restore System.Security.Cryptography.Pkcs 10.0.13'ü çekti, uyarılar kayboldu ve 13 learning test değişiklik olmadan geçti. Yükseltmenin bizim kullandığımız davranışları bozmadığını, uygulamayı açıp elle denemeden öğrenmiş olduk. NuGetAudit'in nasıl çalıştığı ve transitive bağımlılıkların nasıl yönetildiği, tarayıcı tarafındaki SRI (Subresource Integrity) kontrolüyle birlikte OWASP (Open Worldwide Application Security Project) serisinin "OWASP A03: Software Supply Chain Failures — NuGetAudit, npm audit ve SRI" yazısında.
Henüz Var Olmayan API'ye Karşı Kod Yazmak
Legacy ShipOrder kargo etiketini bir .txt dosyasına yazıyor. Yeni sürümde etiketi bir kargo firmasının API'si üretecek, ama firma henüz seçilmedi ve sözleşme haftalar sürecek. Grenning'in bölümdeki hikâyesi de buydu: bir radyo verici alt sisteminin API'si henüz tasarlanmamışken ekip kendi istediği interface'i yazdı, kodunu ona karşı geliştirdi, gerçek API gelince arayı bir adapter'la kapattı.
public sealed record ShipmentRequest(int OrderId, string RecipientName, string Street, string Zip, string City, bool Express);
public sealed record ShippingLabel(string TrackingNumber, string Carrier);
// Kargo firması henüz seçilmedi: interface'i bugünkü ihtiyaca göre biz tanımlıyoruz
public interface IShippingLabelService
{
Task<ShippingLabel> CreateLabelAsync(ShipmentRequest request, CancellationToken ct = default);
}
Fake implementation istekleri saklıyor ve sahte bir takip numarası dönüyor:
ShippingLabel { TrackingNumber = FAKE000001, Carrier = fake }
Bu sırayla çalışmanın faydası, interface'in kütüphanenin değil kullanıcının diliyle konuşması. Gerçek API geldiğinde muhtemelen AddressLine1, PostalCode, ServiceLevel gibi kendi kavramlarını getirecek; o çeviri adapter'ın işi olacak. Fake ise testlerde kalıcı olarak kullanılmaya devam eder.
Lisans Değişince Sarmanın Kazandırdığı
Boundary'yi yönetmenin en az konuşulan getirisi teknik değil hukuki. 2025'te .NET dünyasında üç yaygın kütüphanenin lisansı değişti. Ocak 2025'te FluentAssertions 8, Apache 2.0'dan Xceed'in (.NET bileşenleri satan bir yazılım şirketi) ticari lisansına geçti; 7.x Apache 2.0 olarak kaldı. 2 Temmuz 2025'te AutoMapper ve MediatR'ın yazarı Jimmy Bogard, AutoMapper 15 ve MediatR 13 ile iki kütüphanenin ticari sürümlerini başlattı; küçük şirketler için ücretsiz bir community lisansı var ve eski sürümler kendi lisanslarıyla kalıyor.
Projede IMapper 300 dosyada doğrudan inject edildiyse lisans kararı 300 dosyalık bir migration'dır. DTO (Data Transfer Object) mapping'ini kendi extension method'larının ya da tek bir mapping katmanının arkasına aldıysan, değişiklik o katmanla sınırlıdır. Aynı şey test assertion'ları için de geçerli: domain-specific assert helper'ları kullanan bir test suite'i, FluentAssertions'tan Shouldly'ye ya da topluluk fork'u AwesomeAssertions'a geçerken çok daha az acı çeker; bu konuya iki yazı sonra, temiz test kodu yazısında döneceğiz. AutoMapper lisans değişikliğinin pratik sonuçları ve Mapperly alternatifi ASP.NET Core Web API serisinin "ASP.NET Core Web API'de DTO ve Mapping: Elle Mapping, AutoMapper Lisansı ve Mapperly" yazısında.
Sık Düşülen Tuzaklar
Kütüphanenin API'sini birebir kopyalayan bir interface yazmak. IMailKitClient adında, ConnectAsync, AuthenticateAsync, SendAsync(MimeMessage) method'larını aynen yansıtan bir interface hiçbir abstraction sağlamaz; yalnız mock'lanabilir hâle getirir. Interface'i kullanıcının sorusuyla tasarla: "sipariş onayını gönder".
Kütüphanenin tiplerini imzalardan sızdırmak. Adapter'ın dışına MimeMessage, ChargeResult ya da SmtpCommandException çıkıyorsa boundary sadece kâğıt üzerinde var. Aynı sızıntının en yaygın biçimi repository'den IQueryable<T> dönmek; o an Entity Framework Core'un (EF Core) çeviri kuralları tüm uygulamanın kuralı olur.
Fake ile gerçek implementation'ın zamanla ayrışması. Fake "tok_declined" için Declined dönerken gerçek adapter aynı durumda exception fırlatıyorsa testler yalan söyler. Aynı test senaryolarını hem fake'e hem gerçek adapter'a (sandbox ortamında) koşturan contract test'ler bu ayrışmayı yakalar.
Learning test'leri unit test klasörüne karıştırıp sonra silmek. Onlar senin kodunu değil kütüphaneyi test ediyor; ayrı bir class'ta, adıyla belli tut ve sürüm yükseltmelerinde ilk onlara bak.
Ne Zaman Kullanma
Her şeyi sarma. List<T>, Dictionary<TKey, TValue>, string, LINQ (Language Integrated Query) ve BCL'nin (Base Class Library, .NET'in temel class kütüphanesi) geneli bir third-party değil; IListWrapper yazmak hiçbir değişime karşı koruma sağlamaz. Sarma kararı için iki soru yeter: bu bağımlılığın değişme ihtimali var mı (sağlayıcı, lisans, sürüm), ve kullanıldığı yer büyük mü? İkisi de "hayır" ise doğrudan kullan.
Bilerek bağlandığın framework'ü sarmaya çalışma. ASP.NET Core'un HttpContext'ini ya da EF Core'un DbContext'ini kendi interface'lerinin arkasına almak çoğu projede büyük bir maliyet getirir ve framework'ün en iyi özelliklerini (change tracking, LINQ çevirisi) kullanılmaz hâle getirir. EF Core'un üstüne repository koyma tartışması ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Katmanlı Yapı: Service Katmanı ve Repository Tartışması" yazısında.
Tek bir yerde kullanılan bir kütüphaneyi de hemen sarma. Bir PDF (Portable Document Format) kütüphanesini yalnız fatura üreten tek bir class kullanıyorsa, o class zaten boundary'nin kendisi; ayrıca bir interface ikinci bir boundary çizgisi olmaktan öteye geçmez.
Küçük Bir Deneme
★☆☆ MimeKitLearningTests'e Türkçe display name'li bir alıcı için To header'ının nasıl encode edildiğini sabitleyen bir test ekle (new MailboxAddress("Ayşe Yılmaz", "ayse@example.com")). Sonra altıncı yazıdaki Email.TryCreate'in aynı adresle ne yaptığını kontrol et.
★★☆ VendorPaymentGateway'e RefundAsync ekle ve SDK'nın "12" kodunu nasıl çevireceğine karar ver: Declined mı, exception mı? Kararını learning test'le belgele.
★★★ IPaymentGateway için bir abstract contract test class'ı yaz ve aynı test'leri hem FakePaymentGateway'e hem VendorPaymentGateway'e koşturan iki türetilmiş test class'ı oluştur. Hangi test fake'te geçip gerçek adapter'da kırıldı?
Bu yazıda OrderService'in dışarıyla konuştuğu yerleri ayırdık. Geriye kalan şey, içerisi: 600 satırlık class hâlâ fiyat, kargo, iptal, rapor, fatura ve import işlerini birlikte taşıyor. Bir sonraki yazıda class'ın büyüklüğünü satırla değil sorumlulukla ölçecek ve cohesion'ı field kullanımına bakarak görünür yapacağız.