Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Clean Code — #07

Clean Code'da Error Handling: Exception, Try Pattern ve Result Pattern

Mert Özen 7 Kas 2026 14 dk 0 görüntülenme
Clean Code'da Error Handling: Exception, Try Pattern ve Result Pattern

Beklenen iş sonuçları (stok yok, kupon geçersiz, ödeme reddedildi) için Try pattern ya da Result<T>, bug'lar için argument exception'ları, altyapı hataları için context eklenmiş exception kullan ve onları yalnız uygulamanın sınırında yakala. Legacy OrderService'teki return code'larını, static LastError'ı ve boş catch bloklarını bu üç araçla temizliyoruz; ölçümde fırlatılan bir exception TryParse'tan yaklaşık 1.165 kat yavaş.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et: Üç İptal, İki False

Legacy OrderService ile Alan için bir sipariş açıyoruz, sonra onu iptal ediyoruz. Ardından aynı siparişi bir kez daha, son olarak da hiç var olmayan bir siparişi iptal etmeye çalışıyoruz:

var cart = new Dictionary<string, int> { ["978-0441013593"] = 1 };   // Dune
int id = service.ProcessData(2, cart, null, false, false, 1);
Console.WriteLine(id);
Console.WriteLine(service.CancelOrder(id, refund: true, notify: false));
Console.WriteLine(service.CancelOrder(id, refund: true, notify: false));
Console.WriteLine(service.CancelOrder(4242, refund: true, notify: false));
Console.WriteLine($"LastError: {OrderService.LastError ?? "null"}");

Hangi satırlar True, hangileri False basar? Daha önemlisi: False dönen bir çağrının neden başarısız olduğunu çağıran kod nereden öğrenir?

Return Code'lar ve Static LastError

1000
True
False
False
LastError: null

İkinci iptal sipariş zaten iptal edildiği için, üçüncüsü sipariş bulunamadığı için reddedildi. Çağıran kod bu iki durumu ayırt edemiyor: ikisi de false, LastError ikisinde de boş. CancelOrder'ın içinde false'a giden altı ayrı yol var (sipariş yok, kargoda, iptal edilmiş, ödeme hatalı, iade reddedildi, herhangi bir exception) ve yalnız biri LastError'a bir şey yazıyor.

ProcessData daha kalabalık: -1 boş sepet, -2 kitap yok, -3 stok yok, -4 geçersiz kupon, -5 ödeme reddedildi, -6 müşteri bloklu, -7 müşteri yok, -99 "bir şey oldu". Pozitif bir sayı ise sipariş numarası. Ödeme token'ı olmayan bir müşteriyle denediğimde:

Value cannot be null. (Parameter 'cardToken')
-99 LastError: null

Exception mesajı log'a değil konsola basıldı ve çağırana yalnız -99 ulaştı. Bilinmeyen kargo tipiyle (t = 4) çağırınca ise tersi oluyor; KargoUcretiHesapla içindeki throw new Exception("hata") hiçbir try'a takılmadan dışarı çıkıyor:

System.Exception: hata

Robert C. Martin'in (Uncle Bob) Clean Code kitabındaki (2008; ikinci baskı 2025) error handling bölümünün ilk önerisi tam olarak bu tabloya cevap: return code yerine exception kullan. Return code çağıranı her satırdan sonra bir if yazmaya zorlar, iş mantığı ile hata kontrolü iç içe geçer, ve kontrolü unutmak sessizce yanlış çalışmak demektir. Static LastError ise bunu daha da kötüleştiriyor: tüm OrderService instance'ları ve tüm thread'ler aynı field'ı paylaşıyor, yani iki eşzamanlı istek birbirinin hata mesajını ezebilir. Ama "her şeyi exception yap" da doğru cevap değil; önce hataları ayırmak gerekiyor.

Hangi Hata Hangi Aracı Hak Eder?

Legacy kodun bütün başarısızlıklarını üç gruba ayırınca karar kolaylaşıyor:

DurumTürAraç
Boş sepet, geçersiz kupon, stok yetersiz, ödeme reddedildiBeklenen iş sonucuResult<T> ya da Try pattern
t = 4, null kart token'ı, negatif miktarProgramlama hatası, bir bugArgument exception'ları, fail fast
Ödeme sağlayıcısı cevap vermiyor, disk doluAltyapı hatasıException, context ekleyerek; boundary'de yakala
Müşteri ya da sipariş bulunamadıBağlama görenull (Find), exception (Get) ya da Result

Beklenen iş sonuçları normal akışın parçası: her gün yüzlerce müşteri tükenmiş bir kitabı sepete atar. Bunlar için exception kullanmak hem yavaş hem de yanıltıcı, çünkü exception "bunun olmasını beklemiyordum" demektir. Programlama hataları ise tam tersine yakalanmamalı; program erken ve gürültülü şekilde düşmeli ki bug bulunsun. Microsoft Learn'deki "Best practices for exceptions" sayfası da aynı ayrımı yapıyor: sık beklenen durumları exception'sız ele al, gerçekten istisnai olanları exception'a bırak.

Beklenen Durumlar İçin Try Pattern

.NET'in kendi API'leri (Application Programming Interface) bu ayrımı kırk yerde uyguluyor: int.Parse ile int.TryParse, Dictionary<TKey, TValue> indexer'ı ile TryGetValue, ConcurrentDictionary.TryAdd, Channel yazıcısındaki TryWrite. Framework Design Guidelines (Krzysztof Cwalina ve Brad Abrams'ın .NET kütüphane tasarımı kitabı; Microsoft Learn'de de yayında) bunu iki pattern olarak adlandırıyor. Tester-Doer'da önce soru sorulur (ICollection<T>.IsReadOnly), sonra iş yapılır (Add). Try-Parse'ta ikisi tek method'da birleşir: Try ön ekiyle başlar, bool döner, sonucu out parametresiyle verir; ve her Try method'unun exception fırlatan bir eşi bulunmalıdır.

Farkın neden önemli olduğunu ölçtüm. Geçersiz bir kupon kodunu sayıya çevirmeye çalışan iki method, BenchmarkDotNet 0.15.8 ile Apple M5 üzerinde:

| Method        | Mean         | Error      | StdDev     | Ratio    | RatioSD | Gen0   | Allocated | Alloc Ratio |
|-------------- |-------------:|-----------:|-----------:|---------:|--------:|-------:|----------:|------------:|
| WithTryParse  |     1.902 ns |  0.0030 ns |  0.0027 ns |     1.00 |    0.00 |      - |         - |          NA |
| WithException | 2,215.906 ns | 43.2412 ns | 42.4687 ns | 1,164.91 |   21.72 | 0.0610 |     512 B |          NA |

Fırlatılıp yakalanan her exception yaklaşık 2,2 mikrosaniye ve 512 byte allocation. Tek bir istekte önemsiz, saniyede on binlerce doğrulama yapan bir import işinde ise belirleyici. Legacy CheckStock hem stok kontrolü yapıp hem stok düşüyordu, adı bu side effect'i hiç söylemiyordu. Aynı işi Try-Parse kurallarıyla yazalım:

public sealed class Inventory(Dictionary<string, int> stock)
{
    public int Available(string isbn) => stock.GetValueOrDefault(isbn);

    // Try-Parse pattern: beklenen başarısızlık bool ile döner
    public bool TryReserve(string isbn, int quantity, [NotNullWhen(true)] out StockReservation? reservation)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        reservation = null;
        if (Available(isbn) < quantity)
            return false;

        stock[isbn] -= quantity;
        reservation = new StockReservation(isbn, quantity);
        return true;
    }

    // Guideline: her Try method'unun exception fırlatan bir eşi olsun
    public StockReservation Reserve(string isbn, int quantity) =>
        TryReserve(isbn, quantity, out StockReservation? reservation)
            ? reservation
            : throw new InsufficientStockException(isbn, quantity, Available(isbn));
}
Not enough stock, available: 3
InsufficientStockException: Only 3 of 978-0141439518 left, 5 requested.

İki ayrıntı önemli. [NotNullWhen(true)] attribute'u derleyiciye "true döndüğünde reservation null değildir" der, böylece çağıran kod reservation.Quantity'yi uyarısız okur. Negatif miktar ise "stok yetersiz" ile aynı kategoride değil: o bir bug, bu yüzden Try method'u bile onu ArgumentOutOfRangeException ile reddediyor. Guideline'ın deyişiyle Try'ın kapsamı dar tanımlanmalı; tanımın dışındaki her başarısızlık yine exception'dır.

Result Pattern: Hatayı Tipe Dönüştürmek

Try pattern tek bir başarısızlık nedeni olduğunda güzel çalışır. Sipariş vermenin ise yedi farklı beklenen başarısızlığı var ve ekran her birine farklı tepki vermeli. -3 yerine hatanın kendisini bir tip yapalım:

public abstract record Error(string Code, string Message);

public sealed record CustomerNotFound(int CustomerId)
    : Error("order.customer_not_found", $"Customer {CustomerId} was not found.");

public sealed record OutOfStock(string Title, int Requested, int Available)
    : Error("order.out_of_stock", $"Only {Available} of '{Title}' left, {Requested} requested.");

public sealed record InvalidCoupon(string CouponCode)
    : Error("order.invalid_coupon", $"Coupon '{CouponCode}' is not valid.");

public sealed record PaymentDeclined(string Reason)
    : Error("order.payment_declined", $"Payment was declined: {Reason}.");

CustomerBlocked, EmptyCart ve BookNotFound da aynı biçimde. Bunları taşıyan Result<T> küçük bir class:

public sealed class Result<T>
{
    private readonly T? _value;

    private Result(T value) => (_value, IsSuccess) = (value, true);
    private Result(Error error) => Error = error;

    public bool IsSuccess { get; }
    public Error? Error { get; }

    public T Value => IsSuccess ? _value! : throw new InvalidOperationException($"Result has no value: {Error!.Code}.");

    public static implicit operator Result<T>(T value) => new(value);
    public static implicit operator Result<T>(Error error) => new(error);

    public TOut Match<TOut>(Func<T, TOut> onSuccess, Func<Error, TOut> onFailure) =>
        IsSuccess ? onSuccess(_value!) : onFailure(Error!);
}

Implicit operator'lar sayesinde OrderPlacement içinde hem hata hem başarı düz bir return ile dönüyor:

public async Task<Result<PlacedOrder>> PlaceAsync(PlaceOrderRequest request, CancellationToken ct = default)
{
    ArgumentNullException.ThrowIfNull(request);

    if (!customers.TryGetValue(request.CustomerId, out Customer? customer))
        return new CustomerNotFound(request.CustomerId);
    if (customer.IsBlocked)
        return new CustomerBlocked(customer.Id);
    if (request.Lines.Count == 0)
        return new EmptyCart();

    // ... kitap ve stok kontrolleri, kupon, toplam

    PaymentOutcome outcome = await payments.ChargeAsync(customer.CardToken, total, ct);
    if (outcome is PaymentOutcome.Declined declined)
        return new PaymentDeclined(declined.Reason);

    foreach (CartLine line in request.Lines)
        inventory.Reserve(line.Isbn, line.Quantity);

    return new PlacedOrder(_nextOrderId++, total, ((PaymentOutcome.Approved)outcome).TransactionId);
}

Yedi farklı istekle çağırıp her sonucu Match ile yazdırınca:

OK   #1000 801.90 TRY (TX0001)
FAIL order.customer_not_found: Customer 9 was not found.
FAIL order.customer_blocked: Customer 2 is blocked.
FAIL order.empty_cart: The cart is empty.
FAIL order.out_of_stock: Only 1 of 'Emma' left, 7 requested.
FAIL order.invalid_coupon: Coupon 'SUMMER99' is not valid.
FAIL order.payment_declined: Payment was declined: Do not honor.

Method'un imzası artık dürüst: Task<Result<PlacedOrder>> "bu çağrı başarısız olabilir" diyor ve çağıranın sonucu açmadan siparişe ulaşması mümkün değil. Hatanın kendisi de bilgi taşıyor; ekran OutOfStock'un Available değerini okuyup kullanıcıya teklif yapabiliyor:

string screen = failed.Error switch
{
    null => "Thank you!",
    OutOfStock e => $"Only {e.Available} left. Reduce the quantity?",
    InvalidCoupon => "Check the coupon code.",
    PaymentDeclined => "Try another card.",
    _ => failed.Error.Message
};
Only 1 left. Reduce the quantity?

Buradaki _ kolu bir zayıflık: yeni bir hata tipi eklendiğinde derleyici bu switch'i göstermez, çünkü Error açık bir hiyerarşi. C# 15 ile gelen union type'lar bu boşluğu kapatıyor; case tiplerinin hepsini ele almayan switch için derleyici uyarı veriyor.

Null Dönme, Null Geçme

Legacy GetOrders müşterinin siparişi yoksa null döndürüyor; her çağıran bunu bilmek ve kontrol etmek zorunda, unutan foreach'te NullReferenceException alıyor. Martin'in iki kuralı burada: null döndürme, null geçme. Collection dönen method'lar için cevap basit, boş collection dön: return orders.Where(o => o.CustomerId == customerId).ToList(); sıfır elemanla da geçerli bir sonuç.

Tek bir object için "yok" gerçekten olası bir cevapsa, bunu tipe yaz. .NET'te yerleşik bir isimlendirme var: Find / TryGet "bulamayabilirim" der ve Order? döner, Get "burada olmalı" der ve bulamazsa exception fırlatır. Nullable Reference Types (NRT) açıkken derleyici contract'ı zorluyor:

Order? order = repository.FindOrder(4242);
Console.WriteLine(order.Total);
Program.cs(26,19): warning CS8602: Dereference of a possibly null reference.

Uyarıyı görmezden gelip çalıştırınca da tahmin edilen sonuç:

Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.<Main>$(String[] args) in .../Program.cs:line 26

Fark, legacy kodda bu bilginin hiçbir yerde yazmaması, burada ise derleme sırasında ortaya çıkması. "Yok" durumunun bir davranışı varsa (misafir müşteri, süresiz bitiş tarihi) null yerine Special Case (Martin Fowler'ın Patterns of Enterprise Application Architecture kitabındaki pattern) ya da Null Object daha temizdir; Çözüm Desenleri serisinin "Sentinel Value'lar: 0, -1, "1900-01-01" ve 9999-12-31'in Gizli Maliyeti" yazısı Special Case'i gerçek bir örnekle gösteriyor. NRT'nin ayrıntıları C# İleri serisinin "C#'ta Nullable Reference Types Derinlemesine: NotNullWhen, MaybeNull ve API Tasarımı" yazısında.

Exception'ı Nerede Yakalamalı: Boundary ve Context

Ödeme sağlayıcısı cevap vermediğinde bu artık bir iş sonucu değil, bir altyapı hatası. OrderPlacement bundan kurtulamaz; yapabileceği en iyi şey exception'ın yoluna devam etmesine izin vermek. Exception'ı sarmalayan katman, yani ödeme adapter'ı ise ona context eklemeli: hangi tutar, hangi işlem. Orijinal exception InnerException olarak kalır:

PaymentGatewayException: Charging 495.00 TRY failed: provider did not answer.
  inner: TimeoutException: The operation has timed out.
Dune stock: 9

Son satır da önemli: ödeme başarısız olduğu için stok düşülmedi. Microsoft Learn'ün "exception sonrası state'i geri getir" önerisinin en ucuz uygulaması, side effect'i başarıdan sonraya koymak.

Exception'ı yakalayıp yeniden fırlatman gerekiyorsa throw; yaz, throw ex; değil. Aynı method'un iki sürümü:

public void SaveWithThrowEx(int id)
{
    try
    {
        WriteToDisk(id);
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine($"log: {ex.Message}");
        throw ex;
    }
}

Diğer sürüm aynı, yalnız son satırı throw;. Derleyici farkı zaten görüyor:

Program.cs(45,17): warning CA2200: Re-throwing caught exception changes stack information (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2200)

İki stack trace yan yana:

throw ex:
   at Bookstore.Orders.OrderRepository.SaveWithThrowEx(Int32 id) in .../Program.cs:line 45
   at Program.<Main>$(String[] args) in .../Program.cs:line 7
throw:
   at Bookstore.Orders.OrderRepository.WriteToDisk(Int32 id) in .../Program.cs:line 62
   at Bookstore.Orders.OrderRepository.SaveWithThrow(Int32 id) in .../Program.cs:line 53
   at Program.<Main>$(String[] args) in .../Program.cs:line 17

throw ex; hatanın asıl çıktığı WriteToDisk satırını sildi. Gece 03:00'te production log'unu okuyan kişi için bu fark, beş dakika ile bir saat arasındaki fark.

Peki exception sonunda nerede yakalanacak? Uygulamanın boundary'sinde: HTTP (Hypertext Transfer Protocol) isteğinin, background job'ın, mesaj handler'ının en dışında. ASP.NET Core'da bu iki katmanlı oluyor. Beklenen hatalar Result'tan HTTP contract'ına endpoint'te çevriliyor, beklenmeyenler IExceptionHandler'a düşüyor:

app.MapPost("/orders", async (PlaceOrderRequest request, OrderPlacement placement, CancellationToken ct) =>
{
    Result<PlacedOrder> result = await placement.PlaceAsync(request, ct);
    return result.Match(
        order => Results.Created($"/orders/{order.OrderId}", order),
        error => error.ToProblem());
});

public static class ErrorMapping
{
    // Domain hatası → HTTP contract'ı (yalnız boundary'de)
    public static IResult ToProblem(this Error error)
    {
        int status = error switch
        {
            CustomerNotFound or BookNotFound => StatusCodes.Status404NotFound,
            OutOfStock => StatusCodes.Status409Conflict,
            _ => StatusCodes.Status422UnprocessableEntity
        };
        return Results.Problem(
            title: error.Message,
            statusCode: status,
            extensions: new Dictionary<string, object?> { ["code"] = error.Code });
    }
}

PaymentGatewayExceptionHandler exception'ı log'layıp 503 dönüyor. curl ile üç istek:

HTTP/1.1 201 Created
Location: /orders/1000
{"orderId":1000,"total":198.00,"transactionId":"TX0001"}

HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Only 2 of 'Emma' left, 7 requested.","status":409,"code":"order.out_of_stock","traceId":"00-127e77baf37832a6f0f6c091e0b65431-54b80ce55d983883-00"}

HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Payment service is temporarily unavailable.","status":503,"traceId":"00-5e7c94686e72956ea50e7fb9192cb39e-8d0c1fc1dea10fc5-00"}

Domain HTTP status kodlarından habersiz, endpoint ise iş kurallarından. IExceptionHandler, AddProblemDetails ve status kod seçimi ASP.NET Core Web API serisinin "ASP.NET Core'da Global Hata Yönetimi: Exception Middleware ve ProblemDetails" yazısında; hata cevaplarının neden iç ayrıntı sızdırmaması gerektiği OWASP (Open Worldwide Application Security Project) serisinin "OWASP A10: Mishandling of Exceptional Conditions — Fail Closed ve ProblemDetails" yazısında.

Sık Düşülen Tuzaklar

En yaygını exception yutmak. Legacy dosyada beş tane var ve CA1031'i (CA: .NET'in Code Analysis kuralları) .editorconfig'de warning yapınca hepsi görünüyor:

OrderService.cs(121,25): warning CA1031: Modify 'ProcessData' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)
OrderService.cs(235,13): warning CA1031: Modify 'CancelOrder' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)
OrderService.cs(427,21): warning CA1031: Modify 'ImportBooks' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)
OrderService.cs(430,13): warning CA1031: Modify 'ImportBooks' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)
OrderService.cs(457,13): warning CA1031: Modify 'SendMail' to catch a more specific allowed exception type, or rethrow the exception (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031)

ImportBooks'taki satır başına catch { } en sinsisi. Stok kolonunda "ten" yazan bir CSV'yi (Comma-Separated Values) yükleyince method 2 dönüyor, LastError boş; kimse bir kitabın eksik kaldığını öğrenmiyor. CA2201'i de açınca KargoUcretiHesapla'daki throw new Exception("hata") görünüyor: System.Exception yeterince spesifik değil. Düzeltmede satır hataları bir rapor olarak dönüyor, dosyanın hiç okunamaması ise exception olarak kalıyor:

public sealed record ImportError(int LineNumber, string Reason);

public sealed record ImportReport(IReadOnlyList<Book> Imported, IReadOnlyList<ImportError> Errors);

public static class BookCsvImporter
{
    // Satır hataları beklenen sonuçtur; dosya okunamıyorsa exception çağırana gider
    public static ImportReport Import(string path) => Parse(File.ReadLines(path));

    public static ImportReport Parse(IEnumerable<string> lines)
    {
        List<Book> imported = [];
        List<ImportError> errors = [];
        int lineNumber = 0;

        foreach (string line in lines)
        {
            lineNumber++;
            string[] parts = line.Split(';');
            if (parts.Length < 5)
                errors.Add(new(lineNumber, $"Expected at least 5 columns, found {parts.Length}."));
            else if (!decimal.TryParse(parts[3], NumberStyles.Number, CultureInfo.InvariantCulture, out decimal price))
                errors.Add(new(lineNumber, $"Price '{parts[3]}' is not a number."));
            else if (!int.TryParse(parts[4], CultureInfo.InvariantCulture, out int _))
                errors.Add(new(lineNumber, $"Stock '{parts[4]}' is not a whole number."));
            else
                imported.Add(new Book(parts[0], parts[1], price));
        }

        return new ImportReport(imported, errors);
    }
}
Imported: 2
  line 2: Stock 'ten' is not a whole number.
FileNotFoundException: missing.csv

İkinci tuzak her katmanda log'layıp yeniden fırlatmak. Repository log'lar, service log'lar, controller log'lar; tek bir hata log'da üç kez görünür ve hangisinin asıl olduğu anlaşılmaz. Log'u exception'ı gerçekten ele alan yer, yani boundary yazar.

Üçüncüsü OperationCanceledException'ı hata saymak. Kullanıcı sayfayı kapattığında iptal edilen bir istek bir bug değildir; catch (Exception) ile yakalayıp 500 dönmek ya da error seviyesinde log'lamak alarm gürültüsü üretir.

Dördüncüsü Result'ı her yere yaymak: repository'den controller'a kadar her method Result<T> dönünce kod if (!result.IsSuccess) return result.Error; satırlarıyla dolar, Go'daki if err != nil tekrarının C# versiyonu oluşur. Result'ı iş kararının verildiği yerde kullan, altyapı hatalarını Result'a sarma.

Ne Zaman Kullanma

Result pattern'i bir library'nin public API'sinde kullanmadan önce iki kez düşün. .NET ekosisteminin yerleşik convention'ı exception ve Try pattern; kullanıcıların Result<T>'ni ve hata hiyerarşini öğrenmesi gerekir. Uygulamanın kendi domain'inde ise bu bedel düşük.

Sık olmayan ve kurtarılamayan durumlar için Try ya da Result yazma. Configuration dosyası bozuksa uygulama açılırken düşmeli; TryLoadConfiguration gibi bir method çağıranı imkânsız bir kararla baş başa bırakır.

Tek bir başarısızlık nedeni varsa Result'ın tüm altyapısına gerek yok. TryReserve gibi bir bool + out ya da Find + null daha az kodla aynı şeyi söyler.

Kısa ömürlü bir script ya da tek seferlik migration aracında da bu inceliklere gerek yok; ilk hatada exception ile durmak çoğu zaman en doğru davranış.

Küçük Bir Deneme

★☆☆ Legacy CancelOrder'ı Result<CancelledOrder> dönecek şekilde yeniden yaz. Her return false için bir hata tipi aç; boş catch bloğunu sil ve iade sırasında oluşan exception'ın nereye gitmesi gerektiğine karar ver.

★★☆ OrderPlacement'a CancellationToken ile iptal edilen bir ödeme senaryosu ekle. OperationCanceledException hangi katmanda yakalanmalı, ASP.NET Core boundary'si ona hangi status kodunu dönmeli?

★★★ Error hiyerarşisindeki _ kolunu kaldırmanın bir yolunu bul: Match method'unu her hata tipi için ayrı bir Func alacak şekilde genişlet ve yeni bir hata eklenince derlemenin kırıldığını göster. Bu tasarımın bedeli ne?

Bu yazıda hataları ayırdık ve ödeme sağlayıcısının exception'ını kendi tipimize sardık. Bir sonraki yazı bu sarma işini genelleştiriyor: legacy kodun doğrudan kullandığı ödeme SDK'sı (Software Development Kit), Dictionary<string, object> cache'i ve e-posta kütüphanesi için boundary'ler, ve yeni bir kütüphanenin davranışını learning test'lerle öğrenmek.