Domain-Driven Design (DDD)؛ بخش ششم: Aggregates
نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۰۱ ۰۸:۱۰
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
OrderOrderItem (جزئیات سفارش)ShippingAddress (آدرس ارسال)OrderItem و ShippingAddress باید از طریق کلاس Order انجام شود. شما نمیتوانید یک OrderItem را به صورت مستقیم از بیرون تغییر دهید.public class Order : IAggregateRoot
{
public int OrderId { get; private set; }
private readonly List<OrderItem> _orderItems = new List<OrderItem>();
public IReadOnlyList<OrderItem> OrderItems => _orderItems.AsReadOnly();
public void AddItem(Product product, int quantity)
{
// Add item and update total amount
}
}OrderItems به صورت خصوصی نگهداری میشود و تنها از طریق متدهای داخل کلاس Order قابل تغییر است. این کار تضمین میکند که قوانین تجاری، مانند بهروزرسانی مبلغ کل سفارش، همیشه رعایت میشوند.Entity و ValueObject و همچنین واسط IAggregateRoot را ارائه میدهیم. این کلاسها و واسطها به عنوان سنگ بنای مدل دامنه شما در DDD عمل میکنند.Entity با یک شناسه (ID) مشخص میشود که آن را از سایر موجودیتها متمایز میکند.namespace OnlineStore.Core.Domain
{
public abstract class Entity
{
public Guid Id { get; protected set; }
public override bool Equals(object obj)
{
if (obj == null || !(obj is Entity))
return false;
if (Object.ReferenceEquals(this, obj))
return true;
if (this.GetType() != obj.GetType())
return false;
Entity item = (Entity)obj;
if (item.IsTransient() || this.IsTransient())
return false;
else
return item.Id == this.Id;
}
public override int GetHashCode()
{
if (!IsTransient())
{
if (!_requestedHashCode.HasValue)
_requestedHashCode = this.Id.GetHashCode() ^ 31; // XOR for random distribution
return _requestedHashCode.Value;
}
else
{
return base.GetHashCode();
}
}
public bool IsTransient()
{
return this.Id == default(Guid);
}
private int? _requestedHashCode;
}
}Equals و GetHashCode به گونهای بازنویسی شدهاند که بر اساس شناسه (Id) عمل کنند. این تضمین میکند که دو شیء از یک نوع با ID یکسان، برابر در نظر گرفته شوند.namespace OnlineStore.Core.Domain
{
public abstract class ValueObject
{
protected static bool EqualOperator(ValueObject left, ValueObject right)
{
if (Object.Equals(left, null) ^ Object.Equals(right, null))
{
return false;
}
return Object.Equals(left, null) || left.Equals(right);
}
protected static bool NotEqualOperator(ValueObject left, ValueObject right)
{
return !(EqualOperator(left, right));
}
protected abstract IEnumerable<object> GetEqualityComponents();
public override bool Equals(object obj)
{
if (obj == null || obj.GetType() != this.GetType())
{
return false;
}
ValueObject other = (ValueObject)obj;
return this.GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x != null ? x.GetHashCode() : 0)
.Aggregate((x, y) => x ^ y);
}
}
}Equals و GetHashCode را بر اساس برابری مقادیر پیادهسازی میکند. شما فقط باید متد GetEqualityComponents را پیادهسازی کرده و ویژگیهایی که برای مقایسه مهم هستند را بازگردانید.namespace OnlineStore.Core.Domain
{
public interface IAggregateRoot { }
}OrderOrderLineOrderLine فقط از طریق Order اضافه/حذف میشود.OrderLine را بیرون از Order تغییر دهیم، چه بر سر قواعد فوق میآید؟DbSet از نوع Order را داریم و هم DbSet از نوع OrderLine؛ همچنین مجموعه اقلام، public set بوده و قابل دستکاری مستقیم است.// ضدالگو: مدل دامنه
public class Order
{
public Guid Id { get; set; }
public string Currency { get; set; } = default!; // "USD", "EUR", ...
public decimal Total { get; set; } // ضدالگو: set عمومی
public List<OrderLine> Lines { get; set; } = new(); // ضدالگو: set عمومی
}
public class OrderLine
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public string Sku { get; set; } = default!;
public int Quantity { get; set; } // ضدالگو: set عمومی
public decimal UnitPrice { get; set; } // ضدالگو: set عمومی
public string Currency { get; set; } = default!; // میتواند با Order فرق کند!
}
// ضدالگو: EF Core DbContext
public class SalesDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>(); // ضدالگو
public SalesDbContext(DbContextOptions<SalesDbContext> options) : base(options) { }
}// جای بد ماجرا var line = await db.OrderLines.FirstAsync(); line.Quantity = -5; // نقض قاعده 3 line.Currency = "EUR"; // نقض قاعده 2 await db.SaveChangesAsync(); // سفارش الان "نامعتبر" است var order = await db.Orders.Include(o => o.Lines).FirstAsync(); order.Total += 100; // نقض قاعده 1؛ محاسبه بیرون از Aggregate await db.SaveChangesAsync();
Order بهعنوان Aggregate Root در DbContext ثبت میشود.OrderLine از خارج قابل ساخت/تغییر نیست؛ فقط از طریق رفتارهای Order.Total مشتقشده (Derived) است و set عمومی ندارد.// مدل دامنه صحیح با کپسولهسازی
public class Order
{
private readonly List<OrderLine> _lines = new();
// برای EF Core
private Order() { }
public Order(string currency)
{
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
public Guid Id { get; private set; }
public string Currency { get; private set; }
// مقدار مشتقشده؛ هر بار از اقلام محاسبه میشود
public decimal Total => _lines.Sum(l => l.Subtotal);
// فقط خواندنی برای بیرون
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
// رفتارهای دروازهبان
public OrderLine AddLine(string sku, int quantity, Money unitPrice)
{
if (quantity <= 0) throw new DomainException("Quantity must be > 0");
if (unitPrice.Currency != Currency) throw new DomainException("Currency mismatch");
var line = OrderLine.Create(Id, sku, quantity, unitPrice);
_lines.Add(line);
return line;
}
public void RemoveLine(Guid lineId)
{
var line = _lines.FirstOrDefault(l => l.Id == lineId)
?? throw new DomainException("Line not found");
_lines.Remove(line);
}
public void ChangeLineQuantity(Guid lineId, int newQuantity)
{
var line = _lines.FirstOrDefault(l => l.Id == lineId)
?? throw new DomainException("Line not found");
line.ChangeQuantity(newQuantity); // خود Line هم نگهبان قاعده خودش است
}
}
public class OrderLine
{
// برای EF Core
private OrderLine() { }
private OrderLine(Guid orderId, string sku, int quantity, Money unitPrice)
{
Id = Guid.NewGuid();
OrderId = orderId;
Sku = sku ?? throw new ArgumentNullException(nameof(sku));
UnitPrice = unitPrice ?? throw new ArgumentNullException(nameof(unitPrice));
ChangeQuantity(quantity); // اعتبارسنجی متمرکز
}
public static OrderLine Create(Guid orderId, string sku, int quantity, Money unitPrice)
=> new(orderId, sku, quantity, unitPrice);
public Guid Id { get; private set; }
public Guid OrderId { get; private set; }
public string Sku { get; private set; } = default!;
public int Quantity { get; private set; }
public Money UnitPrice { get; private set; } = default!;
public decimal Subtotal => Quantity * UnitPrice.Amount;
public void ChangeQuantity(int newQuantity)
{
if (newQuantity <= 0) throw new DomainException("Quantity must be > 0");
Quantity = newQuantity;
}
}
// Value Object برای پول
public class Money
{
// برای EF Core
private Money() { }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new DomainException("Amount cannot be negative");
Amount = amount;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
public decimal Amount { get; private set; }
public string Currency { get; private set; }
}
public class DomainException : Exception
{
public DomainException(string message) : base(message) { }
}Order در DbSet قرار میگیرد.OrderLine بهعنوان Entity داخلی با Backing Field نگاشت میشود.Money بهصورت Owned Type نگاشت میشود.public class SalesDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>(); // فقط Aggregate Root
public SalesDbContext(DbContextOptions<SalesDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new OrderConfig());
modelBuilder.ApplyConfiguration(new OrderLineConfig());
modelBuilder.Owned<Money>(); // یا داخل Config مربوطه
}
}
public class OrderConfig : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
b.HasKey(o => o.Id);
b.Property(o => o.Currency).IsRequired().HasMaxLength(3);
// Lines: Backing Field
b.Navigation("Lines").Metadata.SetField("_lines"); // برای EF < 7 ممکن است متفاوت باشد
b.Metadata.FindNavigation(nameof(Order.Lines))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
b.HasMany<OrderLine>("_lines")
.WithOne()
.HasForeignKey(l => l.OrderId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public class OrderLineConfig : IEntityTypeConfiguration<OrderLine>
{
public void Configure(EntityTypeBuilder<OrderLine> b)
{
b.HasKey(l => l.Id);
b.Property(l => l.Sku).IsRequired().HasMaxLength(64);
b.OwnsOne(l => l.UnitPrice, mb =>
{
mb.Property(m => m.Amount).HasColumnType("decimal(18,2)").IsRequired();
mb.Property(m => m.Currency).HasMaxLength(3).IsRequired();
});
// جلوگیری از دستکاری مستقیم
b.Property(l => l.Quantity).UsePropertyAccessMode(PropertyAccessMode.FieldDuringConstruction);
}
}نکته: اگر از EF Core 7/8 استفاده میکنید،UsePropertyAccessModeو تنظیمBackingFieldرا دقیقاً مطابق نسخهتان اعمال کنید. ایدهی اصلی: کلکسیون فقط از طریق میدان خصوصی مدیریت شود.
Order کار میکند؛ خبری از OrderLineRepository نیست.public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
public class EfOrderRepository : IOrderRepository
{
private readonly SalesDbContext _db;
public EfOrderRepository(SalesDbContext db) => _db = db;
public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
_db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task AddAsync(Order order, CancellationToken ct = default)
=> await _db.Orders.AddAsync(order, ct);
public Task SaveChangesAsync(CancellationToken ct = default)
=> _db.SaveChangesAsync(ct);
}
// سرویس کاربردی: مرز تراکنش
public class PlaceOrderService
{
private readonly IOrderRepository _repo;
public PlaceOrderService(IOrderRepository repo) => _repo = repo;
public async Task<Guid> PlaceAsync(string currency, (string sku,int qty, decimal price)[] lines)
{
var order = new Order(currency);
foreach (var l in lines)
{
var money = new Money(l.price, currency);
order.AddLine(l.sku, l.qty, money);
}
await _repo.AddAsync(order);
await _repo.SaveChangesAsync();
return order.Id;
}
}[Fact]
public void ChangingLineQuantity_Directly_ShouldNotBePossible()
{
var order = new Order("USD");
var line = order.AddLine("ABC-1", 1, new Money(10, "USD"));
// بیرون از Aggregate هیچ set عمومی نداریم
// line.Quantity = -5; // کامپایل نمیشود
Assert.Throws<DomainException>(() => order.ChangeLineQuantity(line.Id, 0));
}DbSet برای Entityهای داخلی Aggregatepublic set روی مجموعۀ اقلام یا ویژگیهای حساسAddLine, ChangeLineQuantity)Money)OrderLine فیلتر شوند چه؟OrderLine داد چه میکنید؟»DbSet است.BlogPost به همراه تمام Commentهایش، یک واحد مفهومی را تشکیل میدهد. پس، BlogPostAggregate Root ماست و هرگونه تعامل با نظرات (مثل افزودن یک نظر جدید) باید از طریق آن انجام شود تا از این قانون مهم محافظت کند.public enum PostStatus { Draft, Published, Archived }
public class Comment
{
public Guid Id { get; private set; }
public string Author { get; private set; }
public string Text { get; private set; }
// سازنده برای EF Core
private Comment() { }
public Comment(string author, string text)
{
// ... اعتبارسنجیهای اولیه
Author = author;
Text = text;
}
}
public class BlogPost
{
public Guid Id { get; private set; }
public string Title { get; private set; }
public PostStatus Status { get; private set; }
// و دوباره همان حفره امنیتی آشنا!
public List<Comment> Comments { get; private set; } = new();
public BlogPost(string title)
{
Id = Guid.NewGuid();
Title = title;
Status = PostStatus.Draft; // هر پست به صورت پیشنویس شروع میشود
}
public void Publish()
{
if (Status == PostStatus.Draft)
{
Status = PostStatus.Published;
}
}
}public List. با اینکه set آن خصوصی است، اما خود لیست یک شیء زنده و قابل تغییر است. این یعنی هر بخشی از برنامه میتواند نگهبان ما (BlogPost) را دور بزند و مستقیماً به اتاق نظرات سرک بکشد!// کد در لایه Application یا Service
var post = new BlogPost("مقدمهای بر DDD");
// پست هنوز در وضعیت پیشنویس (Draft) است.
// Console.WriteLine(post.Status); // خروجی: Draft
// نقض مستقیم قانون کسبوکار!
// ما Aggregate Root را نادیده گرفتیم و مستقیماً نظر اضافه کردیم.
var sneakyComment = new Comment("یک کاربر ناشناس", "این پست عالیه!");
post.Comments.Add(sneakyComment);
// فاجعه! یک پست پیشنویس حالا نظر دارد.
// این داده نامعتبر میتواند در پایگاه داده ذخیره شود.
Console.WriteLine(post.Comments.Count); // خروجی: 1public class BlogPost
{
public Guid Id { get; private set; }
public string Title { get; private set; }
public PostStatus Status { get; private set; }
// 1. لیست داخلی را کاملاً خصوصی و غیرقابل دسترس میکنیم.
private readonly List<Comment> _comments = new();
// 2. یک نمای امن و فقط-خواندنی از نظرات را به بیرون ارائه میدهیم.
public IReadOnlyCollection<Comment> Comments => _comments.AsReadOnly();
public BlogPost(string title)
{
Id = Guid.NewGuid();
Title = title;
Status = PostStatus.Draft;
}
public void Publish()
{
if (Status != PostStatus.Draft)
{
throw new InvalidOperationException("فقط یک پیشنویس را میتوان منتشر کرد.");
}
Status = PostStatus.Published;
}
// 3. تنها راه افزودن نظر، این متد است که از قوانین ما محافظت میکند.
public void AddComment(string author, string text)
{
// اینجا دروازه و نگهبان ماست!
if (Status != PostStatus.Published)
{
throw new InvalidOperationException("امکان ثبت نظر برای پستی که منتشر نشده، وجود ندارد.");
}
var newComment = new Comment(author, text);
_comments.Add(newComment);
// اینجا میتوان کارهای دیگری هم انجام داد:
// مثلاً ارسال ایمیل به نویسنده
}
}List به _comments خصوصی تبدیل شد. دیگر هیچ راهی برای دسترسی مستقیم به آن از بیرون وجود ندارد.IReadOnlyCollection، به دیگران اجازه میدهیم نظرات را ببینند، اما هرگز نمیتوانند لیست را دستکاری کنند.AddComment تنها راه مجاز برای افزودن نظر است. این متد قبل از انجام هر کاری، وضعیت پست را بررسی میکند. او نگهبان وفادار قانون کسبوکار ماست.AddComment است که آن هم اجازه کار غیرقانونی را نمیدهد.[TestMethod]
public void Cannot_Add_Comment_To_A_Draft_Post()
{
// Arrange
// از نسخه نفوذناپذیر استفاده میکنیم
var post = new BlogPost("پست تستی");
// پست به صورت پیشفرض در وضعیت Draft است.
// Act & Assert
// انتظار داریم که تلاش برای افزودن نظر، با یک استثنا مواجه شود.
Assert.ThrowsException<InvalidOperationException>(() =>
{
post.AddComment("کاربر مشتاق", "منتظر انتشار این پست هستم!");
});
}AddComment به درستی وضعیت پست را بررسی کرده و InvalidOperationException را پرتاب میکند. این دقیقاً همان رفتار مورد انتظار ماست.BlogPost به وضعیت آن (پیشنویس، منتشر شده) گره خورده و قوانین آن (مثل شرایط نظردهی) باید توسط خودش محافظت شوند.Publish یا AddComment بسیار معنادارتر از SetStatus یا GetComments().Add() هستند. آنها قصد و نیت را بیان میکنند.BlogPostService که منطق بررسی وضعیت پست قبل از افزودن نظر را در خود دارد. (این منطق باید داخل BlogPost باشد!)public List یا public ICollection.BlogPost خودش میتواند تصمیم بگیرد که آیا نظری پذیرفته میشود یا خیر.set های عمومی برای پراپرتیهایی که منطق کسبوکاری پیچیدهای دارند (مثل Status یا Comments).