عنوان:

‫Domain-Driven Design (DDD)؛ بخش یازدهم: DDD با EF Core؛ قسمت سوم


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۰۶ ۰۸:۰۵
آدرس: www.dntips.ir
مدل‌سازی روابط پیچیده: از دنیای دامنه تا دیتابیس 🤝

در دنیای واقعی، اشیاء به ندرت به صورت ایزوله وجود دارند. آن‌ها با یکدیگر ارتباط برقرار می‌کنند. یک "دانشجو" می‌تواند در چندین "کلاس" ثبت‌نام کند، و یک "کلاس" می‌تواند چندین "دانشجو" داشته باشد. این یک رابطه Many-to-Many است. همچنین، یک "مشتری" می‌تواند چندین "سفارش" داشته باشد، اما هر "سفارش" فقط به یک "مشتری" تعلق دارد. این یک رابطه One-to-Many است.
در DDD، هدف ما این است که این روابط پیچیده را در مدل دامنه‌ی خود به درستی مدل‌سازی کنیم و سپس از EF Core برای نگاشت صحیح آن‌ها به دیتابیس استفاده کنیم.

روابط One-to-Many: مالکیت و مسئولیت

در یک رابطه One-to-Many، یک موجودیت (مشتری) "مالک" مجموعه‌ای از موجودیت‌های دیگر (سفارش‌ها) است. در DDD، این رابطه باید در داخل یک Aggregate (تجمیع) مدیریت شود.
مثال:
فرض کنید کلاس Customer و Order را داریم. Order باید به عنوان بخشی از Customer مدیریت شود تا قوانین تجاری به درستی رعایت شوند. اما یک Aggregate نباید خیلی بزرگ باشد، بنابراین در این مورد، بهتر است Order یک Aggregate Root مستقل باشد و به Customer با یک کلید خارجی (Foreign Key) اشاره کند.
// Customer is an Aggregate Root
public class Customer
{
    public int CustomerId { get; private set; }
    // ...
}

// Order is another Aggregate Root
public class Order
{
    public int OrderId { get; private set; }
    public int CustomerId { get; private set; }
    // ...
}
در این حالت، EF Core به صورت خودکار یک رابطه بین این دو جدول برقرار می‌کند. این رویکرد، مرزهای Aggregate ها را حفظ می‌کند و از پیچیدگی‌های غیرضروری جلوگیری می‌نماید.

روابط Many-to-Many: از مدل دامنه تا دیتابیس

پیچیده‌ترین روابط، روابط Many-to-Many هستند. در دیتابیس، این روابط با یک جدول واسط (Join Table) حل می‌شوند. اما در مدل دامنه‌ی ما در DDD، این روابط چگونه باید مدل‌سازی شوند؟
روش اشتباه:
در مدل سنتی، برخی توسعه‌دهندگان به صورت مستقیم کلاس واسط را در مدل خود ایجاد می‌کنند. مثلاً، برای رابطه دانشجو-کلاس: StudentClass
این رویکرد، مدل دامنه‌ی شما را با جزئیات فنی دیتابیس آلوده می‌کند و با اصول DDD همخوانی ندارد.
روش درست:
در DDD، ما از یک کلکسیون (Collection) برای نمایش این روابط استفاده می‌کنیم. مدل دامنه ما از جزئیات فنی جدول واسط بی‌خبر است.
مثال:
public class Student
{
    public int StudentId { get; private set; }
    private readonly List<Class> _classes = new List<Class>();
    public IReadOnlyList<Class> Classes => _classes.AsReadOnly();

    public void EnrollInClass(Class newClass)
    {
        _classes.Add(newClass);
    }
}

public class Class
{
    public int ClassId { get; private set; }
    private readonly List<Student> _students = new List<Student>();
    public IReadOnlyList<Student> Students => _students.AsReadOnly();
}
در این مدل، کلاس‌های Student و Class فقط به یکدیگر ارجاع می‌دهند. EF Core به اندازه کافی هوشمند است که با دیدن این دو کلکسیون، به صورت خودکار یک جدول واسط در دیتابیس ایجاد کند. شما نیازی به ساخت کلاس واسط ندارید.

چرا این روش در DDD بهتر است؟
  • سادگی مدل: مدل دامنه‌ی شما از جزئیات پیاده‌سازی دیتابیس پاک باقی می‌ماند.
  • کپسوله‌سازی: منطق مربوط به اضافه کردن یا حذف یک دانشجو از کلاس در داخل خود متدهای کلاس‌ها قرار می‌گیرد.

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

نظرات

  • ایلیا اکبری فرد در ۱۴۰۴/۰۶/۰۶ ۰۹:۵۷
    برای خاصیت هایی که قابلیت انتخاب چند Enum را در مقادیر خود دارند دارند چه روشی مناسب است؟
    برای مثال یک Enum بنام ColorEnum داریم که شامل Green و Blue و Red می باشد.
    در موجودیت خود هم خاصیتی بنام SelectedColors داریم. بهتر است نوع خاصیت SelectedColors را چه نوعی قرار دهیم؟
    • وحید نصیری در ۱۴۰۴/۰۶/۰۶ ۱۰:۳۶
      بیاییم حالت‌های ممکن را باهم مرور کنیم و ببینیم کدام با فلسفه‌ی DDD و نگاشت EF-Core سازگارتر است:

      ۱) استفاده از Flag Enum
      در دات‌نت می‌توان یک enum را با مشخصه‌ی [Flags] تعریف کرد:
      [Flags]
      public enum ColorEnum
      {
          None = 0,
          Green = 1,
          Blue = 2,
          Red = 4
      }
      سپس در موجودیت:
      public class Product
      {
          public int Id { get; private set; }
          public ColorEnum SelectedColors { get; private set; }
      
          private Product() { } // برای EF-Core
      
          public Product(ColorEnum selectedColors)
          {
              SelectedColors = selectedColors;
          }
      
          public void AddColor(ColorEnum color)
          {
              SelectedColors |= color;
          }
      
          public void RemoveColor(ColorEnum color)
          {
              SelectedColors &= ~color;
          }
      
          public bool HasColor(ColorEnum color) => SelectedColors.HasFlag(color);
      }
      مزایا:
      • نگاشت به دیتابیس خیلی ساده (معمولاً int یا bigint).
      • کارایی بالا.
      • مدیریت آسان در EF-Core (تبدیل اتوماتیک enum به int).
      معایب:
      • وقتی چندین انتخاب زیاد یا متغیر باشند، مدیریت خوانایی سخت می‌شود.
      • نیاز به دانستن بیت‌ماسک دارد (برای افراد تازه‌کار کمی پیچیده است).

      ۲) استفاده از یک کالکشن جداگانه (Value Object یا Entity)
      گاهی اوقات انتخاب چند مقدار، چیزی فراتر از یک پرچم ساده است. مثلاً اگر در آینده بخواهیم برای هر رنگ اطلاعات جانبی ذخیره کنیم (مثل تاریخ انتخاب، یا اولویت). در این حالت بهتر است:
      public class SelectedColor
      {
          public ColorEnum Color { get; private set; }
          private SelectedColor() { }
          public SelectedColor(ColorEnum color) => Color = color;
      }
      و در موجودیت:
      public class Product
      {
          private readonly List<SelectedColor> _selectedColors = new();
          public IReadOnlyCollection<SelectedColor> SelectedColors => _selectedColors;
      
          public void AddColor(ColorEnum color)
          {
              if (_selectedColors.Any(c => c.Color == color))
                  return;
      
              _selectedColors.Add(new SelectedColor(color));
          }
      
          public void RemoveColor(ColorEnum color)
          {
              var item = _selectedColors.FirstOrDefault(c => c.Color == color);
              if (item != null)
                  _selectedColors.Remove(item);
          }
      }
      مزایا:
      • مدل دامنه غنی‌تر می‌شود.
      • قابلیت توسعه (metadata روی انتخاب‌ها).
      • قوانین دامنه راحت‌تر enforce می‌شوند.
      معایب:
      • نیازمند جدول Join در دیتابیس.
      • کمی پیچیدگی بیشتر در پیکربندی EF-Core.

      ۳) ذخیره به صورت لیست از Enum به شکل String یا Json
      راه سوم، نگه‌داری به شکل string یا json column است.
      public class Product
      {
          public int Id { get; private set; }
          public List<ColorEnum> SelectedColors { get; private set; } = new();
      }
      و در EF-Core می‌توان از ValueConverter استفاده کرد:
      modelBuilder.Entity<Product>()
          .Property(p => p.SelectedColors)
          .HasConversion(
              v => string.Join(",", v),           // ذخیره در DB
              v => v.Split(",", StringSplitOptions.RemoveEmptyEntries)
                    .Select(e => Enum.Parse<ColorEnum>(e))
                    .ToList()
          );
      مزایا:
      • پیاده‌سازی ساده برای سناریوهای کوچک.
      • بدون نیاز به جدول اضافه.
      معایب:
      • جستجو و Query زدن روی رنگ‌ها سخت است.
      • مقیاس‌پذیر نیست برای داده‌های بزرگ.

      پیشنهاد عملی (DDD + EF-Core)
      • اگر انتخاب‌ها صرفاً یک پرچم منطقی هستند → Flag Enum بهترین گزینه است.
      • اگر انتخاب‌ها دارای رفتار یا داده‌ی اضافی خواهند شد → کالکشن از Value Object یا Entity.
      • اگر فقط ذخیره‌سازی سبک و ساده مدنظر است → ValueConverter روی یک string یا json.


      یک نکته‌ی تکمیلی و جالب

      در نگارش‌های جدیدتر EF-Core، قابلیتی به نام Primitive Collections معرفی شده که می‌تواند تاکید بر نوع‌های مقداری (مثل enumها) را سبک‌تر و حرفه‌ای‌تر کند: حالا می‌توان آرایه‌ها یا لیست‌هایی از نوع‌های اصلی (primitive types) مثل int، string و حتی enum را با EF-Core نگاشت کرد و در پایگاه داده به صورت ستون JSON، ذخیره‌شده داشت. این قابلیت با پشتیبانی پایگاه‌داده‌های مدرن از JSON همراه است.
      در EF Core 9، این قابلیت توسعه داده شده و شامل انواع خواندنی مثل IReadOnlyList، IReadOnlyCollection یا ReadOnlyCollection نیز می‌شود، که برای سناریوهایی که شامل مجموعه‌های تغییرناپذیر هستند بسیار مناسب است.

      Primitive Collections با JSON (EF Core 8+)
      public List<ColorEnum> SelectedColors { get; set; } = new();
      در EF Core 8 به بعد، بدون نیاز به تنظیمات خاص، این لیست به صورت JSON ذخیره می‌شود. همچنین امکان استفاده از IReadOnlyCollection یا دیگر انواع خواندنی در EF Core 9 وجود دارد.
      مزایا
      • بدون نیاز به جدول اضافی؛ ساده و سبک.
      • قابلیت Query روی JSON با LINQ (مثلاً Contains،Intersect و... در پایگاه‌های پشتیبانی‌کننده).
      معایب
      • پیچیدگی در Query‌گیری پیچیده (ممکن است ایندکس‌ها به درستی استفاده نشوند).
      • کمتر مناسب برای تطابق زیاد یا الزامات پیچیده DDD.


      سناریو رویکرد پیشنهادیمناسب برای...
      فقط پرچم سادهFlag Enumوقتی تنها نیاز به ذخیره چند گزینه هست
      منطق و متادیتاValue Object Collectionوقتی قوانین دامنه و توسعه‌پذیری اهمیت دارد
      نگهداری سبک، بدون جدول اضافیPrimitive Collections (JSON)وقتی می‌خواهید ساده نگه دارید و Query ساده نیاز دارید