عنوان:

‫Domain-Driven Design (DDD)؛ بخش پنجم: موجودیت‌ها


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۵/۳۱ ۰۸:۰۵
آدرس: www.dntips.ir
موجودیت‌ها: قلب تپنده دامنه

تا به حال به این فکر کرده‌اید که چه چیزی یک شیء را از دیگری متمایز می‌کند؟ به یک "شخص" فکر کنید. یک شخص با نام، سن و آدرسش تعریف می‌شود. اما اگر دو نفر، نام و سن یکسان داشته باشند، آیا آن‌ها یک نفر هستند؟ قطعا خیر. هر شخص، یک هویت منحصربه‌فرد دارد.
در Domain-Driven Design (DDD)، این مفهوم هویت اهمیت زیادی پیدا می‌کند. موجودیت‌ها (Entities) همان اشیائی هستند که در طول زمان یک هویت منحصربه‌فرد دارند و این هویت، آن‌ها را از اشیاء دیگر متمایز می‌کند.

موجودیت در برابر مدل داده‌ای

در رویکردهای سنتی، ما اغلب کلاس‌هایی می‌سازیم که صرفاً شبیه به جداول دیتابیس هستند. این کلاس‌ها، فقط شامل ویژگی‌ها (Properties) و getters/setters هستند و هیچ رفتار یا منطق تجاری در خود ندارند. به این کلاس‌ها، Data Models می‌گویند.
اما در DDD، یک Entity فراتر از یک مدل داده‌ای است. یک موجودیت، علاوه بر اینکه داده‌ها را نگهداری می‌کند، مسئول رفتارهای مربوط به خودش نیز هست.
بیایید با یک مثال، تفاوت را بررسی کنیم:
مدل داده‌ای (Data Model):
public class Order
{
    public int OrderId { get; set; }
    public decimal TotalAmount { get; set; }
    public string Status { get; set; }
}
در این مدل، کلاس Order فقط داده‌ها را نگهداری می‌کند. اگر بخواهید وضعیت سفارش را تغییر دهید، مجبورید این کار را از بیرون کلاس و در یک سرویس جداگانه انجام دهید.

موجودیت (Entity) در DDD:
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) را در خود دامنه حفظ کنیم.

چرا این تفاوت اهمیت دارد؟

  • حفظ قوانین تجاری: موجودیت‌ها تضمین می‌کنند که قوانین تجاری همواره رعایت می‌شوند.
  • کپسوله‌سازی (Encapsulation): با پنهان کردن جزئیات پیاده‌سازی، کد شما قابل فهم‌تر و نگهداری آن آسان‌تر می‌شود.
  • تست‌پذیری: موجودیت‌ها را می‌توان به صورت جداگانه از لایه‌های دیگر (مثل دیتابیس) تست کرد.

به این فکر کنید: آیا کلاس‌های شما در پروژه‌تان فقط داده‌ها را نگهداری می‌کنند یا رفتارهای مرتبط با خودشان را نیز انجام می‌دهند؟
با DDD، ما کلاس‌هایی می‌سازیم که فراتر از یک ظرف داده‌ای ساده هستند. آن‌ها موجودیت‌های زنده و پویایی هستند که قوانین و رفتارهای دنیای واقعی را مدل‌سازی می‌کنند و همین، هسته اصلی یک نرم‌افزار قدرتمند است.


Value Objects: اشیاء کوچک، اما قدرتمند 💎

تا به حال در پروژه‌ای بوده‌اید که یک رشته ساده، معانی مختلفی داشته باشد؟ مثلاً در یک سیستم، string برای "آدرس" استفاده می‌شود. اما آیا یک آدرس فقط یک رشته است؟ یک آدرس از "خیابان"، "شهر"، "کد پستی" و "کشور" تشکیل شده است. استفاده از یک string ساده، باعث می‌شود که نتوانیم قوانین تجاری مربوط به آدرس (مثلاً معتبر بودن کد پستی) را به درستی اجرا کنیم و کد ما پر از اعتبارسنجی‌های تکراری شود. اینجاست که Value Objects یا "اشیاء ارزشی" وارد میدان می‌شوند.

Value Objects در برابر Entities

Value Objects، برخلاف Entities (موجودیت‌ها)، هویت منحصربه‌فردی ندارند.
  • یک Entity با هویت خود تعریف می‌شود. دو شخص با نام یکسان، دو نفر متفاوت هستند.
  • یک Value Object با ویژگی‌های خود تعریف می‌شود. دو آدرس که تمام اجزای آن‌ها (خیابان، شهر، کد پستی) یکسان باشد، یک چیز هستند.
اگر یک Value Object را به یک Entity تشبیه کنیم، می‌توان گفت که یک Entity مثل "شخص" است و Value Object مثل "آدرس" یا "شماره تلفن" او.

چرا از Value Objects استفاده کنیم؟

استفاده از Value Objects مزایای زیادی به همراه دارد:
  • کپسوله‌سازی منطق: منطق مربوط به یک مقدار (مثل اعتبارسنجی یک آدرس ایمیل) در داخل خود Value Object قرار می‌گیرد و از تکرار کد جلوگیری می‌شود.
  • تغییرناپذیری (Immutability): Value Objects باید تغییرناپذیر باشند. این یعنی پس از ساخته شدن، نمی‌توان آن‌ها را تغییر داد. اگر نیاز به تغییر باشد، یک نمونه جدید ساخته می‌شود. این ویژگی باعث می‌شود که کد ما امن‌تر و قابل پیش‌بینی‌تر شود، به خصوص در محیط‌های چندرشته‌ای.
  • خوانایی کد: با استفاده از Value Objects، کد شما خواناتر می‌شود. به جای اینکه متدهایی با آرگومان‌های string یا int داشته باشید، می‌توانید از نوع‌های مشخص‌تری مثل EmailAddress یا Money استفاده کنید.

بیایید با یک مثال، این موضوع را روشن‌تر کنیم. به جای اینکه string را برای ایمیل استفاده کنید، می‌توانید یک Value Object بسازید:
بدون Value Object:
public class User
{
    public int Id { get; set; }
    public string Email { get; set; }
}
در این مدل، هر بار که بخواهید از ایمیل استفاده کنید، باید اعتبار آن را بررسی کنید.

با Value Object:
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; }
}
در این مدل، تضمین می‌کنیم که هر ایمیلی که در سیستم وجود دارد، معتبر است.
به این فکر کنید: در پروژه‌تان، آیا مقادیر مهمی دارید که می‌توانند به Value Object تبدیل شوند؟ مثلاً PhoneNumber، Money یا DateRange؟
Value Objects، ابزارهایی کوچک اما حیاتی هستند که به ما کمک می‌کنند تا مدل دامنه‌مان را دقیق‌تر، قوی‌تر و پایدارتر بسازیم.

نظرات

  • وحید نصیری در ۱۴۰۴/۰۵/۳۱ ۰۹:۲۶
    از Entity تا Value Object در EF Core و DDD

    1) آغاز ماجرا: کشف دامنه

    فرض کنید قرار است یک سامانه‌ی فروش بلیت رویداد طراحی کنیم؛ رویدادهایی مثل کنسرت، کارگاه یا کنفرانس. ذی‌نفعان چه می‌خواهند؟ «کاربر بلیت بخرد»، «محدودیت ظرفیت رعایت شود»، «قیمت‌ها و تخفیف‌ها درست محاسبه شوند»، «ایمیل تأیید ارسال شود».
    اگر همین الان دست‌به‌کد شویم، احتمالاً سراغ یک جدول/کلاس ساده می‌رویم: Orders، OrderItems، Events… اما DDD از ما می‌خواهد اول زبان مشترک (Ubiquitous Language) بسازیم و مفهوم‌ها را طبقه‌بندی کنیم: کدام‌ها Entity هستند و کدام‌ها Value Object؟

    بیایید با یک گفت‌وگوی خیالی شروع کنیم:
    • «سفارش بلیت» چیزی است که هویت یکتای خودش را دارد و در طول زمان تغییر می‌کند → Entity.
    • «ایمیل مشتری»، «مبلغ سفارش»، «تعداد بلیت»، «بازه‌ی زمانی رویداد» چیزهایی‌اند که اگر مقدارشان عوض شود، چیز جدیدی محسوب می‌شوند و هویت مستقل ندارند → Value Object.

    در نتیجه، دو کانون توجه داریم:
    • Event (رویداد) به‌عنوان منبع ظرفیت و برنامه.
    • TicketOrder (سفارش بلیت) به‌عنوان آنچه کاربر ایجاد می‌کند و وضعیت می‌گیرد (Draft/Submitted/Paid/Cancelled).

    2) نسخه‌ی اولیه (کشف خام): یک Entity ساده اما آمیخته با «Primitive Obsession»
    در شروع کار، خیلی طبیعی است که به کدی شبیه زیر برسیم. همه‌چیز نوع‌های ابتدایی دارد و منطق کسب‌وکار تقریباً در همه‌جا پخش است.
    // 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
    }

    چرا این یک Entity است؟
    • هویت پایدار دارد (Id).
    • چرخه‌ی عمر دارد (از Draft تا Paid/Cancelled).
    • قابلیت تغییر دارد (مثلاً تغییر تعداد قبل از ثبت نهایی).

    اما مشکل کجاست؟
    • «وسواس نوع‌های ابتدایی» (Primitive Obsession): Email، Money، Quantity همگی string/int/decimal هستند. هیچ نامعقولی را پیشگیری نمی‌کنند (مثلاً Quantity = -3).
    • قوانین حوزه درون هیچ کپسولی نیستند؛ هرکسی می‌تواند هرجا مقادیر را دست‌کاری کند.
    • عدم وضوح معنایی: decimal بودن TotalPrice با Currency جداست، احتمال خطا زیاد است.

    3) بازطراحی بر اساس اصول DDD: غنی کردن مدل با Value Objectها
    هدف: رفتار و قواعد را نزدیک داده‌ها نگه داریم و معنادهی کنیم.

    3.1) Value Objectها را معرفی کنیم
    • Email: فقط ایمیل معتبر.
    • PersonName: تفکیک نام/نام‌خانوادگی یا قالب کامل، بدون فاصله‌های اضافی.
    • Quantity: عدد صحیح مثبت و محدود به سقف منطقی.
    • Money: مقدار + ارز با قواعد جمع/تفریق/مقایسه.
    • EventSchedule: بازه‌ی زمانی شروع/پایان رویداد با اعتبارسنجی.
    • OrderStatus: یک enum معنایی.
    // پایه‌ی مشترک برای 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 }

    3.2) Aggregateها: چه کسی ریشه است؟
    • Event ظرفیت را مدیریت می‌کند؛ نباید بیش از ظرفیت، بلیت فروخته شود.
    • TicketOrder وضعیت سفارش را نگه می‌دارد.

    در بسیاری از سامانه‌ها، چک‌کردن ظرفیت در لایه‌ی Application Service انجام می‌شود تا از تراکنش سازگار بین دو Aggregate مطمئن شویم (یا با الگوی Reservation/Outbox این سازگاری را تقویت کنیم).

    3.3) نسخه‌ی بهبود‌یافته‌ی Entity با Value Objectها
    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;
        }
    }

    3.4) نگاشت EF Core برای Value Objectها (Owned Types)
    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)
         );

    4) چرا این بازطراحی «بهتر» است؟
    • بیان‌گراتر: وقتی می‌بینیم Quantity.Create(…) یا Money.Create(…) فراخوانی شده، معنای دامنه را می‌خوانیم، نه فقط نوع داده را.
    • ایمنی بیشتر: امکان Quantity = -3 یا اختلاف ارز عملاً از بین می‌رود.
    • کپسوله‌سازی قواعد: اعتبارسنجی‌ها و رفتارها نزدیک داده‌ها هستند؛ از تکرار منطق در لایه‌های مختلف جلوگیری می‌شود.
    • تست‌پذیری: Value Objectها کوچک و مستقل‌اند؛ تست واحد آن‌ها ساده است.
    • پیشگیری از «رشته‌های جادویی»: به‌جای Status = "Paid" از enum معنادار استفاده می‌کنیم.

    5) یک قدم فراتر: تعامل Aggregateها و ظرفیت رویداد
    برای رعایت ظرفیت، معمولاً Event به‌صورت Aggregate Root نگهداری می‌شود:
    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;
        }
    }
    در لایه‌ی سرویس کاربردی (Application Service)، قبل از ایجاد سفارش، از Event می‌پرسیم آیا رزرو ممکن است؛ اگر بله، رزرو می‌کنیم و سپس TicketOrder می‌سازیم. اگر نیاز به سازگاری قوی داریم، الگوهای Outbox یا Saga/Process Manager کمک می‌کنند.

    6) تست واحد (چند نمونه‌ی کوتاه)
    [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);
    }

    7) الگوی مهاجرت (Refactor گام‌به‌گام)
    • نام‌گذاری شفاف: از همین امروز OrderStatus را جایگزین string Status کنید.
    • VOهای کم‌ریسک: ابتدا Quantity و Email را بسازید؛ کم‌ترین وابستگی را دارند.
    • VO مالی: Money را اضافه کنید و در همه‌ی مسیرهای قیمت‌گذاری استفاده کنید.
    • تثبیت نگاشت EF Core: Owned Types را برای VOها تعریف کنید؛ مهاجرت پایگاه داده را بنویسید.
    • جداکردن قوانین: هر قانون تکراری را به نزدیک‌ترین موجودیت/VO منتقل کنید.
    • پوشش تست: برای هربار تغییر، تست‌های واحد را اضافه/به‌روزرسانی کنید.

    8) جمع‌بندی: از «داده» به «مدل»
    آنچه شروع شد با یک کلاس ساده‌ی TicketOrder، با چند گام به مدلی غنی تبدیل شد که:
    • قواعد را بیان می‌کند،
    • خطاهای رایج را از ابتدا حذف می‌کند،
    • و خوانایی و نگهداشت را به‌طور محسوس افزایش می‌دهد.

    اگر در مسیر طراحی، مرتب به خودتان بگویید «این عدد/رشته دقیقاً چی را در دامنه نشان می‌دهد؟»، در حال حرکت از Entityهای فقیر به مدل‌های غنی هستید؛ جایی که DDD و EF Core بهترین هم‌افزایی را پیدا می‌کنند.

    پیوست A) نکته‌های EF Core برای Value Objectها
    • Owned Types ساده‌ترین راه نگاشت VOها هستند؛ ستون‌ها معمولاً کنار جدول Entity قرار می‌گیرند.
    • برای Strongly-Typed Idها از ValueConverter استفاده کنید.
    • اگر VO پیچیده/چند-به-یک است (مثلاً Address با جدول جدا)، می‌توانید آن را به Entity تبدیل کنید؛ معیار، هویت مستقل و چرخه‌ی عمر است.

    پیوست B) پرسش‌هایی برای ادامه‌ی کاوش
    • آیا DiscountCode در سیستم شما VO است یا Entity؟ چرا؟
    • کدام VOها باید غیرقابل‌تغییر (Immutable) باشند؟ همه؟
    • در صورت نیاز به چند ارز، کجا باید تبدیل نرخ ارز انجام شود؟
    و اکنون نوبت شماست: یکی از Entityهای سیستم خود را بردارید و همین چرخه‌ی کشف → نقد → بازطراحی را روی آن تمرین کنید.

  • وحید نصیری در ۱۴۰۴/۰۵/۳۱ ۰۹:۳۶
    از ایده تا کد: سفری برای ساختن یک مدل دامنه معنادار با DDD و EF Core

    تا به حال برایتان پیش آمده که به یک قطعه کد نگاه کنید و احساس کنید که با شما حرف نمی‌زند؟ کدی که کار می‌کند، اما داستان واقعی کسب‌وکار را روایت نمی‌کند. امروز می‌خواهیم با هم سفری را آغاز کنیم. سفری از یک تحلیل ساده و خام به یک مدل دامنه (Domain Model) غنی، گویا و قدرتمند.

    ۱) آغاز ماجرا: کشف دامنه

    تصور کنید از ما خواسته شده تا سیستمی برای مدیریت یک باشگاه ورزشی طراحی کنیم. اولین قدم، صحبت با کارشناسان دامنه (Domain Experts) است؛ یعنی مدیر باشگاه، مربیان و مسئول پذیرش. در میان گفتگوها، یک مفهوم کلیدی بارها و بارها تکرار می‌شود: «عضو» (Member).
    • "هر عضو یک کد عضویت منحصر به فرد دارد."
    • "ما باید نام، نام خانوادگی و کد ملی عضو را ثبت کنیم."
    • "آدرس و شماره موبایل عضو برای اطلاع‌رسانی‌ها ضروری است."
    • "اگر عضو آدرسش را عوض کند، او همچنان همان عضو قبلی است، فقط اطلاعاتش به‌روز می‌شود."

    این گفتگوی ساده، سرنخ‌های ارزشمندی به ما می‌دهد. جمله‌ی آخر یک چراغ بزرگ در ذهن ما روشن می‌کند. مفهومی که در سیستم ما یک شناسه (Identity) منحصر به فرد و یک چرخه حیات (Lifecycle) دارد، حتی اگر تمام ویژگی‌هایش تغییر کند، یک موجودیت (Entity) است.
    یک «عضو» با کد عضویت 101، حتی اگر نام، آدرس یا شماره تلفنش تغییر کند، همچنان همان عضو شماره 101 است. هویت او به ویژگی‌هایش وابسته نیست، بلکه به آن شناسه یکتا گره خورده است. پس با اطمینان، Member را به عنوان اولین Entity سیستم خود کشف و انتخاب می‌کنیم.

    ۲) نسخه‌ی اولیه: یک Entity ساده اما آمیخته با «Primitive Obsession»

    خب، حالا که 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; }
    
        // سازنده و متدها...
    }
    این کد به نظر درست می‌آید، نه؟ کار می‌کند و می‌توانیم آن را با EF Core به راحتی در دیتابیس ذخیره کنیم. اما بیایید کمی عمیق‌تر به آن نگاه کنیم. یک حس ناخوشایند وجود دارد؛ انگار چیزی سر جایش نیست. این حس، ناشی از یک بوی بد کد (Code Smell) معروف به نام «وسواس انواع اولیه» (Primitive Obsession) است. ما مفاهیم غنی دامنه را به ساده‌ترین انواع داده ممکن (string, int, bool) تقلیل داده‌ایم. این کار چه مشکلاتی ایجاد می‌کند؟
    • فقدان معنای ذاتی: آیا MobileNumber صرفاً یک string است؟ آیا هر رشته‌ای می‌تواند یک شماره موبایل معتبر باشد؟ قطعاً نه! اما مدل ما این اجازه را می‌دهد. string هیچ‌کدام از قوانین کسب‌وکار (مثلاً شروع با "09" و داشتن ۱۱ رقم) را در خود ندارد.
    • پراکندگی منطق اعتبارسنجی: منطق بررسی صحت کد ملی یا فرمت شماره موبایل کجاست؟ احتمالاً در لایه‌های بالاتر، مانند سرویس‌ها یا حتی کنترلرها پخش می‌شود. این یعنی تکرار کد و افزایش احتمال خطا.
    • مفاهیم پنهان:Address_Street, Address_City و Address_PostalCode به وضوح به هم مرتبط هستند. آن‌ها با هم یک مفهوم واحد به نام «آدرس» را تشکیل می‌دهند. اما در مدل ما، سه string پراکنده و بی‌ربط هستند و این ارتباط فقط در ذهن ما وجود دارد، نه در کد!

    مدل فعلی ما یک مدل کم‌خون (Anemic Domain Model) است. فقط داده را نگه می‌دارد و هیچ رفتار و منطق هوشمندانه‌ای ندارد. وقت آن است که به آن جان ببخشیم!

    ۳) بازطراحی بر اساس اصول DDD: غنی کردن مدل با Value Objectها

    اینجاست که یکی از قدرتمندترین ابزارهای DDD به کمک ما می‌آید: اشیاء مقدار (Value Objects).
    یک Value Object چیست؟
    یک شیء است که یک مفهوم را در دامنه توصیف می‌کند، اما شناسه منحصر به فرد ندارد. هویت آن توسط ترکیب مقادیر ویژگی‌هایش تعیین می‌شود. دو Value Object با مقادیر یکسان، کاملاً معادل و قابل جایگزینی هستند. مهم‌ترین ویژگی آن‌ها تغییرناپذیری (Immutability) است؛ یعنی پس از ساخته شدن، نمی‌توان مقادیرشان را تغییر داد.
    بیایید مفاهیم پنهان در مدل قبلی را شناسایی و به Value Object تبدیل کنیم.
    الف) استخراج Address
    آدرس یک کاندیدای عالی است. یک آدرس هویت مستقلی ندارد؛ بلکه با خیابان، شهر و کد پستی‌اش تعریف می‌شود.
    public 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 و NationalCode
    این‌ها هم فقط رشته نیستند، بلکه مفاهیمی با قوانین خاص خودشان هستند.
    public 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 نیز به روشی مشابه پیاده‌سازی می‌شود
    با این کار، ساخت یک شماره موبایل نامعتبر در سیستم ما غیرممکن می‌شود. این قدرت یک مدل دامنه غنی است!

    نسخه نهاییEntity Member

    حالا بیایید ببینیم کلاس Member ما چقدر تمیزتر، گویاتر و هوشمندتر شده است:
    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));
        }
    }
    این مدل جدید داستان کسب‌وکار را بسیار بهتر روایت می‌کند: «یک عضو، یک کد ملی، یک شماره موبایل و یک آدرس دارد.»

    پیاده‌سازی در EF Core

    شاید بپرسید EF Core چگونه این کلاس‌های سفارشی را در دیتابیس ذخیره می‌کند؟ به سادگی!
    • برای Value Object‌های پیچیده مانند Address، از قابلیت Owned Entity Types استفاده می‌کنیم.
    • برای Value Object‌های ساده مانند 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 نیز به همین صورت خواهد بود...
    }

    نتیجه‌گیری: فراتر از داده

    در این سفر کوتاه، دیدیم که چگونه با تغییر دیدگاه و استفاده از اصول DDD، یک کلاس ساده و «کم‌خون» را به یک مدل دامنه غنی و هوشمند تبدیل کردیم. این کار فقط یک بازآرایی کد نبود؛ یک تغییر بنیادین در تفکر بود.

    مدل جدید ما:
    • گویاتر است: کد، زبان مشترک کسب‌وکار (Ubiquitous Language) را بازتاب می‌دهد.
    • امن‌تر است: با کپسوله کردن قوانین، از ایجاد داده‌های نامعتبر جلوگیری می‌کند.
    • قابل نگهداری‌تر است: منطق در جای درست خود قرار گرفته و از تکرار جلوگیری می‌شود.

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