Domain-Driven Design (DDD)؛ بخش پنجم: موجودیتها
نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۵/۳۱ ۰۸:۰۵
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
public class Order
{
public int OrderId { get; set; }
public decimal TotalAmount { get; set; }
public string Status { get; set; }
}Order فقط دادهها را نگهداری میکند. اگر بخواهید وضعیت سفارش را تغییر دهید، مجبورید این کار را از بیرون کلاس و در یک سرویس جداگانه انجام دهید.public class Order
{
public int OrderId { get; private set; }
public decimal TotalAmount { get; private set; }
public OrderStatus Status { get; private set; }
public Order(int orderId, decimal totalAmount)
{
this.OrderId = orderId;
this.TotalAmount = totalAmount;
this.Status = OrderStatus.Pending;
}
public void MarkAsShipped()
{
if (this.Status != OrderStatus.Pending)
{
throw new InvalidOperationException("Cannot ship an order that is not pending.");
}
this.Status = OrderStatus.Shipped;
}
}Order هم دادهها و هم منطق را در خود کپسولهسازی کرده است. هویت سفارش ( OrderId ) قابل تغییر نیست و رفتار MarkAsShipped در داخل خود کلاس پیادهسازی شده است. این به ما کمک میکند تا قوانین تجاری (Business Rules) را در خود دامنه حفظ کنیم.string برای "آدرس" استفاده میشود. اما آیا یک آدرس فقط یک رشته است؟ یک آدرس از "خیابان"، "شهر"، "کد پستی" و "کشور" تشکیل شده است. استفاده از یک string ساده، باعث میشود که نتوانیم قوانین تجاری مربوط به آدرس (مثلاً معتبر بودن کد پستی) را به درستی اجرا کنیم و کد ما پر از اعتبارسنجیهای تکراری شود. اینجاست که Value Objects یا "اشیاء ارزشی" وارد میدان میشوند.string یا int داشته باشید، میتوانید از نوعهای مشخصتری مثل EmailAddress یا Money استفاده کنید.string را برای ایمیل استفاده کنید، میتوانید یک Value Object بسازید:public class User
{
public int Id { get; set; }
public string Email { get; set; }
}public class EmailAddress
{
public string Value { get; private set; }
public EmailAddress(string value)
{
if (!IsValidEmail(value))
{
throw new ArgumentException("Invalid email format.");
}
this.Value = value;
}
private bool IsValidEmail(string value)
{
// Logic to validate email format
return true;
}
}
public class User
{
public int Id { get; private set; }
public EmailAddress Email { get; private set; }
}PhoneNumber، Money یا DateRange؟Orders، OrderItems، Events… اما DDD از ما میخواهد اول زبان مشترک (Ubiquitous Language) بسازیم و مفهومها را طبقهبندی کنیم: کدامها Entity هستند و کدامها Value Object؟// EF Core: نسخهی ساده و ابتدایی (before)
public class TicketOrder
{
public Guid Id { get; set; }
public Guid EventId { get; set; }
public string CustomerEmail { get; set; } = default!;
public string CustomerName { get; set; } = default!;
public int Quantity { get; set; }
public decimal TotalPrice { get; set; }
public string Currency { get; set; } = "USD";
public DateTime CreatedAt { get; set; }
public string Status { get; set; } = "Draft"; // Draft, Submitted, Paid, Cancelled
}Id).Email، Money، Quantity همگی string/int/decimal هستند. هیچ نامعقولی را پیشگیری نمیکنند (مثلاً Quantity = -3).decimal بودن TotalPrice با Currency جداست، احتمال خطا زیاد است.// پایهی مشترک برای Value Object ها
public abstract class ValueObject
{
protected static bool EqualOperator(ValueObject left, ValueObject right)
=> ReferenceEquals(left, right) || (left is not null && right is not null && left.Equals(right));
protected static bool NotEqualOperator(ValueObject left, ValueObject right) => !(EqualOperator(left, right));
protected abstract IEnumerable<object?> GetEqualityComponents();
public override bool Equals(object? obj)
=> obj is ValueObject other && GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
public override int GetHashCode()
=> GetEqualityComponents().Aggregate(1, (current, obj) => HashCode.Combine(current, obj));
}
public sealed class Email : ValueObject
{
public string Value { get; }
private Email(string value) { Value = value; }
public static Email Create(string value)
{
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Email is required");
// اعتبارسنجی ساده؛ در عمل از regex/کتابخانههای معتبر استفاده کنید
if (!value.Contains('@')) throw new ArgumentException("Invalid email");
return new Email(value.Trim().ToLowerInvariant());
}
protected override IEnumerable<object?> GetEqualityComponents()
{ yield return Value; }
public override string ToString() => Value;
}
public sealed class PersonName : ValueObject
{
public string FullName { get; }
private PersonName(string fullName) { FullName = fullName; }
public static PersonName Create(string fullName)
{
if (string.IsNullOrWhiteSpace(fullName)) throw new ArgumentException("Name is required");
return new PersonName(fullName.Trim());
}
protected override IEnumerable<object?> GetEqualityComponents()
{ yield return FullName; }
public override string ToString() => FullName;
}
public sealed class Quantity : ValueObject
{
public int Value { get; }
private Quantity(int value) { Value = value; }
public static Quantity Create(int value)
{
if (value <= 0) throw new ArgumentOutOfRangeException(nameof(value), "Quantity must be positive");
if (value > 20) throw new ArgumentOutOfRangeException(nameof(value), "Max 20 tickets per order");
return new Quantity(value);
}
protected override IEnumerable<object?> GetEqualityComponents()
{ yield return Value; }
public override string ToString() => Value.ToString();
}
public sealed class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
private Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public static Money Create(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount));
if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency is required");
return new Money(decimal.Round(amount, 2), currency.ToUpperInvariant());
}
public static Money operator +(Money left, Money right)
{
if (left.Currency != right.Currency) throw new InvalidOperationException("Currency mismatch");
return Create(left.Amount + right.Amount, left.Currency);
}
protected override IEnumerable<object?> GetEqualityComponents()
{ yield return Amount; yield return Currency; }
public override string ToString() => $"{Amount} {Currency}";
}
public enum OrderStatus { Draft, Submitted, Paid, Cancelled }public class TicketOrder
{
private TicketOrder() { } // برای EF Core
public Guid Id { get; private set; }
public Guid EventId { get; private set; }
// Value Objects بهصورت Owned Types در EF Core نگاشت میشوند
public Email CustomerEmail { get; private set; } = default!;
public PersonName CustomerName { get; private set; } = default!;
public Quantity Quantity { get; private set; } = default!;
public Money TotalPrice { get; private set; } = default!;
public DateTime CreatedAt { get; private set; }
public OrderStatus Status { get; private set; }
public static TicketOrder CreateDraft(Guid eventId, Email email, PersonName name, Quantity quantity, Money unitPrice)
{
var order = new TicketOrder
{
Id = Guid.NewGuid(),
EventId = eventId,
CustomerEmail = email,
CustomerName = name,
Quantity = quantity,
TotalPrice = Money.Create(unitPrice.Amount * quantity.Value, unitPrice.Currency),
CreatedAt = DateTime.UtcNow,
Status = OrderStatus.Draft
};
return order;
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Only draft orders can be submitted");
Status = OrderStatus.Submitted;
}
public void MarkPaid()
{
if (Status != OrderStatus.Submitted)
throw new InvalidOperationException("Only submitted orders can be paid");
Status = OrderStatus.Paid;
}
public void Cancel()
{
if (Status == OrderStatus.Paid)
throw new InvalidOperationException("Paid orders cannot be cancelled");
Status = OrderStatus.Cancelled;
}
}public class OrderingDbContext : DbContext
{
public DbSet<TicketOrder> TicketOrders => Set<TicketOrder>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var order = modelBuilder.Entity<TicketOrder>();
order.HasKey(x => x.Id);
order.Property(x => x.EventId).IsRequired();
order.Property(x => x.CreatedAt).IsRequired();
order.Property(x => x.Status).HasConversion<int>();
// Email
order.OwnsOne(x => x.CustomerEmail, b =>
{
b.Property(p => p.Value).HasColumnName("CustomerEmail").HasMaxLength(256);
});
// PersonName
order.OwnsOne(x => x.CustomerName, b =>
{
b.Property(p => p.FullName).HasColumnName("CustomerName").HasMaxLength(128);
});
// Quantity
order.OwnsOne(x => x.Quantity, b =>
{
b.Property(p => p.Value).HasColumnName("Quantity");
});
// Money
order.OwnsOne(x => x.TotalPrice, b =>
{
b.Property(p => p.Amount).HasColumnName("TotalPrice");
b.Property(p => p.Currency).HasColumnName("Currency").HasMaxLength(3);
});
}
}نکته: اگر از شناسههای قویتایپ (Strongly-Typed Id) مثلOrderIdیاEventIdاستفاده کنید، در EF Core با ValueConverter آنها را به/ازGuidنگاشت کنید.
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
// نگاشت
order.Property(x => x.Id)
.HasConversion(
id => id.Value,
value => new OrderId(value)
);Quantity.Create(…) یا Money.Create(…) فراخوانی شده، معنای دامنه را میخوانیم، نه فقط نوع داده را.Quantity = -3 یا اختلاف ارز عملاً از بین میرود.Status = "Paid" از enum معنادار استفاده میکنیم.public class Event
{
private Event() { }
public Guid Id { get; private set; }
public string Title { get; private set; } = default!;
public int Capacity { get; private set; }
public int Sold { get; private set; }
public bool CanReserve(Quantity q) => Sold + q.Value <= Capacity;
public void Reserve(Quantity q)
{
if (!CanReserve(q)) throw new InvalidOperationException("Capacity exceeded");
Sold += q.Value;
}
}Event میپرسیم آیا رزرو ممکن است؛ اگر بله، رزرو میکنیم و سپس TicketOrder میسازیم. اگر نیاز به سازگاری قوی داریم، الگوهای Outbox یا Saga/Process Manager کمک میکنند.[Fact]
public void Quantity_cannot_be_negative()
{
Assert.Throws<ArgumentOutOfRangeException>(() => Quantity.Create(-1));
}
[Fact]
public void Money_cannot_add_with_different_currencies()
{
var a = Money.Create(10, "USD");
var b = Money.Create(5, "EUR");
Assert.Throws<InvalidOperationException>(() => { var _ = a + b; });
}
[Fact]
public void Draft_order_can_be_submitted_then_paid()
{
var order = TicketOrder.CreateDraft(Guid.NewGuid(), Email.Create("a@b.com"), PersonName.Create("Ali"), Quantity.Create(2), Money.Create(100, "USD"));
order.Submit();
order.MarkPaid();
Assert.Equal(OrderStatus.Paid, order.Status);
}OrderStatus را جایگزین string Status کنید.Quantity و Email را بسازید؛ کمترین وابستگی را دارند.Money را اضافه کنید و در همهی مسیرهای قیمتگذاری استفاده کنید.TicketOrder، با چند گام به مدلی غنی تبدیل شد که:Address با جدول جدا)، میتوانید آن را به Entity تبدیل کنید؛ معیار، هویت مستقل و چرخهی عمر است.DiscountCode در سیستم شما VO است یا Entity؟ چرا؟و اکنون نوبت شماست: یکی از Entityهای سیستم خود را بردارید و همین چرخهی کشف → نقد → بازطراحی را روی آن تمرین کنید.
101، حتی اگر نام، آدرس یا شماره تلفنش تغییر کند، همچنان همان عضو شماره 101 است. هویت او به ویژگیهایش وابسته نیست، بلکه به آن شناسه یکتا گره خورده است. پس با اطمینان، Member را به عنوان اولین Entity سیستم خود کشف و انتخاب میکنیم.Member را مینویسیم:public class Member
{
public int Id { get; private set; } // شناسه یکتا
public string FirstName { get; private set; }
public string LastName { get; private set; }
public string NationalCode { get; private set; }
public string MobileNumber { get; private set; }
public string Address_Street { get; private set; }
public string Address_City { get; private set; }
public string Address_PostalCode { get; private set; }
// سازنده و متدها...
}string, int, bool) تقلیل دادهایم. این کار چه مشکلاتی ایجاد میکند؟MobileNumber صرفاً یک string است؟ آیا هر رشتهای میتواند یک شماره موبایل معتبر باشد؟ قطعاً نه! اما مدل ما این اجازه را میدهد. string هیچکدام از قوانین کسبوکار (مثلاً شروع با "09" و داشتن ۱۱ رقم) را در خود ندارد.Address_Street, Address_City و Address_PostalCode به وضوح به هم مرتبط هستند. آنها با هم یک مفهوم واحد به نام «آدرس» را تشکیل میدهند. اما در مدل ما، سه string پراکنده و بیربط هستند و این ارتباط فقط در ذهن ما وجود دارد، نه در کد!Addresspublic class Address
{
public string Street { get; private set; }
public string City { get; private set; }
public string PostalCode { get; private set; }
// سازنده خصوصی برای EF Core
private Address() { }
public Address(string street, string city, string postalCode)
{
// اعتبارسنجیها همینجا، در قلب مدل دامنه اتفاق میافتد
if (string.IsNullOrWhiteSpace(street))
throw new ArgumentNullException(nameof(street));
if (string.IsNullOrWhiteSpace(city))
throw new ArgumentNullException(nameof(city));
// و سایر قوانین...
Street = street;
City = city;
PostalCode = postalCode;
}
}MobileNumber و NationalCodepublic class MobileNumber
{
public string Value { get; private set; }
private MobileNumber() { }
public MobileNumber(string value)
{
if (string.IsNullOrWhiteSpace(value) || !IsValid(value)) // IsValid یک متد داخلی برای چک کردن فرمت است
throw new ArgumentException("Invalid mobile number format.");
Value = value;
}
private static bool IsValid(string number)
{
// منطق بررسی فرمت شماره موبایل
return number.StartsWith("09") && number.Length == 11 && number.All(char.IsDigit);
}
// ... میتوان متدهای دیگری هم برای برابری و ... اضافه کرد
}
// کلاس NationalCode نیز به روشی مشابه پیادهسازی میشودMemberMember ما چقدر تمیزتر، گویاتر و هوشمندتر شده است:public class Member
{
public int Id { get; private set; }
public string FirstName { get; private set; } // برای سادگی، اینها را primitive نگه داشتیم، اما میتوانستند FullName VO باشند
public string LastName { get; private set; }
public NationalCode NationalCode { get; private set; } // به جای string
public MobileNumber MobileNumber { get; private set; } // به جای string
public Address Address { get; private set; } // به جای سه string پراکنده
private Member() { } // برای EF Core
public Member(string firstName, string lastName, NationalCode nationalCode, MobileNumber mobileNumber, Address address)
{
// ... اعتبارسنجیهای مربوط به خود Member
FirstName = firstName;
LastName = lastName;
NationalCode = nationalCode;
MobileNumber = mobileNumber;
Address = address;
}
public void ChangeAddress(Address newAddress)
{
// متدی گویا که رفتار کسبوکار را بیان میکند
Address = newAddress ?? throw new ArgumentNullException(nameof(newAddress));
}
}Address، از قابلیت Owned Entity Types استفاده میکنیم.MobileNumber، از Value Converters بهره میگیریم.// در فایل DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var memberBuilder = modelBuilder.Entity<Member>();
// 1. نگاشت Address به عنوان یک Owned Type
// EF Core فیلدهای Street, City, PostalCode را به عنوان ستونهایی در جدول Members ذخیره میکند.
memberBuilder.OwnsOne(m => m.Address, addressBuilder =>
{
addressBuilder.Property(a => a.Street).HasColumnName("Address_Street");
addressBuilder.Property(a => a.City).HasColumnName("Address_City");
addressBuilder.Property(a => a.PostalCode).HasColumnName("Address_PostalCode");
});
// 2. نگاشت MobileNumber با استفاده از Value Converter
// به EF Core میگوییم که چطور MobileNumber را به string و برعکس تبدیل کند.
memberBuilder.Property(m => m.MobileNumber)
.HasConversion(
v => v.Value, // از MobileNumber به string
v => new MobileNumber(v) // از string به MobileNumber
);
// نگاشت NationalCode نیز به همین صورت خواهد بود...
}