الگوی Result یا Exception؟ مدیریت خطاهای کسب‌وکار در سی‌شارپ بدون غافلگیری

پاسخ کوتاه: برای چیزهایی که نباید اتفاق بیفتند exception پرتاب کنید (باگ، قطعی دیتابیس، null‌ای که یک قاعده را می‌شکند). برای نتیجه‌هایی که بخش عادی کسب‌وکارند (موجودی ناکافی، کیف‌پول مسدود، کد تخفیف منقضی) یک Result برگردانید. فراخوان نمی‌تواند Result را تصادفی نادیده بگیرد و API شما هر خطا را به یک پاسخ 4xx روشن تبدیل می‌کند.

یک مثال واقعی: خطای 500 که در واقع 422 بود

endpoint برداشت، wallet.Withdraw(amount) را صدا می‌زند که InvalidOperationException("Insufficient funds") پرتاب می‌کند. در یک کنترلر کسی یادش بوده catch کند و 400 برگرداند. در endpoint جدید موبایل هیچ‌کس یادش نبوده، پس کاربرانی که موجودی کم دارند «خطایی رخ داد» می‌بینند و خطا به‌عنوان مشکل سرور لاگ می‌شود. حالا داشبورد مانیتورینگ هر مشتری کم‌پول را یک حادثه حساب می‌کند.

ریشه مشکل این است که «پول کافی نیست» به شکل یک کرش مدل شده. امضای void Withdraw(long) می‌گوید همیشه موفق است و کامپایلر نمی‌تواند به کسی یادآوری کند که این‌طور نیست.

قاعده سرانگشتی

موقعیت استفاده
موجودی ناکافی، کیف‌پول مسدود، کد ملی تکراری Result
اعتبارسنجی ورودی کاربر Result (یا فیلترهای validation)
قطعی دیتابیس، timeout، باگ، شکستن قاعده داخلی Exception
استفاده نادرست برنامه‌نویس (آرگومان null، تعداد منفی) Exception (ArgumentException)

اگر مالک محصول می‌تواند آن حالت را در یک user story توضیح دهد، Result است.

کد قابل اجرا

یک نوع Result کوچک:

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

public sealed class Result<T>
{
    private Result(T? value, Error? error) => (Value, Error) = (value, error);

    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static Result<T> Ok(T value) => new(value, null);
    public static Result<T> Fail(Error error) => new(default, error);

    public static implicit operator Result<T>(T value) => Ok(value);
    public static implicit operator Result<T>(Error error) => Fail(error);
}

خطاهای دامنه کنار خود دامنه زندگی می‌کنند:

public static class WalletErrors
{
    public static readonly Error Frozen = new("wallet.frozen", "This wallet is frozen.");
    public static readonly Error InsufficientFunds =
        new("wallet.insufficient_funds", "Not enough balance for this withdrawal.");
}

public Result<LedgerEntry> Withdraw(long amountRials, TimeProvider clock)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amountRials); // باگ است، نه حالت کسب‌وکار

    if (IsFrozen) return WalletErrors.Frozen;
    if (amountRials > Balance) return WalletErrors.InsufficientFunds;

    var entry = LedgerEntry.Withdrawal(Id, amountRials, clock.GetUtcNow());
    Entries.Add(entry);
    return entry;
}

نگاشت به HTTP در یک نقطه:

public static class ResultHttp
{
    public static IResult ToHttp<T>(this Result<T> result) => result.IsSuccess
        ? Results.Ok(result.Value)
        : Results.Problem(
            statusCode: StatusCodes.Status422UnprocessableEntity,
            title: result.Error!.Message,
            extensions: new Dictionary<string, object?> { ["code"] = result.Error.Code });
}

app.MapPost("/wallets/{id:guid}/withdraw",
    async (Guid id, WithdrawRequest req, WalletService wallets, CancellationToken ct) =>
        (await wallets.WithdrawAsync(id, req.AmountRials, ct)).ToHttp());

کلاینت یک code پایدار می‌گیرد که می‌تواند ترجمه‌اش کند ("wallet.insufficient_funds") و در لاگ‌ها فقط شکست‌های واقعی دیده می‌شوند.

مزایا و معایب

مزایا

  • شکست‌های قابل‌انتظار در امضای متد دیده می‌شوند و فراموش کردنشان سخت است.
  • کدهای خطا پایدار و قابل تست‌اند؛ وب و موبایل می‌توانند ترجمه‌شان کنند.
  • مانیتورینگ دیگر نتیجه‌های کسب‌وکار را خطای سرور حساب نمی‌کند.

معایب

  • تشریفات بیشتر: هر فراخوان باید IsSuccess را چک کند یا نتیجه‌ها را زنجیر کند.
  • بدون انضباط به هر دو می‌رسید: برای یک نوع خطا بعضی متدها پرتاب می‌کنند و بعضی برمی‌گردانند.
  • سی‌شارپ هنوز discriminated union داخلی ندارد، پس این نوع یک قرارداد است نه تضمین زبان.

می‌خواهید مدیریت خطا در یک کدبیس ASP.NET Core را مرتب کنید؟ ببینید چطور می‌توانم کمک کنم.

پرسش‌های متداول

Exception برای خطاهای کسب‌وکار خیلی کند نیست؟

پرتاب exception چند میکروثانیه هزینه دارد که برای یک درخواست وب معمولاً مهم نیست. مشکل اصلی این است که exception یک نتیجه قابل‌انتظار را از امضای متد پنهان می‌کند و فراخوان مدیریتش را فراموش می‌کند. Result شکست را بخشی از قرارداد می‌کند.

از کتابخانه‌ای مثل ErrorOr، FluentResults یا OneOf استفاده کنم؟

خوب‌اند و کمی کد صرفه‌جویی می‌کنند. یک Result<T> دست‌نویس سی‌خطی هم خوب است و وابستگی ندارد. در هر کدبیس یک روش انتخاب کنید و به آن پایبند بمانید؛ ترکیب سه سبک از هر انتخاب تکی بدتر است.

هنوز به exception handler سراسری نیاز دارم؟

بله. Result نتیجه‌های قابل‌انتظار را پوشش می‌دهد. باگ، timeout و خطای دیتابیس همچنان exception می‌دهند و یک handler سراسری باید آن‌ها را به ProblemDetails با کد 500 و شناسه همبستگی تبدیل کند، بدون نشت stack trace.