Domain-Driven Design (DDD)؛ بخش دهم: DDD با EF Core؛ قسمت دوم
نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۰۵ ۰۸:۱۰
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
DbContext اغلب به عنوان یک اتصال مستقیم به دیتابیس در نظر گرفته میشود. اما در دنیای Domain-Driven Design (DDD)، نقش آن بسیار عمیقتر و مهمتر است.DbContext تمام تغییرات (افزودن، بهروزرسانی و حذف) را تا زمانی که شما دستور SaveChanges() را صادر نکنید، در حافظه ردیابی میکند.DbContext دارای یک قابلیت قدرتمند به نام Change Tracker است. این ابزار به صورت هوشمند، وضعیت هر Entity که در حافظه قرار دارد را ردیابی میکند.DbContext بازیابی میکنید، Change Tracker شروع به نظارت بر آن میکند. اگر ویژگیهای آن Entity را تغییر دهید، Change Tracker متوجه میشود و این تغییرات را برای ذخیرهسازی آماده میکند.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 همیشه به درستی محاسبه میشود و هیچ کس نمیتواند آن را به صورت مستقیم دستکاری کند.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;
}
}DbContext پیکربندی کنید:protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Account>().Property(a => a._balance).HasColumnName("Balance");
}private set یا Field-only Properties برای کپسولهسازی منطق در کلاسهای خود استفاده کنید؟_balance به این شکل توسط EF Core شناسایی نمیشود و ستون مربوطه در دیتابیس ایجاد نخواهد شد.public class Order
{
public int Id { get; set; }
public decimal TotalAmount { get; set; }
public List<OrderItem> Items { get; set; } = new();
public Order() { }
}public set دارند.TotalAmount یا حتی Items را دستکاری کند.public باشد، مقادیر حساس را پشت فیلدهای خصوصی نگه میداریم.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 را مستقیم تغییر دهد.modelBuilder.Entity<Order>()
.Property<decimal>("_totalAmount") // field-only property
.HasColumnName("TotalAmount");
modelBuilder.Entity<Order>()
.Navigation(o => o.Items)
.UsePropertyAccessMode(PropertyAccessMode.Field);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 داشته باشیم: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 کردن پراپرتیها همهچیز را دور زد.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");
}
}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();
});
}
}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;
}
}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);
}
}
}TotalAmount در جدول ذخیره شد، بدون اینکه set عمومی داشته باشد._items نگاشت شد.public string Name { get; private set; }private set، خود لیست قابل دستکاری است:public List<OrderItem> Items { get; private set; } = new();
// بیرون میتواند Items.Add(...) کند، بدون اینکه قوانین دامنه اجرا شودprivate readonly List<OrderItem> _items = new(); private decimal _totalAmount; public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly(); public decimal TotalAmount => _totalAmount;
PropertyAccessMode.Field).private set کافی است.private set.private set و Field-only Properties | ویژگی | private set | Field-only Properties |
| سادگی کد | سادهتر، بدون نیاز به پیکربندی اضافی | پیچیدهتر، نیازمند پیکربندی Fluent API |
| دسترسی از بیرون | قابل خواندن کامل از بیرون | قابل کنترل دقیق (میتوان فقط بخشی را نمایان کرد) |
| نگاشت در EF-Core | به صورت پیشفرض پشتیبانی میشود | نیازمند PropertyAccessMode.Field یا نگاشت به فیلد |
| مناسب برای | مقادیر ساده (Name, Email, CreatedAt) | مقادیر محاسباتی (TotalAmount)، مجموعهها (Items) و منطقهای حساس |
| خطر دور زدن قوانین دامنه | بالاتر (مثلاً تغییر مستقیم Collection) | بسیار کم (فقط از طریق متدهای دامنه قابل تغییر است) |
| خوانایی کد برای تیمهای تازهکار | بیشتر و راحتتر | نیازمند توضیح و آموزش بیشتر |
| میزان انطباق با مدل غنی DDD | متوسط | بالا |