مقالات › C#

مدیریت Exception در C#؛ بهترین روش‌ها و اشتباهات رایج

Exceptionها را در C# حرفه‌ای مدیریت کنید: try/catch/finally، فیلترهای Exception، پرتاب مجدد درست، Exception سفارشی، using و IDisposable، خطا در کد async و اینکه کِی نباید Exception پرتاب کرد.

Exception سازوکار C# برای گزارش خطاهایی است که یک متد خودش نمی‌تواند مدیریتشان کند. اگر درست استفاده شوند، نادیده گرفتن خطا را غیرممکن و «مسیر موفق» را تمیز نگه می‌دارند. اگر بد استفاده شوند، باگ‌ها را پنهان می‌کنند، Stack Trace را از بین می‌برند و برنامه را کند می‌کنند. این راهنما قوانینی را پوشش می‌دهد که توسعه‌دهنده‌های باتجربه‌ی دات‌نت رعایت می‌کنند.

مبانی: try، catch و finally

try
{
    var json = await File.ReadAllTextAsync(path);
    config = JsonSerializer.Deserialize<AppConfig>(json)!;
}
catch (FileNotFoundException)
{
    config = AppConfig.Default;                 // قابل انتظار و قابل جبران
}
catch (JsonException ex)
{
    throw new InvalidOperationException($"Config file '{path}' is invalid.", ex);
}
finally
{
    logger.LogDebug("Config loading finished");  // همیشه اجرا می‌شود
}
  • بلوک‌های catch از بالا به پایین بررسی می‌شوند؛ نوع‌های مشخص را قبل از نوع‌های کلی بنویسید.
  • finally در هر حالت اجرا می‌شود — موفقیت، خطا یا return — و برای پاک‌سازی ایده‌آل است.

قانون ۱: فقط چیزی را بگیرید که می‌توانید مدیریتش کنید

بلوک catch باید کار معناداری انجام دهد: جبران، تلاش مجدد، ترجمه‌ی خطا یا افزودن زمینه. اگر نمی‌توانید، بگذارید Exception به سطح بالاتر برسد (معمولاً یک Handler سراسری).

// ❌ قورت دادن خطا: باگ ناپدید می‌شود و کاربر داده‌ی اشتباه می‌بیند
try { await SaveAsync(order); }
catch { }

// ❌ گرفتن Exception فقط برای لاگ و ادامه دادن انگار اتفاقی نیفتاده
catch (Exception ex) { logger.LogError(ex, "Error"); }

گرفتن Exception در مرزها قابل قبول است — Handler سراسری خطا، بالاترین سطح حلقه‌ی یک کار پس‌زمینه یا مصرف‌کننده‌ی پیام — جایی که لاگ می‌کنید و آن واحد کار را امن متوقف می‌کنید.

قانون ۲: با throw; دوباره پرتاب کنید، نه throw ex;

catch (SqlException ex)
{
    logger.LogWarning(ex, "Database error while saving order {OrderId}", order.Id);
    throw;        // ✅ Stack Trace اصلی حفظ می‌شود
    // throw ex;  ❌ Stack Trace از همین خط شروع می‌شود و محل واقعی خطا گم می‌شود
}

وقتی Exception را ترجمه می‌کنید، Exception اصلی را به‌عنوان innerException بدهید:

catch (HttpRequestException ex)
{
    throw new PaymentGatewayException("Payment provider is unavailable.", ex);
}

قانون ۳: از فیلترهای Exception استفاده کنید

فیلترها (when) اجازه می‌دهند فقط در شرایط خاص Exception را بگیرید، بدون اینکه برای بقیه‌ی موارد Stack باز شود:

catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    await Task.Delay(retryDelay);
}

catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2627 or 2601 })
{
    throw new DuplicateEmailException(email, ex);
}

فیلترها از الگوی «گرفتن، بررسی و پرتاب دوباره» تمیزترند و Stack Trace را دست‌نخورده نگه می‌دارند.

قانون ۴: برای جریان عادی برنامه از Exception استفاده نکنید

Exception گران است (ثبت Stack Trace) و وقتی برای نتایج قابل انتظار استفاده شود، دنبال کردن کد را سخت می‌کند.

// ❌ Exception به‌جای if
try { var n = int.Parse(input); }
catch (FormatException) { n = 0; }

// ✅ الگوی Try
if (!int.TryParse(input, out var n)) n = 0;

// ✅ «پیدا نشد» قابل انتظار → null یا Result برگردانید
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email);
if (user is null) return Result.Failure("User not found.");

راهنما: وقتی متد نمی‌تواند قراردادش را انجام دهد Exception پرتاب کنید؛ وقتی شکست یک نتیجه‌ی عادی و قابل انتظار است، یک مقدار (null، bool، Result<T>) برگردانید.

قانون ۵: آرگومان‌ها را زود با Guard Clause بررسی کنید

public void Transfer(Account from, Account to, decimal amount)
{
    ArgumentNullException.ThrowIfNull(from);
    ArgumentNullException.ThrowIfNull(to);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
    if (from.Id == to.Id)
        throw new ArgumentException("Cannot transfer to the same account.", nameof(to));

    // ...
}

دات‌نت مدرن متدهای کمکی استاتیک (ThrowIfNull، ThrowIfNullOrEmpty، ThrowIfNegative) دارد که کوتاه و سریع‌اند.

Exceptionهای سفارشی

وقتی فراخواننده باید به یک خطای خاص واکنش متفاوتی نشان دهد، Exception سفارشی بسازید:

public class InsufficientStockException(int productId, int requested, int available)
    : Exception($"Product {productId}: requested {requested}, only {available} available.")
{
    public int ProductId { get; } = productId;
    public int Requested { get; } = requested;
    public int Available { get; } = available;
}

راهنما:

  • نام آن‌ها را با پسوند Exception بگذارید.
  • از Exception (یا یک کلاس پایه‌ی معنادار) مشتق کنید، نه ApplicationException.
  • Propertyهایی با داده‌ی مفید اضافه کنید؛ فراخواننده نباید پیام را Parse کند.
  • برای هر متد یکی نسازید؛ بر اساس معنا گروه‌بندی کنید.

پاک‌سازی با using و IDisposable

منابعی مثل فایل، Stream، اتصال دیتابیس و پاسخ HTTP باید حتی در صورت خطا آزاد شوند. using بلوک try/finally را برایتان تولید می‌کند:

await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync();
// در پایان Scope، حتی در صورت خطا، خودکار Dispose می‌شوند

پیاده‌سازی درست IDisposable برای کلاس‌هایی که مالک منابع Unmanaged یا قابل Dispose هستند مهم است؛ اما بیشتر کلاس‌های اپلیکیشن به آن نیازی ندارند.

Exception در کد async

try
{
    await Task.WhenAll(SendEmailAsync(), SendSmsAsync());
}
catch (Exception ex)
{
    // await فقط اولین Exception را دوباره پرتاب می‌کند
    logger.LogError(ex, "Notification failed");
}

برای بررسی همه‌ی شکست‌های Task.WhenAll، ارجاعی به Task برگشتی نگه دارید:

var all = Task.WhenAll(tasks);
try { await all; }
catch
{
    foreach (var inner in all.Exception!.InnerExceptions)
        logger.LogError(inner, "Task failed");
}

و یادتان باشد: Exceptionی که داخل متد async void پرتاب شود توسط فراخواننده قابل گرفتن نیست و ممکن است پروسه را از کار بیندازد. از async Task استفاده کنید.

مدیریت لغو عملیات

OperationCanceledException (و TaskCanceledException) معمولاً یعنی کاربر یا سرور درخواست لغو داده است، نه باگ:

catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    logger.LogInformation("Request was cancelled by the client");
}

این‌ها را به‌عنوان Error لاگ نکنید؛ در مانیتورینگ نویز ایجاد می‌کنند.

مدیریت سراسری در ASP.NET Core

در برنامه‌های وب، بیشتر Exceptionها باید به یک Handler سراسری واحد (IExceptionHandler + UseExceptionHandler) برسند که یک بار لاگ می‌کند و پاسخ امن ProblemDetails برمی‌گرداند. این کار کنترلرها و سرویس‌ها را از بلوک‌های تکراری try/catch خلاص می‌کند.

مرجع سریع

انجام دهید انجام ندهید
گرفتن Exceptionهای مشخص catch (Exception) در عمق کد بیزینس
throw; برای پرتاب مجدد throw ex;
پیچیدن با innerException از دست دادن خطای اصلی
استفاده از فیلتر (when) گرفتن، بررسی و پرتاب دوباره
TryParse، null، Result<T> برای حالت‌های قابل انتظار Exception برای جریان برنامه
using / await using برای منابع پاک‌سازی دستی بدون finally
یک بار لاگ در مرز لاگ و پرتاب دوباره در هر لایه

جمع‌بندی

وقتی متد نمی‌تواند کارش را انجام دهد Exception پرتاب کنید، فقط جایی آن را بگیرید که می‌توانید کار مفیدی انجام دهید، با throw; دوباره پرتاب کنید، برای افزودن زمینه از innerException استفاده کنید، برای مدیریت شرطی از فیلترها و برای پاک‌سازی از using بهره ببرید. نتایج قابل انتظار را از مسیر Exception دور نگه دارید و بقیه را به Handler سراسری بسپارید. کد شما بلند، گویا و امن خطا خواهد داد.

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

آیا Exceptionها کند هستند؟

پرتاب کردن نسبتاً پرهزینه است؛ پس در حلقه‌های داغ و برای نتایج عادی از آن پرهیز کنید. در موارد واقعاً استثنایی این هزینه اهمیتی ندارد.

آیا هر متد باید try/catch داشته باشد؟

نه. بیشتر متدها باید اجازه دهند Exception منتشر شود. آن را در مرزها یا جایی که جبران ممکن است مدیریت کنید.

catch (Exception ex) when (Log(ex)) چطور است؟

فیلتری که لاگ می‌کند و false برمی‌گرداند اجازه می‌دهد بدون گرفتن Exception لاگ کنید؛ ترفند جالبی است، اما برای خوانایی کد کم از آن استفاده کنید.