هنر استثناهای سفارشی در توسعه نرمافزار با NET.: رویکردی بر پایه طراحی دامنه محور (DDD)
نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۱۸ ۱۱:۵۰
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
ArgumentOutOfRangeException یا NullReferenceException وجود دارند که بسیاری از سناریوهای رایج را پوشش میدهند. با این حال، تکیه صرف بر این استثناهای عمومی، ما را از مزایای بزرگ استفاده از استثناهای سفارشی (Custom Exceptions) محروم میکند. این مقاله به بررسی اهمیت، مزایا و نحوه پیادهسازی استثناهای سفارشی در پلتفرم NET.، با تأکید بر اصول طراحی دامنه محور (Domain-Driven Design - DDD)، میپردازد.ArgumentOutOfRangeException، هرچند به درستی وقوع یک مشکل را گزارش میدهد، اما اطلاعات مربوط به قوانین کسبوکار را در قالب رشتههای متنی (string) پنهان میکند که نه تنها قابل درک نیست، بلکه مدیریت و بهروزرسانی آنها نیز دشوار است.ArgumentOutOfRangeException عمومی، میتوانیم یک استثنا با نام گویای DepositAmountMustBePositiveException (استثنای مبلغ واریزی باید مثبت باشد) پرتاب کنیم. این نام به تنهایی، گویای علت اصلی خطا است و به توسعهدهنده و حتی کاربران نهایی، اطلاعات دقیقی در مورد آنچه اشتباه پیش رفته، میدهد. این رویکرد، باعث میشود که خطایابی سریعتر و آسانتر انجام شود و کد ما خواناتر و قابل نگهداریتر باشد.Exception یا یکی از کلاسهای مشتق شده از آن (مانند ArgumentOutOfRangeException) به ارث ببرید. با ارثبری از یک کلاس پایهی مناسب، میتوانیم هم از ویژگیهای آن استفاده کنیم و هم با اضافه کردن اطلاعات دامنه، به آن معنای بیشتری ببخشیم.Deposit در یک کلاس مربوط به حساب بانکی را در نظر بگیرید که یک قانون کسبوکار را بررسی میکند:public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException($"Deposit amount must be greater than 0. Given {amount}");
// انجام عملیات واریز
}public class DepositAmountMustBePositiveException(decimal invalidAmount)
: ArgumentOutOfRangeException($"Deposit amount must be greater than 0. Given {invalidAmount}");Deposit را با استفاده از استثنای سفارشی بهروزرسانی میکنیم:public void Deposit(decimal amount)
{
if (amount <= 0)
throw new DepositAmountMustBePositiveException(amount);
// انجام عملیات واریز
}Deposit را با مقادیر نامعتبر فراخوانی میکنیم (مثلاً 0 یا -10.55) و مطمئن میشویم که دقیقاً استثنای DepositAmountMustBePositiveException پرتاب میشود. این کار به ما اطمینان میدهد که منطق کسبوکار به درستی پیادهسازی شده است.if-throw در سراسر کد جلوگیری میکند.Guard ساده بسازیم:// Domain/Common/Guard.cs
namespace Domain.Common;
// یک استثنای سفارشی برای خطاهای اعتبارسنجی دامنه
public class DomainValidationException : Exception
{
public DomainValidationException(string message) : base(message) { }
}
public static class Guard
{
public static void AgainstNullOrEmpty(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainValidationException($"{parameterName} نمیتواند خالی باشد.");
}
public static void AgainstNegative(decimal value, string parameterName)
{
if (value < 0)
throw new DomainValidationException($"{parameterName} نمیتواند مقداری منفی داشته باشد.");
}
public static void AgainstZeroOrNegative(int value, string parameterName)
{
if (value <= 0)
throw new DomainValidationException($"{parameterName} باید مقداری بزرگتر از صفر داشته باشد.");
}
}Money استفاده میکنیم:// Domain/ValueObjects/Money.cs
using Domain.Common;
namespace Domain.ValueObjects;
// استفاده از record برای تعریف یک شیء مقدار تغییرناپذیر (Immutable)
public record Money
{
public decimal Amount { get; }
public string Currency { get; }
// سازنده را private میکنیم تا کسی نتواند شیء را در وضعیت نامعتبر بسازد
private Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
// از یک متد کارخانهای استاتیک برای ساخت شیء استفاده میکنیم
public static Money Create(decimal amount, string currency)
{
// اعمال قوانین با Guard Clause ها
Guard.AgainstNegative(amount, nameof(amount));
Guard.AgainstNullOrEmpty(currency, nameof(currency));
// میتوانیم قوانین پیچیدهتری هم اضافه کنیم
if (currency.Length != 3)
throw new DomainValidationException("کد ارز باید ۳ حرفی باشد.");
return new Money(amount, currency);
}
}Money با مقدار منفی یا واحد پول خالی بسازد. این قانون برای همیشه در دل دامنه شما حک شده است. 💡Money دیدید، با خصوصی کردن سازنده و ارائه یک متد استاتیک Create، ما کنترل کاملی بر فرآیند ساخت اشیاء داریم. این الگو برای Entities نیز بسیار قدرتمند است.// Domain/Entities/Order.cs
using Domain.Common;
using Domain.ValueObjects;
public class Order : Entity // فرض میکنیم Entity یک کلاس پایه با یک شناسه (Id) است
{
public CustomerId CustomerId { get; private set; }
public DateTime OrderDate { get; private set; }
public OrderStatus Status { get; private set; }
private readonly List<OrderItem> _items = [];
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
// سازنده بدون پارامتر برای استفاده EF Core
// این سازنده باید private یا protected باشد تا از بیرون فراخوانی نشود
private Order() { }
public static Order Create(CustomerId customerId, DateTime orderDate)
{
// بررسی قوانین ذاتی کسبوکار
if (orderDate > DateTime.UtcNow)
throw new DomainValidationException("تاریخ سفارش نمیتواند در آینده باشد.");
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
OrderDate = orderDate,
Status = OrderStatus.Pending
};
return order;
}
// مثال دیگری از منطق تجاری درون موجودیت
public void AddItem(ProductId productId, int quantity, Money price)
{
// یک قانون: نمیتوان به سفارشی که نهایی شده، آیتم اضافه کرد
if (Status == OrderStatus.Confirmed || Status == OrderStatus.Shipped)
throw new DomainValidationException("امکان افزودن آیتم به سفارش نهاییشده وجود ندارد.");
Guard.AgainstZeroOrNegative(quantity, nameof(quantity));
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
if (existingItem != null)
{
// اگر آیتم قبلاً وجود داشته، تعدادش را افزایش بده
existingItem.AddQuantity(quantity);
}
else
{
_items.Add(OrderItem.Create(this.Id, productId, quantity, price));
}
}
}// Domain/Specifications/ISpecification.cs
namespace Domain.Specifications;
public interface ISpecification<T>
{
bool IsSatisfiedBy(T entity);
string ErrorMessage { get; }
}// Domain/Specifications/OrderMustHaveItemsSpec.cs
using Domain.Entities;
public class OrderMustHaveItemsSpec : ISpecification<Order>
{
public string ErrorMessage => "سفارش برای تایید شدن باید حداقل یک آیتم داشته باشد.";
public bool IsSatisfiedBy(Order order) => order.Items.Any();
}Order:// بخشی از کلاس Order
public void Confirm()
{
var spec = new OrderMustHaveItemsSpec();
if (!spec.IsSatisfiedBy(this))
{
throw new DomainValidationException(spec.ErrorMessage);
}
// قانون دیگر: فقط سفارش در حالت Pending میتواند تایید شود
if (Status != OrderStatus.Pending)
{
throw new DomainValidationException("سفارش در وضعیت فعلی قابل تایید نیست.");
}
Status = OrderStatus.Confirmed;
// رویداد دامنه (Domain Event) را منتشر کن (اختیاری)
// AddDomainEvent(new OrderConfirmedEvent(this.Id));
}Result بسیار خواناتر و بهتر است.Result را پیادهسازی کنیم:// Domain/Common/Result.cs
namespace Domain.Common;
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public string? Error { get; }
protected Result(bool success, string? error)
{
IsSuccess = success;
Error = error;
}
public static Result Success() => new(true, null);
public static Result Failure(string error) => new(false, error);
public static Result<T> Success<T>(T value) => Result<T>.Success(value);
public static Result<T> Failure<T>(string error) => Result<T>.Failure(error);
}
public class Result<T> : Result
{
public T? Value { get; }
protected internal Result(bool success, T? value, string? error) : base(success, error)
{
Value = value;
}
public static Result<T> Success(T value) => new(true, value, null);
public static new Result<T> Failure(string error) => new(false, default, error);
}Create و Confirm در کلاس Order را بازنویسی میکنیم تا از Result استفاده کنند:// بازنویسی متد Create در کلاس Order
public static Result<Order> Create(CustomerId customerId, DateTime orderDate)
{
if (orderDate > DateTime.UtcNow)
return Result.Failure<Order>("تاریخ سفارش نمیتواند در آینده باشد.");
var order = new Order { /* ... تخصیص مقادیر ... */ };
return Result.Success(order);
}
// بازنویسی متد Confirm در کلاس Order
public Result Confirm()
{
var spec = new OrderMustHaveItemsSpec();
if (!spec.IsSatisfiedBy(this))
return Result.Failure(spec.ErrorMessage);
if (Status != OrderStatus.Pending)
return Result.Failure("سفارش در وضعیت فعلی قابل تایید نیست.");
Status = OrderStatus.Confirmed;
return Result.Success();
}// در لایه Application
var createResult = Order.Create(customerId, DateTime.UtcNow);
if (createResult.IsFailure)
{
return BadRequest(createResult.Error); // یا هر پاسخ خطای دیگری
}
var order = createResult.Value;
order.AddItem(...);
var confirmResult = order.Confirm();
if (confirmResult.IsFailure)
{
return BadRequest(confirmResult.Error);
}
_orderRepository.Update(order);
await _unitOfWork.SaveChangesAsync();// Application/Orders/Commands/CreateOrder/CreateOrderCommandValidator.cs
using FluentValidation;
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty().WithMessage("شناسه مشتری الزامی است.");
RuleFor(x => x.Items)
.NotEmpty().WithMessage("سفارش باید حداقل شامل یک آیتم باشد.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.Quantity)
.GreaterThan(0).WithMessage("تعداد محصول باید بیشتر از صفر باشد.");
item.RuleFor(i => i.Price)
.GreaterThan(0).WithMessage("قیمت محصول باید بیشتر از صفر باشد.");
});
}
}// Tests/Domain/OrderTests.cs
using Domain.Entities;
using FluentAssertions;
using Xunit;
public class OrderTests
{
[Fact]
public void Create_WhenOrderDateIsInFuture_ShouldReturnFailure()
{
// Arrange
var futureDate = DateTime.UtcNow.AddDays(1);
var customerId = new CustomerId(Guid.NewGuid());
// Act
var result = Order.Create(customerId, futureDate);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be("تاریخ سفارش نمیتواند در آینده باشد.");
}
[Fact]
public void Confirm_WhenOrderHasNoItems_ShouldReturnFailure()
{
// Arrange
var orderResult = Order.Create(new CustomerId(Guid.NewGuid()), DateTime.UtcNow);
var order = orderResult.Value;
// Act
var confirmResult = order.Confirm();
// Assert
confirmResult.IsFailure.Should().BeTrue();
confirmResult.Error.Should().Contain("حداقل یک آیتم");
}
[Fact]
public void AddItem_ToConfirmedOrder_ShouldThrowException()
{
// Arrange
var orderResult = Order.Create(new CustomerId(Guid.NewGuid()), DateTime.UtcNow);
var order = orderResult.Value;
order.AddItem(new ProductId(Guid.NewGuid()), 1, Money.Create(100, "USD"));
order.Confirm();
// Act
Action action = () => order.AddItem(new ProductId(Guid.NewGuid()), 1, Money.Create(50, "USD"));
// Assert
action.Should().Throw<DomainValidationException>()
.WithMessage("امکان افزودن آیتم به سفارش نهاییشده وجود ندارد.");
}
}