Result ve Railway-Oriented Programming: Hatayı Değer Olarak Taşımak ve Validation Biriktirmek
Railway-oriented programming, başarısız olabilen adımları Bind ile iki raylı bir akışa bağlar: bir adım hata verirse sonrakiler çalışmaz ve hata sonuna kadar değer olarak taşınır; birbirinden bağımsız form alanlarında ise hatalar biriktirilir. Bookstore'un sipariş akışını Map, Bind, Ensure, Tap ve Match ile kuruyor, Task<Result<T>> zincirlerini, TypedResults ve ValidationProblem eşlemesini, Angular tarafındaki UI state'ini ve exception ile Result sınırını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10 (SDK 10.0.402) ve ASP.NET Core 10 ile, TypeScript örnekleri TypeScript 7.0.2, Node.js 26 ve Angular 22.2 (@angular/core 22.2.6) ile derlenip denenmiştir.
Önce Tahmin Et: Üç Hatalı Alan, Kaç Hata Mesajı?
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) sipariş formunda üç alan var: e-posta, kitabın ISBN'i (kitapların uluslararası standart numarası) ve adet. Her alanın kendi doğrulama fonksiyonu Result döndürüyor ve üçü, Design Patterns serisinde gördüğümüz gibi Bind ile zincirlenmiş:
// Kısa devre: ilk hatada durur
public static Result<ValidOrderRequest> ValidateFirstError(PlaceOrderRequest request) =>
ValidateEmail(request.Email)
.Bind(email => ValidateIsbn(request.Isbn)
.Bind(isbn => ValidateQuantity(request.Quantity)
.Map(quantity => new ValidOrderRequest(email, isbn, quantity))));
Ada formu aceleyle dolduruyor: e-postada @ yerine nokta var, ISBN'in son hanesi yanlış, adet sıfır. Üç alan da hatalı. Form gönderildiğinde müşteriye kaç hata mesajı gösterilir?
PlaceOrderRequest broken = new("ada.example.com", "9780441013599", 0, null);
Result<ValidOrderRequest> result = OrderValidation.ValidateFirstError(broken);
Console.WriteLine(result);
Console.WriteLine($"errors shown to the customer: {result.Errors.Count}");
Cevap: Bind İlk Hatada Raydan Çıkar
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-guess
Failure(email.invalid)
errors shown to the customer: 1
Bir hata. Bind, önceki adım başarısızsa sonraki fonksiyonu hiç çağırmıyor; ISBN ve adet kontrol bile edilmedi. Ada e-postayı düzeltip gönderiyor, bu kez ISBN hatası alıyor; onu da düzeltiyor, şimdi adet hatası. Üç hata için üç deneme. Sipariş akışında bu davranış tam istediğimiz şey (stok yoksa ödeme alınmamalı), ama bir formu doğrularken kullanıcıyı yoruyor.
Bu yazı tam bu iki davranışın, kısa devre yapan ve hataları biriktiren composition'ın, ne zaman hangisinin kullanılacağıyla ilgili. Result'ın ne olduğu ve exception, Try pattern ile karşılaştırması Clean Code serisinin "Clean Code'da Error Handling: Exception, Try Pattern ve Result Pattern" yazısında; adımları Then ile bağlayan küçük bir Result ise Design Patterns serisinin "GoF Dışında Bilmen Gereken Kod Seviyesi Pattern'ler: Null Object, Specification, Result ve Pipeline" yazısında. Burada o tanımları tekrar etmiyoruz; üstlerine composition için gereken method'ları, biriktiren validation'ı, async zincirleri ve HTTP ile UI sınırlarını ekliyoruz.
Result'ı Composition İçin Tamamlamak
Önceki yazıdaki Option "değer yok" diyordu; Result buna "neden yok"u ekliyor. Başarısız taraf bir hata listesi taşıyor, çünkü birazdan hataları biriktireceğiz:
public sealed record Error(string Code, string Message);
// Struct değil class: aksi hâlde default(Result<T>) değersiz bir "başarı" olurdu
public sealed class Result<T>
{
private readonly T? _value;
private Result(T value) => (_value, Errors) = (value, []);
private Result(IReadOnlyList<Error> errors) => Errors = errors;
public IReadOnlyList<Error> Errors { get; }
public bool IsSuccess => Errors.Count == 0;
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(Error error) => new([error]);
public static Result<T> Failure(IReadOnlyList<Error> errors) =>
errors.Count > 0 ? new(errors) : throw new ArgumentException("A failure needs at least one error.", nameof(errors));
public static implicit operator Result<T>(T value) => Success(value);
public static implicit operator Result<T>(Error error) => Failure(error);
public Result<TOut> Map<TOut>(Func<T, TOut> map) =>
IsSuccess ? Result<TOut>.Success(map(_value!)) : Result<TOut>.Failure(Errors);
public Result<TOut> Bind<TOut>(Func<T, Result<TOut>> bind) =>
IsSuccess ? bind(_value!) : Result<TOut>.Failure(Errors);
public Result<T> Ensure(Func<T, bool> predicate, Func<T, Error> error) =>
IsSuccess && !predicate(_value!) ? Failure(error(_value!)) : this;
public Result<T> Tap(Action<T> action)
{
if (IsSuccess)
action(_value!);
return this;
}
public TOut Match<TOut>(Func<T, TOut> success, Func<IReadOnlyList<Error>, TOut> failure) =>
IsSuccess ? success(_value!) : failure(Errors);
public override string ToString() =>
IsSuccess ? $"Success({_value})" : $"Failure({string.Join(", ", Errors.Select(error => error.Code))})";
}
Beş method'un her birinin net bir rolü var. Map, başarılı değeri dönüştürüyor ve başarısız olamayan adımlar için. Bind, kendisi de başarısız olabilen bir adımı bağlıyor. Ensure, başarılı değer üzerinde bir koşul kontrol ediyor ve koşul tutmazsa hataya çeviriyor; ayrı bir validation fonksiyonu yazmadan "adet stoktan fazla olamaz" gibi kuralları zincire eklemeye yarıyor. Tap, değeri değiştirmeden bir side effect çalıştırıyor (log yazmak gibi). Match ise zincirin sonunda iki rayı tek bir değere indiriyor.
İki tasarım kararı Option'dan farklı. Result bir class: Option'da default değerin None olması anlamlıydı, Result'ta ise default için anlamlı bir karşılık yok; struct olsaydı default(Result<T>) hata listesi boş olduğu için "başarılı" görünür ve içinden default(T) çıkardı. İkincisi, implicit conversion'lar sayesinde doğrulama fonksiyonları ya değeri ya da bir Error'ı doğrudan return edebiliyor:
// Pure doğrulama kuralları: I/O yok, saat yok
public static class OrderValidation
{
public const int MaxQuantity = 20;
public static Result<string> ValidateEmail(string? email) =>
string.IsNullOrWhiteSpace(email) ? new Error("email.missing", "Email is required.")
: email.Contains('@') && email.IndexOf('.', email.IndexOf('@')) > 0 ? email.Trim()
: new Error("email.invalid", $"'{email}' is not a valid email address.");
public static Result<string> ValidateIsbn(string? isbn)
{
string digits = new((isbn ?? "").Where(char.IsAsciiDigit).ToArray());
if (digits.Length != 13)
return new Error("isbn.length", "ISBN-13 must have 13 digits.");
int sum = digits.Take(12).Select((c, i) => (c - '0') * (i % 2 == 0 ? 1 : 3)).Sum();
int check = (10 - sum % 10) % 10;
return check == digits[12] - '0' ? digits : new Error("isbn.checksum", $"{digits} has a wrong check digit.");
}
public static Result<int> ValidateQuantity(int quantity) =>
quantity is >= 1 and <= MaxQuantity ? quantity
: new Error("quantity.range", $"Quantity must be between 1 and {MaxQuantity}.");
}
E-posta kontrolü bilinçli olarak kaba; gerçek doğrulama (domain'in var olması, adresin kullanıcıya ait olması) bir onay e-postasıyla yapılır. Buradaki amaç kuralların kendisi değil, kuralları birleştirme biçimi.
Railway-Oriented Programming: İki Ray ve Makaslar
Scott Wlaschin 2014'te Functional Programming eXchange ve NDC (Norwegian Developers Conference; Oslo kökenli bir yazılım konferansı) konuşmalarında bu composition biçimine railway-oriented programming adını verdi: başarılı değerin ilerlediği bir ray, hataların ilerlediği ikinci bir ray ve aralarında makaslar. Başarısız olabilen her adım bir makas; hata oluşursa akış ikinci raya geçiyor ve bir daha geri dönmüyor. Bookstore'un sipariş akışı bu modele göre şöyle görünüyor:

public Task<Result<PlacedOrder>> PlaceAsync(PlaceOrderRequest request, CancellationToken ct = default) =>
OrderValidation.Validate(request)
.Bind(valid => FindBook(valid.Isbn).Map(book => (valid, book)))
.BindAsync(draft => stock.ReserveAsync(draft.book.Isbn, draft.valid.Quantity, ct).MapAsync(_ => draft))
.MapAsync(draft => Price(draft.valid, draft.book, request.CouponCode))
.TapAsync(order => log.WriteLine($"[order] {order.OrderNo} {order.Line.Quantity} x {order.Line.Isbn} total={order.Totals.Total}"));
Akışı yukarıdan aşağı okuyabiliyorsun: doğrula, kitabı bul, stoğu ayır, fiyatla, log yaz. FindBook önceki yazının Option'ını kullanıyor ve "bulunamadı"yı bir hataya çeviriyor; Option'ın "neden yok"u söyleyemediği yer tam burası:
public Result<CatalogBook> FindBook(string isbn) =>
catalog.Find(isbn).Match(
book => Result<CatalogBook>.Success(book),
() => new Error("book.not_found", $"No book with ISBN {isbn}."));
Price ise üçüncü yazıdan beri büyüttüğümüz functional core'u (CheckoutCalculator.Calculate) çağırıyor; başarısız olamadığı için Map ile bağlanıyor. Dört istekle deneyelim: geçerli bir sipariş, katalogda olmayan bir kitap, stoğu yetmeyen bir sipariş ve tahmin sorusundaki bozuk form:
foreach (PlaceOrderRequest request in requests)
{
Result<PlacedOrder> result = await placement.PlaceAsync(request);
Console.WriteLine(result.Match(
order => $"placed {order.OrderNo}: {order.Totals.Total} TL",
errors => $"rejected: {string.Join(", ", errors.Select(error => error.Code))}"));
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-place
[order] BS-2026-0042 2 x 9780441013593 total=341.10
placed BS-2026-0042: 341.10 TL
rejected: book.not_found
rejected: stock.insufficient
rejected: email.invalid, isbn.checksum, quantity.range
Log satırı yalnızca başarılı sipariş için yazıldı; Tap hata rayında hiç çalışmadı. İlk sipariş iki Dune'u ayırınca stokta bir tane kaldı, Grace'in iki kitaplık siparişi stok adımında hata rayına geçti ve fiyatlanmadı. Son satırda ise üç hata birden var: bu kez doğrulama kısa devre yapmadı.
Biriktiren Validation: Bütün Hatalar Tek Seferde
Validate, Bind zinciri yerine üç sonucu yan yana değerlendiren bir Combine kullanıyor:
// Her doğrulamayı çalıştırır ve ilk hatada durmak yerine bütün hataları toplar
public static Result<TOut> Combine<T1, T2, T3, TOut>(
Result<T1> first, Result<T2> second, Result<T3> third, Func<T1, T2, T3, TOut> combine) =>
first.IsSuccess && second.IsSuccess && third.IsSuccess
? first.Bind(a => second.Bind(b => third.Map(c => combine(a, b, c))))
: Result<TOut>.Failure([.. first.Errors, .. second.Errors, .. third.Errors]);
// Biriktiren: hatalı bütün alanları tek seferde bildirir
public static Result<ValidOrderRequest> Validate(PlaceOrderRequest request) =>
Result.Combine(
ValidateEmail(request.Email),
ValidateIsbn(request.Isbn),
ValidateQuantity(request.Quantity),
(email, isbn, quantity) => new ValidOrderRequest(email, isbn, quantity));
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-combine
Failure(email.invalid, isbn.checksum, quantity.range)
email.invalid: 'ada.example.com' is not a valid email address.
isbn.checksum: 9780441013599 has a wrong check digit.
quantity.range: Quantity must be between 1 and 20.
Success(ValidOrderRequest { Email = ada@example.com, Isbn = 9780441013593, Quantity = 2 })
Fark şurada: Bind zincirinde ikinci adımın girdisi birinci adımın çıktısı. ISBN doğrulamasını e-posta geçerli olmadan çalıştıramazsın, çünkü lambda'nın içindesin. Combine'da ise üç doğrulama birbirinden bağımsız; her biri kendi girdisiyle çalışıyor ve sonuçlar en sonda birleşiyor. Functional programming literatüründe bu bağımsız birleştirmenin adı applicative (bağımsız hesaplamaları yan yana çalıştırıp sonuçlarını birleştiren yapı); Bind'ın sıralı, birbirine bağımlı composition'ı ise monad. Pratik kural: adımlar birbirinin sonucuna ihtiyaç duyuyorsa (kitap bulunmadan stok sorulmaz) Bind, birbirinden bağımsızsa (formun alanları) biriktir.
Combine'ın üç parametreli olması bir sınırlama; dört alanlı bir form için bir overload daha yazman gerekir. Kütüphaneler bunu ya çok sayıda overload'la ya da hata listesini dışarıda biriktiren bir builder'la çözüyor. ISBN'i (978-0-441-01359-3) tirelerle yazmanın da kabul edildiğine dikkat: ValidateIsbn hem kontrol ediyor hem de normalleştirilmiş değeri döndürüyor. Doğrulamanın çıktısı yalnızca bool değil, bir sonraki adımın güvenle kullanabileceği bir değer (ValidOrderRequest); Alexis King'in 2019 tarihli yazısıyla yaygınlaşan "parse, don't validate" fikri de bu.
Async Adımlar: Task<Result<T>> Zincirleri
Stok ayırma gerçek hayatta veritabanına gidiyor, yani async: Task<Result<Reservation>>. Bind'ın lambda'sı artık bir Task döndürüyor ve zincir Task<Result<T>> üzerinde devam ediyor. Bunun için birkaç extension yeterli:
public static async Task<Result<TOut>> BindAsync<T, TOut>(this Result<T> result, Func<T, Task<Result<TOut>>> bind) =>
await result.Match(bind, errors => Task.FromResult(Result<TOut>.Failure(errors)));
public static async Task<Result<TOut>> BindAsync<T, TOut>(this Task<Result<T>> task, Func<T, Task<Result<TOut>>> bind) =>
await (await task).BindAsync(bind);
public static async Task<Result<TOut>> MapAsync<T, TOut>(this Task<Result<T>> task, Func<T, TOut> map) =>
(await task).Map(map);
public static async Task<Result<T>> TapAsync<T>(this Task<Result<T>> task, Action<T> action) =>
(await task).Tap(action);
Burada dürüst olmak gerekiyor: C#'ta async railway zincirleri F#'taki kadar doğal durmuyor. Her method'un sync ve async, Result ve Task<Result> üzerinde ayrı overload'ları gerekiyor; PlaceAsync'teki tuple (draft.valid, draft.book) adımlar arasında birden fazla değer taşımanın bedeli; bir hata çıktığında stack trace'te BindAsync, MapAsync ve compiler'ın ürettiği state machine frame'leri görünüyor. Aynı akışın await ve erken return ile yazılmış hâli daha uzun ama her C# geliştiricisinin ilk bakışta okuyabileceği bir kod:
// Aynı akış, await ve erken return ile
public async Task<Result<PlacedOrder>> PlaceWithAwaitAsync(PlaceOrderRequest request, CancellationToken ct = default)
{
Result<ValidOrderRequest> valid = OrderValidation.Validate(request);
if (!valid.IsSuccess)
return Result<PlacedOrder>.Failure(valid.Errors);
ValidOrderRequest input = valid.Match(v => v, _ => throw new InvalidOperationException());
Result<CatalogBook> book = FindBook(input.Isbn);
if (!book.IsSuccess)
return Result<PlacedOrder>.Failure(book.Errors);
CatalogBook found = book.Match(b => b, _ => throw new InvalidOperationException());
Result<Reservation> reservation = await stock.ReserveAsync(found.Isbn, input.Quantity, ct);
if (!reservation.IsSuccess)
return Result<PlacedOrder>.Failure(reservation.Errors);
PlacedOrder order = Price(input, found, request.CouponCode);
log.WriteLine($"[order] {order.OrderNo} {order.Line.Quantity} x {order.Line.Isbn} total={order.Totals.Total}");
return order;
}
İki sürümün aynı sonucu verdiğini bir [Theory] ile, dört farklı istek için doğruluyoruz (Railway_and_await_versions_agree). Hangisinin seçileceği ekibin kararı. Benim tercihim: domain core'unda sync zincirler (doğrulama, fiyatlama) railway ile, I/O'nun yoğun olduğu shell'de await ve erken return. Async'in iç işleyişi C# Async serisinin "C# async/await Nasıl Çalışır: State Machine ve Continuation" yazısında.
ASP.NET Core'da Result'tan HTTP Yanıtına
Result'ın HTTP'ye çevrilmesi tek bir yerde, endpoint'in sınırında olmalı. Clean Code yazısı bunu Results.Problem ile yapıyordu; burada iki şey ekliyoruz: birikmiş doğrulama hatalarını alan bazında bir ValidationProblem'a çevirmek ve dönüş tipini TypedResults ile derleyiciye anlatmak:
public sealed record OrderResponse(string OrderNo, string Isbn, int Quantity, decimal Total);
public static class ResultHttp
{
// Bir form alanıyla ilgili olmayan iş hataları
private static readonly Dictionary<string, int> StatusByCode = new()
{
["book.not_found"] = StatusCodes.Status404NotFound,
["stock.insufficient"] = StatusCodes.Status409Conflict
};
public static Results<Created<OrderResponse>, ValidationProblem, ProblemHttpResult> ToHttpResult(this Result<PlacedOrder> result) =>
result.Match<Results<Created<OrderResponse>, ValidationProblem, ProblemHttpResult>>(
order => TypedResults.Created(
$"/orders/{order.OrderNo}",
new OrderResponse(order.OrderNo, order.Line.Isbn, order.Line.Quantity, order.Totals.Total)),
errors => StatusByCode.TryGetValue(errors[0].Code, out int status)
? TypedResults.Problem(title: errors[0].Message, statusCode: status, type: $"https://bookstore.example.com/errors/{errors[0].Code}")
: TypedResults.ValidationProblem(errors
.GroupBy(error => error.Code.Split('.')[0])
.ToDictionary(group => group.Key, group => group.Select(error => error.Message).ToArray())));
}
app.MapPost("/orders", async (PlaceOrderRequest request, OrderPlacement placement, CancellationToken ct) =>
(await placement.PlaceAsync(request, ct)).ToHttpResult());
Results<Created<OrderResponse>, ValidationProblem, ProblemHttpResult> da bir sum type: endpoint ya 201, ya 400, ya da problem yanıtı döndürüyor. Microsoft Learn'deki Minimal API responses sayfasına göre bu tip, listede olmayan bir sonuç döndürmeye çalışınca derleme hatası veriyor ve OpenAPI metadata'sını endpoint'e kendisi ekliyor. API'yi çalıştırıp üç istek gönderelim:
$ dotnet run --project src/Bookstore.Fp.Api --urls http://127.0.0.1:52107
$ curl ... -d '{"email":"ada@example.com","isbn":"9780441013593","quantity":2,"couponCode":"autumn10"}'
{"orderNo":"BS-2026-0042","isbn":"9780441013593","quantity":2,"total":341.10}
HTTP 201 application/json; charset=utf-8
$ curl ... -d '{"email":"ada.example.com","isbn":"9780441013599","quantity":0}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"email":["'ada.example.com' is not a valid email address."],"isbn":["9780441013599 has a wrong check digit."],"quantity":["Quantity must be between 1 and 20."]},"traceId":"00-5b92b8bedb4be25acac808ec628c2535-36b7a687632caab1-00"}
HTTP 400 application/problem+json
$ curl ... -d '{"email":"grace@example.com","isbn":"9780441013593","quantity":2}'
{"type":"https://bookstore.example.com/errors/stock.insufficient","title":"Only 1 left for 9780441013593.","status":409,"traceId":"00-4dbabf206a63d49441791978773c8a4e-27ea1b618a1832a8-00"}
HTTP 409 application/problem+json
curl ..., curl -s -X POST http://127.0.0.1:52107/orders -H "Content-Type: application/json" çağrısının kısaltması; aynı üç istek API projesindeki Bookstore.Fp.Api.http dosyasında da var. Birikmiş üç hata errors nesnesinde alan adlarıyla duruyor, ProblemDetails formatını tanımlayan standart (RFC 9457) gereği type, title ve status var, AddProblemDetails() her yanıta bir traceId ekliyor. API gerçek saati (TimeProvider.System) kullandığı için kampanya penceresi dışında çalıştırırsan ilk siparişin toplamı indirimsiz, 379.00 çıkar.
Hata kodu ile HTTP status'u arasındaki eşleme bilerek endpoint katmanında: domain "stok yetmedi" diyor, "409" demiyor. Aynı Result'ı bir background job'da ya da bir mesaj handler'ında kullandığında HTTP kavramları domain'e sızmamış oluyor.
Angular Tarafı: Hata Union'ını UI State'e Çevirmek
İstemci tarafında aynı fikir bir discriminated union olarak devam ediyor. Template HTTP status kodlarını bilmemeli; yanıtı bir kez, tek bir yerde UI state'ine çevirip ekranı o state'ten çizmek yeterli:
export type CheckoutState =
| { readonly kind: "idle" }
| { readonly kind: "invalid"; readonly fieldErrors: Readonly<Record<string, readonly string[]>> }
| { readonly kind: "rejected"; readonly message: string }
| { readonly kind: "placed"; readonly orderNo: string; readonly total: number };
type ProblemDetails = { readonly title?: string; readonly errors?: Record<string, string[]> };
type OrderResponse = { readonly orderNo: string; readonly total: number };
// HTTP yanıtını tek bir UI state'ine çevirir; template ham status kodlarını hiç görmez
export function toCheckoutState(status: number, body: unknown): CheckoutState {
if (status === 201) {
const order = body as OrderResponse;
return { kind: "placed", orderNo: order.orderNo, total: order.total };
}
const problem = body as ProblemDetails;
if (status === 400 && problem.errors) {
return { kind: "invalid", fieldErrors: problem.errors };
}
return { kind: "rejected", message: problem.title ?? `Unexpected status ${status}` };
}
export function banner(state: CheckoutState): string {
switch (state.kind) {
case "idle":
return "";
case "invalid":
return `Please fix ${Object.keys(state.fieldErrors).length} field(s): ${Object.keys(state.fieldErrors).join(", ")}`;
case "rejected":
return `Order not placed: ${state.message}`;
case "placed":
return `Thank you! Order ${state.orderNo}, total ${state.total.toFixed(2)} TL`;
default:
return assertNever(state);
}
}
Component'te state bir signal, ekrandaki mesaj bir computed. Angular derleyicisi olmadan, aynı signal API'siyle Node'da çalışan küçük bir script çalışan API'ye üç istek gönderiyor:
const state = signal<CheckoutState>({ kind: "idle" });
const message = computed(() => banner(state()));
for (const request of requests) {
const response = await fetch(`${apiUrl}/orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
state.set(toCheckoutState(response.status, await response.json()));
console.log(`${response.status} ${state().kind.padEnd(8)} ${message()}`);
}
$ node src/07-checkout-state.ts
201 placed Thank you! Order BS-2026-0043, total 59.90 TL
400 invalid Please fix 2 field(s): email, isbn
404 rejected Order not placed: No book with ISBN 9780261103344.
invalid state'i alan bazında hataları taşıdığı için form her alanın altına kendi mesajını koyabiliyor; birikmiş validation'ın kazancı kullanıcıya ancak burada ulaşıyor. switch'teki assertNever, beşinci bir state eklenince (örneğin submitting) derleme hatası veriyor. TypeScript'te Result tipinin kendisi TypeScript Orta serisinin "TypeScript ile Hata Yönetimi: catch'te unknown, Error Narrowing ve Result Pattern" yazısında.
Exception mı, Result mı?
Result her hatanın yerine geçmiyor. Ayrım, hatanın beklenen bir iş sonucu mu yoksa programın ya da altyapının arızası mı olduğu. Kargoya verilmiş bir siparişi iptal etmeye çalışmak beklenen bir iş sonucu: müşteri hizmetleri ekranında her gün oluyor. Beşinci yazıda bu durumu tiplerle (yalnızca Pending ve Paid alan iki overload) derleme zamanında engellemiştik; ama durum veritabanından OrderStatus olarak geldiğinde hangi case olduğu ancak runtime'da belli. Orada Result doğru araç:
public static class OrderCancellation
{
// Durum veritabanından OrderStatus olarak gelir, kontrol runtime'da yapılır
public static Result<Cancelled> TryCancel(OrderStatus status, string reason) => status switch
{
Pending or Paid => new Cancelled(reason),
Shipped shipped => new Error("order.shipped", $"Already shipped with {shipped.TrackingNo}; start a return instead."),
Cancelled => new Error("order.cancelled", "The order is already cancelled."),
_ => throw new UnreachableException($"Unknown status {status.GetType().Name}")
};
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-cancel
Pending -> Cancelled { Reason = customer request }
Paid -> Cancelled { Reason = customer request }
Shipped -> Already shipped with TRK-778; start a return instead.
Cancelled -> The order is already cancelled.
Aynı switch'in son kolu ise exception: bilinmeyen bir case bir programlama hatası ve kimsenin onu "işleyip" devam etmesi beklenmiyor. Veritabanı bağlantısının kopması, timeout, disk dolması da exception olarak kalmalı; onları Result'a sarmak her çağıranı kurtaramayacağı bir hatayla ilgilenmeye zorlar. Wlaschin'in kendisi de sonradan yazdığı "Against Railway-Oriented Programming" yazısında yaklaşımın aşırıya götürülmemesi gerektiğini, beklenmeyen hatalar için exception'ın hâlâ doğru araç olduğunu anlatıyor.
Kendi tipini yazmak yerine kütüphane kullanmak da bir seçenek. C#'ta language-ext (Option, Either, Validation ve çok daha fazlası), ErrorOr ve FluentResults yaygın; TypeScript'te neverthrow daha hafif, Effect ise bütün bir effect sistemi. Seçerken bak: async zincirleri ne kadar destekliyor, hata biriktirmeyi destekliyor mu, ekibin öğrenmesi gereken kavram sayısı ne kadar, aktif bakımı var mı. Bu yazıdaki 72 satırlık Result.cs çoğu uygulama için yeterli; language-ext gibi geniş bir kütüphaneye geçmenin ekipteki maliyetine serinin son yazısında döneceğiz.
Sık Düşülen Tuzaklar
Exception'ı Result'a çevirip stack trace'i kaybetmek. catch (Exception ex) { return new Error("x", ex.Message); } yazmak kolaydır, ama hatanın nerede oluştuğu bilgisi o satırda yok olur:
// Anti-pattern: exception bir mesaja dönüşür, stack trace kaybolur
private static Result<decimal> TryParsePrice(string text)
{
try
{
return PriceText.Parse(text);
}
catch (FormatException ex)
{
return new Error("price.format", ex.Message);
}
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 07-stack
as Result: The input string '12,50 TL' was not in a correct format.
as exception: 4 frames, ours: at Bookstore.Ordering.Pricing.PriceText.Parse(String text)
Result'taki mesaj hatanın ne olduğunu söylüyor ama nerede olduğunu söylemiyor; exception'da ise dört frame ve hatanın bizim koddaki yeri var. Kullanıcı girdisini parse ediyorsan decimal.TryParse ile exception'ı hiç oluşturmadan Result döndür; beklenmeyen bir exception'ı yakalıyorsan ya fırlatmaya devam et ya da en azından exception'ın kendisini log'a yaz.
Result'ı döndürüp kontrol etmemek. OrderCancellation.TryCancel(status, reason); satırı derleniyor ve C# dönüş değerinin kullanılmadığını söylemiyor; iptal başarısız olsa da kod devam ediyor. Result döndüren method'ların adlarını (Try...) ve review'da "bu sonuç nerede kullanılıyor?" sorusunu alışkanlık hâline getir. Zincirin sonunda Match olması bunu doğal olarak çözüyor: iki dalı da yazmadan değere ulaşamıyorsun.
Her method'u Result döndürür yapmak. Repository'den controller'a kadar her katman Result döndürünce, kurtarılamayan hatalar da Result'a sarılmaya başlar ve kod if (!result.IsSuccess) return result.Errors; satırlarıyla dolar. Result'ı iş kararının verildiği yerde kullan; altyapı hataları exception olarak boundary'ye kadar gitsin.
Hata kodlarını rastgele string'ler olarak bırakmak. "stock.insufficient" bir yerde, "stock.insuficient" başka bir yerde yazılırsa HTTP eşlemesi sessizce 400'e düşer. Kodları bir static class ErrorCodes içinde sabit olarak topla ya da hata tiplerini sum type olarak modelle; C# 15 ile ikincisi çok daha kolay, sıradaki yazının konusu.
Tap'i core'un içinde kullanmak. Tap side effect çalıştırmak için var; core'un ortasına konduğunda üçüncü yazıda kenara ittiğimiz log, saat ve I/O geri gelir. Bu yazıda Tap yalnızca shell'deki OrderPlacement'ta duruyor.
Küçük Bir Deneme
★☆☆ Validate'e dördüncü bir kural ekle: kupon kodu verilmişse en fazla 12 karakter olsun. Bunun için Combine'ın dört parametreli overload'ını yazman gerekecek; tekrar eden kodu nasıl azaltırsın?
★☆☆ PlaceAsync zincirine Ensure ile "e-kitaplarda adet 1'den fazla olamaz" kuralını ekle. Hata hangi rayda, hangi kodla dönüyor ve API onu hangi HTTP status'una çeviriyor?
★★☆ ResultHttp.ToHttpResult'ta errors[0] yalnızca ilk iş hatasına bakıyor. Hem doğrulama hem iş hatası aynı anda dönebilir mi? Akışa bakarak cevapla, sonra bunu bir testle doğrula.
★★★ Hata kodlarını string yerine bir sum type olarak modelle (EmailInvalid, IsbnChecksum, BookNotFound, StockInsufficient...) ve StatusByCode dictionary'sini bir switch expression'a çevir. Yeni bir hata case'i eklediğinde C# 14 derleyicisi switch'teki eksik kolu sana söylüyor mu?
Son alıştırmanın sorusu bizi bir sonraki yazıya götürüyor. C# 15'in union type'ları ve closed hiyerarşileri, beşinci yazıdaki OrderStatus'u ve bu yazıdaki Result'ı gerçek sum type'lar olarak yazmamızı sağlıyor. Derleyici uyarılarının nasıl değiştiğine, System.Text.Json'un bu tipleri nasıl serialize ettiğine ve mevcut bir public API'yi union'a çevirmenin neyi kırdığına bakacağız.