عنوان:

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


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۰۵ ۰۸:۱۰
آدرس: www.dntips.ir
DbContext در DDD: فراتر از یک رابط، یک مدیر هوشمند 🧠

تا به حال به این فکر کرده‌اید که چگونه Entity Framework Core تمام تغییراتی را که در مدل دامنه‌ی شما ایجاد می‌شود، ردیابی می‌کند و به صورت یکپارچه در دیتابیس ذخیره می‌نماید؟ DbContext، آن قهرمان خاموش در پشت پرده است که این کار را برای ما انجام می‌دهد.
در دیدگاه سنتی، DbContext اغلب به عنوان یک اتصال مستقیم به دیتابیس در نظر گرفته می‌شود. اما در دنیای Domain-Driven Design (DDD)، نقش آن بسیار عمیق‌تر و مهم‌تر است.

DbContext به عنوان Unit of Work

یکی از مهم‌ترین وظایف DbContext، پیاده‌سازی الگوی Unit of Work است.
Unit of Work به یک تراکنش واحد و منطقی از کار اشاره دارد که تمام تغییرات در یک عملیات را در بر می‌گیرد.
به جای اینکه هر تغییر را به صورت جداگانه در دیتابیس ذخیره کنیم، DbContext تمام تغییرات (افزودن، به‌روزرسانی و حذف) را تا زمانی که شما دستور SaveChanges() را صادر نکنید، در حافظه ردیابی می‌کند.
چرا این در DDD مهم است؟
در DDD، ما از Aggregates (تجمیع‌ها) استفاده می‌کنیم تا قوانین تجاری را به صورت یکپارچه اجرا کنیم. یک Aggregate به عنوان یک واحد کامل و منسجم ذخیره می‌شود. DbContext با مدیریت Unit of Work، تضمین می‌کند که تمام تغییرات در یک Aggregate، به صورت اتمی (Atomic) و در یک تراکنش واحد در دیتابیس ذخیره شوند. این یعنی یا تمام تغییرات اعمال می‌شوند و یا هیچ‌کدام، که برای حفظ Consistency (سازگاری) حیاتی است.

DbContext به عنوان ابزار ردیابی تغییرات (Change Tracker)

DbContext دارای یک قابلیت قدرتمند به نام Change Tracker است. این ابزار به صورت هوشمند، وضعیت هر Entity که در حافظه قرار دارد را ردیابی می‌کند.
وقتی یک Entity را از طریق DbContext بازیابی می‌کنید، Change Tracker شروع به نظارت بر آن می‌کند. اگر ویژگی‌های آن Entity را تغییر دهید، Change Tracker متوجه می‌شود و این تغییرات را برای ذخیره‌سازی آماده می‌کند.
این قابلیت چگونه به DDD کمک می‌کند؟
در DDD، ما از Rich Domain Models استفاده می‌کنیم که رفتار و منطق را در خود کپسوله‌سازی کرده‌اند. ما از private set برای ویژگی‌ها و متدهای عمومی برای اجرای منطق استفاده می‌کنیم. DbContext با قابلیت Change Tracker خود، با این رویکرد به خوبی کار می‌کند. شما فقط متدهای Entity خود را فراخوانی می‌کنید و DbContext به صورت خودکار تغییرات را ردیابی می‌کند و نیازی به کد اضافی برای به‌روزرسانی دستی نیست.

به این فکر کنید: در پروژه شما، آیا برای ذخیره هر تغییر کوچک، یک کوئری جداگانه به دیتابیس ارسال می‌کنید؟ یا تمام تغییرات را در یک تراکنش واحد ذخیره می‌کنید؟
در نهایت، DbContext چیزی فراتر از یک ابزار فنی است. این یک ابزار استراتژیک در DDD است که به ما اجازه می‌دهد تا با خیال راحت روی مدل‌سازی دامنه تمرکز کنیم و در عین حال، از اینکه تمام تغییرات به صورت امن و یکپارچه در دیتابیس ذخیره می‌شوند، اطمینان حاصل کنیم.


مقابله با مدل دامنه بی‌جان با EF Core 🧟‍♂️

در درس‌های قبل، با مفهوم Anemic Domain Model (مدل دامنه بی‌جان) آشنا شدیم: کلاس‌هایی که فقط داده‌ها را نگهداری می‌کنند و هیچ منطق تجاری در خود ندارند. این مدل، یکی از رایج‌ترین الگوهای اشتباه در توسعه نرم‌افزار است که به پراکندگی منطق تجاری و سختی در نگهداری کد منجر می‌شود.
اما در DDD، هدف ما ساخت Rich Domain Model (مدل دامنه غنی) است: کلاس‌هایی که هم داده‌ها و هم رفتارهای مربوط به خود را کپسوله‌سازی می‌کنند.
سوال اینجاست: چگونه می‌توانیم این مدل غنی را با استفاده از EF Core پیاده‌سازی کنیم، به گونه‌ای که قوانین تجاری ما (Invariants) همواره حفظ شوند؟

Invariants چیست؟

Invariants به قوانینی اشاره دارد که همیشه باید در مدل دامنه ما صادق باشند. مثلاً:
  • یک سفارش (Order) نمی‌تواند مبلغ منفی داشته باشد.
  • یک محصول (Product) نمی‌تواند به موجودی کمتر از صفر برسد.
  • یک حساب بانکی (BankAccount) نمی‌تواند مبلغی کمتر از صفر داشته باشد.
در یک Anemic Model، این قوانین در لایه‌های سرویس (Service Layer) بررسی می‌شوند. اما در یک Rich Model، این قوانین در داخل خود کلاس پیاده‌سازی می‌شوند تا از تغییرات ناخواسته جلوگیری کنند.

چگونه با EF Core این کار را انجام دهیم؟

EF Core، با ویژگی‌های قدرتمند خود، به ما اجازه می‌دهد که به راحتی با این مشکل مقابله کنیم.
1. استفاده از Private Setters:
در یک مدل دامنه غنی، ما نمی‌خواهیم که ویژگی‌ها (Properties) از بیرون کلاس قابل تغییر باشند. برای این کار، از private set استفاده می‌کنیم. EF Core به اندازه کافی هوشمند است که حتی با وجود private set، بتواند مقادیر را به درستی در دیتابیس ذخیره و بازیابی کند.
مثال:
public class Order
{
    public int Id { get; private set; }
    public decimal TotalAmount { get; private set; }

    public void AddItem(decimal itemPrice)
    {
        // Business logic to validate and add item
        this.TotalAmount += itemPrice;
    }
}
در این مثال، TotalAmount فقط از طریق متد AddItem قابل تغییر است. این تضمین می‌کند که TotalAmount همیشه به درستی محاسبه می‌شود و هیچ کس نمی‌تواند آن را به صورت مستقیم دستکاری کند.

2. استفاده از Field-only Properties:
این یک روش پیشرفته‌تر است که در آن، شما حتی نیازی به private set هم ندارید. در این روش، شما داده‌ها را در یک فیلد خصوصی (private field) نگهداری می‌کنید و EF Core را به صورت مستقیم به آن نگاشت می‌کنید.
مثال:
public class Account
{
    public int Id { get; private set; }
    private decimal _balance;

    public void Deposit(decimal amount)
    {
        if (amount <= 0)
        {
            throw new InvalidOperationException("Amount must be positive.");
        }
        _balance += amount;
    }
}
برای اینکه EF Core این فیلد را شناسایی کند، باید آن را در DbContext پیکربندی کنید:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Account>().Property(a => a._balance).HasColumnName("Balance");
}
این روش، قوی‌ترین راه برای حفظ Invariants است، زیرا فیلد از بیرون به هیچ وجه قابل دسترسی نیست.

به این فکر کنید: آیا در پروژه‌تان، ویژگی‌های عمومی (Public Properties) دارید که می‌خواهید فقط از طریق متدها قابل تغییر باشند؟ چگونه می‌توانید از private set یا Field-only Properties برای کپسوله‌سازی منطق در کلاس‌های خود استفاده کنید؟
با استفاده از این تکنیک‌ها، می‌توانیم از قدرت EF Core برای پیاده‌سازی مدل‌های دامنه‌ی قدرتمند استفاده کنیم و به جنگ با Anemic Domain Model برویم. این یک گام بزرگ به سوی ساخت نرم‌افزارهای پایدار و قابل نگهداری است.

نظرات

  • سید امید موسوی در ۱۴۰۴/۰۶/۰۵ ۱۱:۵۴
    در مورد روش استفاده از Field-only Properties

    در این حالت، EF Core به صورت پیش‌فرض فقط property‌های عمومی را مپ می‌کند و نمی‌تواند مستقیم از طریق لامبدا به یک فیلد private دسترسی پیدا کند. بنابراین فیلد _balance به این شکل توسط EF Core شناسایی نمی‌شود و ستون مربوطه در دیتابیس ایجاد نخواهد شد.


  • وحید نصیری در ۱۴۰۴/۰۶/۰۵ ۱۳:۳۷
    از آنمیک تا غنی: کشف Field-only Properties در EF-Core

    گاهی اوقات وقتی به مدل‌های اولیه در EF-Core نگاه می‌کنیم، چیزی شبیه به این می‌بینیم:
    public class Order
    {
        public int Id { get; set; }
        public decimal TotalAmount { get; set; }
        public List<OrderItem> Items { get; set; } = new();
    
        public Order() { }
    }
    در نگاه اول همه‌چیز ساده و روشن است. ولی اگر با دقت بیشتری بنگریم، یک نکته به چشم می‌آید: این کلاس بیشتر شبیه یک ساختار داده است تا یک موجودیت دامنه (Domain Entity).
    • هیچ منطق خاصی درون آن وجود ندارد.
    • تمام اعضا public set دارند.
    • هر کسی از بیرون می‌تواند TotalAmount یا حتی Items را دستکاری کند.

    به چنین مدلی معمولاً می‌گویند: مدل آنمیک (Anemic Domain Model).

    پرسشی برای فکر کردن: آیا واقعاً موجودیت «سفارش (Order)» در دنیای واقعی، چیزی است که هر کسی بتواند هر زمان خواست مبلغ کل آن را تغییر دهد؟ یا «آیتم‌های سفارش» چیزی هستند که باید با قواعد خاصی مدیریت شوند؟ این پرسش ما را آرام‌آرام به سمت یک مدل غنی‌تر هدایت می‌کند.

    حرکت به سمت موجودیت غنی با Field-only Properties
    در EF-Core قابلیتی وجود دارد به نام Field-only Properties. ایده ساده است:
    • به جای این‌که همه‌چیز public باشد، مقادیر حساس را پشت فیلدهای خصوصی نگه می‌داریم.
    • EF-Core همچنان می‌تواند داده‌ها را از پایگاه داده بارگذاری کند، ولی از بیرون کسی به سادگی به آن‌ها دسترسی ندارد.
    بیایید همان مثال «Order» را بازطراحی کنیم:
    public class Order
    {
        private readonly List<OrderItem> _items = new();
        private decimal _totalAmount;
    
        public int Id { get; private set; }
    
        // فقط خواندنی برای بیرون
        public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
        public decimal TotalAmount => _totalAmount;
    
        // سازنده برای ایجاد موجودیت معتبر
        public Order(IEnumerable<OrderItem> items)
        {
            if (items == null || !items.Any())
                throw new InvalidOperationException("سفارش نمی‌تواند بدون آیتم باشد.");
    
            _items.AddRange(items);
            _totalAmount = _items.Sum(i => i.Price * i.Quantity);
        }
    
        // منطق دامنه برای افزودن آیتم
        public void AddItem(OrderItem item)
        {
            if (item == null) throw new ArgumentNullException(nameof(item));
    
            _items.Add(item);
            _totalAmount += item.Price * item.Quantity;
        }
    }
    چند نکته کلیدی که کشف کردیم
    • اطلاعات حساس محافظت شده‌اند
    • دیگر کسی نمی‌تواند TotalAmount را مستقیم تغییر دهد.
    • تنها راه تغییر آن، عبور از متدهای دامنه است.
    • معنا و قوانین درون موجودیت آمده‌اند
    • «سفارش بدون آیتم» دیگر غیرممکن است.
    • هر افزودن آیتم، به‌طور خودکار مبلغ کل را به‌روزرسانی می‌کند.
    • EF-Core همچنان پشتیبانی می‌کند
    • EF-Core می‌تواند از طریق Field-only Mapping به فیلدهای خصوصی دسترسی داشته باشد.

    مثلاً در تنظیمات Fluent API:
    modelBuilder.Entity<Order>()
        .Property<decimal>("_totalAmount") // field-only property
        .HasColumnName("TotalAmount");
    
    modelBuilder.Entity<Order>()
        .Navigation(o => o.Items)
        .UsePropertyAccessMode(PropertyAccessMode.Field);
    جمع‌بندی
    • مدل آنمیک: موجودیتی شبیه یک DTO، ساده اما آسیب‌پذیر.
    • مدل غنی: موجودیتی که قوانین دامنه را در خود جای داده است.
    • Field-only Properties در EF-Core به ما اجازه می‌دهد همین غنای دامنه را پیاده کنیم، بدون این‌که EF-Core دچار مشکل شود.

    در ادامه بیاییم همین مثال «Order» را با یک Unit Test ساده کامل کنیم تا مشخص شود که چطور قوانین دامنه در عمل کار می‌کنند.

    تست واحد برای موجودیت غنی Order
    using Xunit;
    
    public class OrderTests
    {
        [Fact]
        public void Cannot_Create_Order_Without_Items()
        {
            // Arrange
            var emptyItems = new List<OrderItem>();
    
            // Act & Assert
            Assert.Throws<InvalidOperationException>(() => new Order(emptyItems));
        }
    
        [Fact]
        public void Creating_Order_Should_Calculate_TotalAmount()
        {
            // Arrange
            var items = new List<OrderItem>
            {
                new OrderItem("Book", 2, 50m),
                new OrderItem("Pen", 5, 10m)
            };
    
            // Act
            var order = new Order(items);
    
            // Assert
            Assert.Equal(2 * 50m + 5 * 10m, order.TotalAmount);
        }
    
        [Fact]
        public void Adding_Item_Should_Update_TotalAmount()
        {
            // Arrange
            var order = new Order(new List<OrderItem>
            {
                new OrderItem("Notebook", 1, 100m)
            });
    
            var newItem = new OrderItem("Eraser", 3, 5m);
    
            // Act
            order.AddItem(newItem);
    
            // Assert
            Assert.Equal(100m + (3 * 5m), order.TotalAmount);
            Assert.Contains(order.Items, i => i.Name == "Eraser");
        }
    }

    کلاس ساده OrderItem
    برای این تست نیاز داریم یک کلاس کوچک هم برای OrderItem داشته باشیم:
    public class OrderItem
    {
        public string Name { get; }
        public int Quantity { get; }
        public decimal Price { get; }
    
        public OrderItem(string name, int quantity, decimal price)
        {
            if (string.IsNullOrWhiteSpace(name))
                throw new ArgumentException("نام آیتم نمی‌تواند خالی باشد.");
    
            if (quantity <= 0)
                throw new ArgumentException("تعداد باید بیشتر از صفر باشد.");
    
            if (price <= 0)
                throw new ArgumentException("قیمت باید بیشتر از صفر باشد.");
    
            Name = name;
            Quantity = quantity;
            Price = price;
        }
    }

    • تست اول: نشان می‌دهد که سفارش بدون آیتم ممنوع است.
    • تست دوم: نشان می‌دهد که مبلغ کل در زمان ساخت به‌طور خودکار محاسبه می‌شود.
    • تست سوم: نشان می‌دهد که هر افزودن آیتم، قوانین دامنه را فعال می‌کند (یعنی فقط از طریق متد AddItem می‌توان مقدار کل را تغییر داد).

    مزایا:
    • دیگر مثل مدل آنمیک نمی‌شود با set کردن پراپرتی‌ها همه‌چیز را دور زد.
    • تست‌ها به‌وضوح نشان می‌دهند که قوانین دامنه در خود مدل نهادینه شده‌اند.

    در ادامه سناریو را کامل‌تر می‌کنیم:
    • پیکربندی EF-Core با Field-only Properties
    • استفاده از دیتابیس InMemory برای تست انتها‌به‌انتها (Integration Test)

    ۱) پیکربندی Fluent API برای Order
    using Microsoft.EntityFrameworkCore;
    using Microsoft.EntityFrameworkCore.Metadata.Builders;
    
    public class OrderConfiguration : IEntityTypeConfiguration<Order>
    {
        public void Configure(EntityTypeBuilder<Order> builder)
        {
            builder.ToTable("Orders");
    
            builder.HasKey(o => o.Id);
    
            // Mapping به فیلد خصوصی
            builder.Property<decimal>("_totalAmount")
                   .HasColumnName("TotalAmount");
    
            // تنظیم رفتار دسترسی به مجموعه آیتم‌ها
            builder.Metadata
                   .FindNavigation(nameof(Order.Items))!
                   .SetPropertyAccessMode(PropertyAccessMode.Field);
    
            // رابطه یک به چند با OrderItem
            builder.HasMany<OrderItem>()
                   .WithOne()
                   .HasForeignKey("OrderId");
        }
    }

    ۲) کلاس DbContext
    using Microsoft.EntityFrameworkCore;
    
    public class OrderingContext : DbContext
    {
        public OrderingContext(DbContextOptions<OrderingContext> options)
            : base(options) { }
    
        public DbSet<Order> Orders { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.ApplyConfiguration(new OrderConfiguration());
    
            modelBuilder.Entity<OrderItem>(builder =>
            {
                builder.ToTable("OrderItems");
                builder.HasKey(i => i.Id);
    
                builder.Property(i => i.Name).IsRequired();
                builder.Property(i => i.Quantity).IsRequired();
                builder.Property(i => i.Price).IsRequired();
            });
        }
    }

    ۳) تکمیل OrderItem برای EF-Core
    public class OrderItem
    {
        public int Id { get; private set; }
        public string Name { get; private set; }
        public int Quantity { get; private set; }
        public decimal Price { get; private set; }
    
        private OrderItem() { } // برای EF-Core
    
        public OrderItem(string name, int quantity, decimal price)
        {
            if (string.IsNullOrWhiteSpace(name))
                throw new ArgumentException("نام آیتم نمی‌تواند خالی باشد.");
    
            if (quantity <= 0)
                throw new ArgumentException("تعداد باید بیشتر از صفر باشد.");
    
            if (price <= 0)
                throw new ArgumentException("قیمت باید بیشتر از صفر باشد.");
    
            Name = name;
            Quantity = quantity;
            Price = price;
        }
    }

    ۴) تست انتها به انتها با InMemory
    using Microsoft.EntityFrameworkCore;
    using Xunit;
    
    public class OrderIntegrationTests
    {
        private OrderingContext CreateContext()
        {
            var options = new DbContextOptionsBuilder<OrderingContext>()
                .UseInMemoryDatabase(Guid.NewGuid().ToString())
                .Options;
    
            return new OrderingContext(options);
        }
    
        [Fact]
        public void Can_Save_And_Load_Order_With_Items()
        {
            using (var context = CreateContext())
            {
                // Arrange
                var order = new Order(new List<OrderItem>
                {
                    new OrderItem("Book", 2, 50m),
                    new OrderItem("Pen", 5, 10m)
                });
    
                context.Orders.Add(order);
                context.SaveChanges();
            }
    
            using (var context = CreateContext())
            {
                // Act
                var order = context.Orders
                                   .Include(o => o.Items)
                                   .First();
    
                // Assert
                Assert.Equal(2 * 50m + 5 * 10m, order.TotalAmount);
                Assert.Equal(2, order.Items.Count);
            }
        }
    }
    📌 توضیحات:
    • Field-only Properties در EF-Core واقعاً کار می‌کنند؛ TotalAmount در جدول ذخیره شد، بدون این‌که set عمومی داشته باشد.
    • مجموعه آیتم‌ها با PropertyAccessMode.Field به فیلد خصوصی _items نگاشت شد.
    • تست InMemory نشان داد که قوانین دامنه، حتی بعد از ذخیره و بازیابی از دیتابیس، پابرجا می‌مانند.
  • وحید نصیری در ۱۴۰۴/۰۶/۰۵ ۱۴:۲۹
    سؤال مهم: «چه زمانی Field-only بهتر است و چه زمانی private set کافی است؟»

    بیاییم با هم این موضوع را باز کنیم.

    private set properties؛ ویژگی‌هایی مثل:
    public string Name { get; private set; }
    مزایا:
    • کد ساده‌تر و خواناتر است.
    • EF-Core بدون پیکربندی خاصی به‌راحتی آن‌ها را نگاشت می‌کند.
    • برای بیشتر سناریوهای روزمره (مثل Name, Email, CreatedAt) کفایت می‌کند.
    محدودیت‌ها:
    • همچنان از بیرون قابل دسترسی خواندن هستند.
    • اگر یک Property مجموعه‌ای (Collection) باشد، حتی با private set، خود لیست قابل دستکاری است:
    public List<OrderItem> Items { get; private set; } = new();
    // بیرون می‌تواند Items.Add(...) کند، بدون اینکه قوانین دامنه اجرا شود

    Field-only Properties؛ ویژگی‌هایی که با فیلد خصوصی نگهداری می‌شوند و بیرون فقط یک نمای کنترل‌شده می‌بیند:
    private readonly List<OrderItem> _items = new();
    private decimal _totalAmount;
    
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
    public decimal TotalAmount => _totalAmount;
    مزایا:
    • دسترسی بیرونی کاملاً کنترل‌شده است.
    • EF-Core می‌تواند مقداردهی و نگاشت را به فیلد انجام دهد.
    • برای سناریوهایی که قوانین دامنه حساس‌اند (مثل محاسبه‌ی مبلغ کل سفارش، یا مدیریت وضعیت‌ها) بسیار مفید است.
    محدودیت‌ها:
    • نیازمند پیکربندی بیشتر در EF-Core (PropertyAccessMode.Field).
    • کد کمی پیچیده‌تر و پرجزئیات‌تر می‌شود.
    • برای ویژگی‌های ساده ممکن است استفاده از آن بیش‌ازحد مهندسی (Overengineering) باشد.

    راهنمای عملی انتخاب
    • ویژگی‌های ساده و بدون منطق خاص (نام، کد ملی، تاریخ ایجاد)
    • private set کافی است.
    • ویژگی‌های مشتق‌شده یا محاسباتی که نباید بیرون دستی تغییر کنند (مثل TotalAmount)
    • ➝ Field-only بهتر است.
    • مجموعه‌ها (Collections)
    • ➝ تقریباً همیشه Field-only (چون اگر List یا HashSet را مستقیم بیرون بدهی، قوانین دامنه دور زده می‌شوند).
    • موجودیت‌هایی که قوانین پیچیده یا Invariantهای حساس دارند
    • ➝ Field-only توصیه می‌شود.

    جمع‌بندی
    • اگر فقط می‌خواهی یک پنجره شیشه‌ای داشته باشی که بیرون بتواند داخل را ببیند اما نه دستکاری کند → private set.
    • اگر می‌خواهی پرده‌های کنترلی داشته باشی که فقط بخش‌هایی از داخل دیده شوند و فقط خودت تصمیم بگیری چه چیزی قابل تغییر باشد → Field-only.

    جدول تفاوت‌های اصلیprivate set و Field-only Properties

    ویژگیprivate set Field-only Properties
    سادگی کدساده‌تر، بدون نیاز به پیکربندی اضافیپیچیده‌تر، نیازمند پیکربندی Fluent API
    دسترسی از بیرونقابل خواندن کامل از بیرونقابل کنترل دقیق (می‌توان فقط بخشی را نمایان کرد)
    نگاشت در EF-Coreبه صورت پیش‌فرض پشتیبانی می‌شودنیازمند PropertyAccessMode.Field یا نگاشت به فیلد
    مناسب برایمقادیر ساده (Name, Email, CreatedAt)مقادیر محاسباتی (TotalAmount)، مجموعه‌ها (Items) و منطق‌های حساس
    خطر دور زدن قوانین دامنهبالاتر (مثلاً تغییر مستقیم Collection)بسیار کم (فقط از طریق متدهای دامنه قابل تغییر است)
    خوانایی کد برای تیم‌های تازه‌کاربیشتر و راحت‌ترنیازمند توضیح و آموزش بیشتر
    میزان انطباق با مدل غنی DDDمتوسطبالا