بهترین الگوهای Error Handling سرویس‌های WebAPI در .NET

بهترین الگوهای Error Handling در WebAPIهای .NET با تمرکز بر ApiResponse

مدیریت خطا در WebAPI یکی از مهم‌ترین پایه‌های طراحی یک سیستم پایدار و قابل نگهداری است. هر API – چه کوچک، چه سازمانی – نیاز دارد خروجی‌های خطای آن یکدست، قابل پیش‌بینی، ایمن، قابل ردیابی و سازگار با فرانت‌اند باشد. بدون یک معماری استاندارد برای Error Handling، دیباگ کردن، توسعه و پشتیبانی سیستم به‌مرور زمان بسیار سخت و پرهزینه خواهد شد.

رویکردی که در این نوشته بررسی می‌شود این است که تمام پاسخ‌های API – چه در حالت موفق، چه در حالت خطا – در قالب یک ساختار واحد به نام ApiResponse بازگردانده شوند. سپس روی این ساختار، لایه‌هایی مثل Middleware سراسری، BusinessException، Logging و Result Pattern سوار می‌شوند تا یک معماری تمیز، قابل توسعه و حرفه‌ای برای مدیریت خطا شکل بگیرد.

۱. ApiResponse؛ استاندارد واحد برای تمام پاسخ‌ها

اولین گام در استانداردسازی Error Handling، تعریف یک مدل پاسخ مشترک است که تمام جواب‌های API در همین قالب برگردند. یک نسخه ساده و عملی از این مدل می‌تواند به شکل زیر باشد:

public class ApiResponse
{
    public bool Succeeded { get; set; }
    public string? Message { get; set; }
    public List<string>? Errors { get; set; }
    public string? TraceId { get; set; }
}

public class ApiResponse<T> : ApiResponse
{
    public T? Data { get; set; }
}

در این ساختار:

  • Succeeded مشخص می‌کند عملیات موفق بوده یا خیر.
  • Message برای پیام‌های غیرخطایی و توضیحات تکمیلی (معمولاً در پاسخ‌های موفق) استفاده می‌شود.
  • Errors لیستی از پیام‌های خطا است؛ این موضوع به‌خصوص برای Validation که چند خطا هم‌زمان رخ می‌دهد بسیار مفید است.
  • Data داده خروجی عملیات در سناریوهای موفق را نگه می‌دارد.
  • TraceId شناسه‌ای است برای ردیابی درخواست در سیستم لاگ و ابزارهای مانیتورینگ.

با استفاده از همین مدل ساده می‌توان همه پاسخ‌های API را – چه در حالت موفقیت، چه در حالت خطا – در یک قالب یکپارچه برگرداند و فرانت‌اند را نیز با همین ساختار هماهنگ کرد.

۲. ارتباط ApiResponse با استاندارد ProblemDetails (RFC7807)

استاندارد جهانی RFC7807 ساختاری به نام ProblemDetails برای نمایش خطا در APIهای REST معرفی کرده است. این ساختار شامل فیلدهایی مانند type، title، detail، status و instance است و در بسیاری از APIهای عمومی و همچنین در خود ASP.NET Core پشتیبانی می‌شود.

با این حال، در بسیاری از سناریوهای عملی نیاز است که قالب پاسخ در حالت موفق و ناموفق یکسان باشد، داده بیزنسی در کنار متای خطا برگردد، و فیلدهایی مانند Data و TraceId در همه پاسخ‌ها حضور داشته باشند. به همین دلیل رویکردی مانند ApiResponse در عمل انعطاف‌پذیرتر است. می‌توان آن را ساختاری دانست که از مفاهیم ProblemDetails الهام گرفته و برای نیازهای بیزنسی و فرانت‌اند مدرن سفارشی شده است.

۳. TraceId؛ قلب ردیابی خطا

وقتی سیستمی چندین سرویس، لایه و ماژول دارد، پیدا کردن منبع دقیق یک خطا بدون شناسه ردیابی کار ساده‌ای نیست. اینجاست که TraceId وارد می‌شود. ایده ساده است: هر درخواست یک شناسه یکتا دارد که:

  • در لاگ‌ها ذخیره می‌شود،
  • در پاسخ API به کلاینت برمی‌گردد،
  • و در صورت گزارش خطا توسط کاربر، با همین شناسه می‌توان آن را در سیستم لاگ پیدا کرد.

نمونه‌ای از پاسخ خطا با TraceId:

{
  "succeeded": false,
  "errors": [ "User not found" ],
  "traceId": "00-af1d3..."
}

در ASP.NET Core می‌توان از HttpContext.TraceIdentifier برای دسترسی به این شناسه استفاده کرد و همان مقدار را در فیلد TraceId قرار داد.

۴. انتخاب صحیح StatusCode؛ اصول حرفه‌ای با مثال واقعی

انتخاب StatusCode مناسب برای هر نوع خطا بخش مهمی از طراحی API است. کلاینت‌ها رفتار خود را تا حد زیادی براساس StatusCode تنظیم می‌کنند؛ بنابراین خطای Validation نباید با ۵۰۰ برگردد و خطای عدم دسترسی نباید ۲۰۰ باشد. در ادامه مهم‌ترین StatusCodeها با یک مثال واقعی از خروجی ApiResponse آورده شده است.

۴.۱. 400 – Bad Request (خطای ورودی / Validation)

زمانی استفاده می‌شود که داده ارسال‌شده از سمت کلاینت از نظر قوانین ورودی معتبر نباشد. مثال رایج آن Validation ناموفق است؛ برای نمونه مبلغ منفی در یک درخواست برداشت:

{
  "succeeded": false,
  "errors": [ "Amount must be greater than zero" ],
  "traceId": "00-a183..."
}

۴.۲. 401 – Unauthorized (کاربر احراز هویت نشده)

زمانی که کاربر توکن ارسال نکرده، توکن نامعتبر است یا منقضی شده، باید از ۴۰۱ استفاده شود. در این حالت معمولاً کلاینت باید کاربر را به مسیر ورود هدایت کند.

{
  "succeeded": false,
  "errors": [ "Token expired" ],
  "traceId": "00-e89d..."
}

۴.۳. 403 – Forbidden (اجازه دسترسی وجود ندارد)

در این حالت کاربر احراز هویت شده است، اما Permission کافی برای انجام عملیات را ندارد. به عنوان مثال کاربر عادی می‌خواهد داده‌ای را که فقط مدیران مجاز هستند حذف کند:

{
  "succeeded": false,
  "errors": [ "Access denied" ],
  "traceId": "00-77ea..."
}

۴.۴. 404 – Not Found (منبع یافت نشد)

وقتی موجودیتی که با شناسه مشخصی درخواست شده در سیستم وجود نداشته باشد، از ۴۰۴ استفاده می‌شود؛ این وضعیت خطای سیستمی نیست، بلکه درخواست کاربر به منبعی اشاره کرده که وجود ندارد.

{
  "succeeded": false,
  "errors": [ "User with id 987 not found" ],
  "traceId": "00-cc91..."
}

۴.۵. 409 – Conflict (تعارض در پردازش)

زمانی که وضعیت فعلی منبع با عملیات جدید ناسازگار است – مانند دوبار پردازش شدن یک عملیات یا بروز خطای همزمانی (Optimistic Concurrency) – از ۴۰۹ استفاده می‌شود. مثال ساده آن ارسال دوباره یک درخواست برداشت است که قبلاً پردازش شده:

{
  "succeeded": false,
  "errors": [ "The operation was already processed" ],
  "traceId": "00-33ff..."
}

۴.۶. 422 – Unprocessable Entity (داده از نظر معنایی نامعتبر)

زمانی که ساختار JSON درست است، اما مقدار از نظر بیزنسی یا معنایی مشکل دارد، استفاده از ۴۲۲ مناسب است. مثال رایج آن ارسال تاریخ پایان کوچک‌تر از تاریخ شروع است:

{
  "succeeded": false,
  "errors": [ "End date cannot be earlier than start date" ],
  "traceId": "00-ab22..."
}

۴.۷. 500 – Internal Server Error (خطای غیرمنتظره سیستمی)

این وضعیت برای خطاهای غیرمنتظره و سیستمی استفاده می‌شود؛ مانند خطای اتصال به دیتابیس، NullReferenceException یا باگ‌های داخلی. نکته بسیار مهم این است که جزئیات فنی خطا نباید در پاسخ به کاربر نمایش داده شود و فقط باید در لاگ ذخیره گردد.

{
  "succeeded": false,
  "errors": [ "Internal server error" ],
  "traceId": "00-aa93..."
}

۵. Middleware سراسری برای مدیریت Exceptionها

برای جلوگیری از تکرار try/catch در تمام کنترلرها، بهترین راه این است که یک Middleware سراسری برای مدیریت Exceptionها داشته باشیم. این Middleware تمام خطاها را در یک نقطه مرکزی دریافت و به پاسخ استاندارد ApiResponse تبدیل می‌کند.

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(
        RequestDelegate next,
        ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            // ادامه پردازش درخواست
            await _next(context);
        }
        catch (BusinessException bx)
        {
            // خطای بیزنسی با StatusCode مشخص
            await WriteErrorAsync(context, bx.StatusCode, bx.Message);
        }
        catch (Exception ex)
        {
            // خطای غیرمنتظره در سطح سیستم
            _logger.LogError(
                ex,
                "Unhandled exception. TraceId: {TraceId}",
                context.TraceIdentifier);

            await WriteErrorAsync(
                context,
                StatusCodes.Status500InternalServerError,
                "Internal server error");
        }
    }

    private async Task WriteErrorAsync(HttpContext context, int statusCode, string error)
    {
        var response = new ApiResponse
        {
            Succeeded = false,
            Errors  = new List<string> { error },
            TraceId = context.TraceIdentifier
        };

        context.Response.StatusCode  = statusCode;
        context.Response.ContentType = "application/json";

        await context.Response.WriteAsJsonAsync(response);
    }
}

با این الگو، کنترلرها از منطق تکراری مدیریت خطا خالی می‌شوند و تمام خطاها، از خطاهای بیزنسی تا خطاهای سیستمی، در یک نقطه مرکزی به خروجی واحد و استاندارد تبدیل می‌شوند.

۶. BusinessException؛ تعریف رسمی خطاهای بیزنسی

بسیاری از خطاها در سیستم، خطاهای قابل پیش‌بینی بیزنسی هستند (مثلاً موجودی کافی نیست، رکورد یافت نشد، محدودیت سقف تراکنش رعایت نشده و ...). برای این دسته از خطاها بهتر است به‌جای استفاده از Exceptionهای خام، یک نوع خاص به نام BusinessException تعریف شود.

public class BusinessException : Exception
{
    public int StatusCode { get; }

    public BusinessException(string message, int statusCode = 400)
        : base(message)
    {
        StatusCode = statusCode;
    }
}

نمونه استفاده در یک سناریوی ساده برداشت از کیف پول:

if (wallet.Balance < amount)
{
    throw new BusinessException("Insufficient balance", StatusCodes.Status400BadRequest);
}

این Exception در Middleware گرفته می‌شود و براساس مقدار StatusCode به پاسخ استاندارد ApiResponse تبدیل می‌گردد.

۷. مدیریت Validation Errors با ApiResponse

خطاهای Validation معمولاً شامل چند خطا هستند و بهتر است همه آن‌ها به صورت هم‌زمان به کاربر نمایش داده شوند. ساختار Errors در ApiResponse دقیقاً برای همین منظور مناسب است.

{
  "succeeded": false,
  "errors": [
    "Name is required",
    "National code is invalid"
  ],
  "traceId": "00-2233..."
}

این الگو به‌خصوص در کنار کتابخانه‌هایی مثل FluentValidation بسیار قدرتمند می‌شود؛ تمام خطاهای Validation در یک مجموعه جمع‌آوری و در قالبی قابل پیش‌بینی برای فرانت‌اند ارسال می‌شوند.

۸. Logging حرفه‌ای با TraceId

ثبت لاگ بدون داشتن زمینه مناسب، در عمل کمک زیادی به دیباگ نمی‌کند. برای لاگ‌نویسی حرفه‌ای در کنار Error Handling، باید اطلاعاتی مثل مسیر درخواست، شناسه کاربر، TraceId و جزئیات Exception ثبت شوند.

_logger.LogError(
    ex,
    "Unhandled exception. TraceId: {TraceId} Path: {Path} User: {User}",
    context.TraceIdentifier,
    context.Request.Path,
    context.User?.Identity?.Name
);

با این رویکرد، وقتی کاربر TraceId را گزارش می‌کند، می‌توان همان شناسه را در ابزارهایی مانند Elastic، Kibana، Seq یا هر سیستم لاگ دیگری جست‌وجو کرد و خیلی سریع به منبع مشکل رسید.

۹. Result Pattern و BaseController؛ تکمیل معماری Error Handling

برای جلوگیری از استفاده بیش‌ازحد از Exception در منطق بیزنسی، می‌توان از الگوی Result استفاده کرد؛ به‌طوری که متدها مقدار موفقیت یا شکست را به‌صورت صریح برگردانند و فقط در شرایط غیرمنتظره Exception پرتاب شود.

public record Result<T>(bool IsSuccess, T? Data, string? Error)
{
    public static Result<T> Success(T data) => new(true, data, null);
    public static Result<T> Failure(string error) => new(false, default, error);
}

در کنار این الگو، می‌توان یک BaseApiController تعریف کرد تا تمام خروجی‌ها به‌صورت خودکار در قالب ApiResponse برگردند.

[ApiController]
public abstract class BaseApiController : ControllerBase
{
    protected IActionResult Success<T>(T data, string? message = null)
        => Ok(new ApiResponse<T>
        {
            Succeeded = true,
            Data      = data,
            Message   = message
        });

    protected IActionResult Fail(string message, int status = StatusCodes.Status400BadRequest)
        => StatusCode(status, new ApiResponse
        {
            Succeeded = false,
            Errors    = new List<string> { message },
            TraceId   = HttpContext.TraceIdentifier
        });
}

حالا یک کنترلر نمونه می‌تواند به شکل زیر باشد:

public class WalletController : BaseApiController
{
    private readonly IWalletService _walletService;

    public WalletController(IWalletService walletService)
    {
        _walletService = walletService;
    }

    [HttpGet("balance")]
    public async Task<IActionResult> GetBalance()
    {
        var result = await _walletService.GetBalanceAsync(UserId());

        if (!result.IsSuccess)
            return Fail(result.Error!);

        return Success(result.Data, "Balance retrieved successfully.");
    }

    // مثال ساده برای دسترسی به شناسه کاربر
    private Guid UserId()
        => Guid.Parse(User.FindFirst("sub")!.Value);
}

با این ترکیب، خروجی تمام اکشن‌ها در قالب استاندارد ApiResponse برمی‌گردد و Error Handling در سطح معماری، تمیز و یکپارچه باقی می‌ماند.

۱۰. جمع‌بندی نهایی

یک WebAPI حرفه‌ای، Error Handling را به‌عنوان یک دغدغه فرعی نمی‌بیند، بلکه آن را بخشی از طراحی معماری در نظر می‌گیرد. با تعریف یک ساختار استاندارد مانند ApiResponse، استفاده صحیح از StatusCodeها، استفاده از TraceId برای ردیابی، پیاده‌سازی ExceptionHandlingMiddleware و تعریف BusinessException و BaseApiController، می‌توان:

  • خروجی API را قابل پیش‌بینی و استاندارد کرد،
  • فرآیند دیباگ را به کمک TraceId بسیار ساده‌تر نمود،
  • توسعه فرانت‌اند را آسان‌تر و سریع‌تر کرد،
  • و نگهداری سیستم را در بلندمدت به‌مراتب کم‌هزینه‌تر و تمیزتر ساخت.

پیاده‌سازی همین اصول در عمل، تفاوت یک WebAPI ساده و آزمایشی را با یک سیستم واقعی، قابل اتکا و آماده برای رشد و توسعه نشان می‌دهد.