عنوان:

‫Domain-Driven Design (DDD)؛ بخش ششم: Aggregates


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۰۱ ۰۸:۱۰
آدرس: www.dntips.ir
Aggregates: مرزهای امنیت و ثبات در دنیای DDD 🔒

تا به حال در سیستمی کار کرده‌اید که تغییر در یک بخش کوچک، باعث به هم ریختن بخش دیگری از سیستم شود؟ این اتفاق به دلیل عدم وجود مرزهای مشخص در اطراف اشیاء رخ می‌دهد. در دنیای واقعی، اشیاء اغلب به صورت گروهی عمل می‌کنند. مثلاً در یک سیستم سفارش‌دهی آنلاین، یک "سفارش" از "جزئیات سفارش" (OrderItem) و "اطلاعات مشتری" تشکیل شده است.
اگر بتوانیم هر کدام از این اجزا را به صورت جداگانه تغییر دهیم، به زودی با یک وضعیت ناسازگار مواجه می‌شویم. مثلاً مبلغ کل سفارش با مجموع جزئیات سفارش همخوانی ندارد. اینجاست که Aggregates (تجمیع‌ها) و Aggregate Roots (ریشه‌های تجمیع) وارد میدان می‌شوند.

Aggregate چیست؟

یک Aggregate مجموعه‌ای از Entities (موجودیت‌ها) و Value Objects (اشیاء ارزشی) است که به عنوان یک واحد کامل و منسجم با هم عمل می‌کنند. این مجموعه دارای یک مرز Consistency (سازگاری) است. این یعنی هر تغییری که در داخل Aggregate اتفاق می‌افتد، باید کل مجموعه را در یک حالت سازگار نگه دارد.
به عبارت ساده، Aggregate یک "خانواده" از اشیاء است که نمی‌توانید هیچ یک از اعضای آن را به صورت جداگانه تغییر دهید.

Aggregate Root چیست؟

هر Aggregate باید یک Aggregate Root (ریشه تجمیع) داشته باشد. Aggregate Root همان نماینده اصلی و تنها نقطه ورود به Aggregate است.
  • تمام دسترسی‌ها به Aggregate از طریق Aggregate Root انجام می‌شود.
  • فقط Aggregate Root دارای یک هویت سراسری (Global Identity) است. موجودیت‌های داخل آن، هویت محلی (Local Identity) دارند و از بیرون Aggregate قابل دسترس نیستند.

بیایید با یک مثال این مفهوم را شفاف‌تر کنیم:
فرض کنید یک Aggregate "سفارش" (Order) داریم. این Aggregate از موارد زیر تشکیل شده است:
  • Aggregate Root: کلاس Order
  • Entities داخل Aggregate: کلاس OrderItem (جزئیات سفارش)
  • Value Objects داخل Aggregate: کلاس 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 قابل تغییر است. این کار تضمین می‌کند که قوانین تجاری، مانند به‌روزرسانی مبلغ کل سفارش، همیشه رعایت می‌شوند.

چرا Aggregate ها مهم هستند؟

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

به این فکر کنید: در پروژه شما، آیا اشیاء به صورت مستقل از هم تغییر داده می‌شوند؟ آیا هر تغییر کوچک، باعث به هم ریختن وضعیت کل سیستم می‌شود؟
DDD با معرفی Aggregate ها، به ما یک الگوی قدرتمند برای سازماندهی مدل دامنه ارائه می‌دهد که به حفظ ثبات و پایداری سیستم در برابر پیچیدگی‌های روزافزون کمک می‌کند.


معرفی سنگ بنای مدل‌های دامنه در DDD

در ادامه جهت درک بهتر کدهای آتی، تعاریف کلاس‌های پایه Entity و ValueObject و همچنین واسط IAggregateRoot را ارائه می‌دهیم. این کلاس‌ها و واسط‌ها به عنوان سنگ بنای مدل دامنه شما در DDD عمل می‌کنند.

۱. کلاس پایه Entity

این کلاس، هویت منحصربه‌فرد یک موجودیت را تضمین می‌کند. هر 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 یکسان، برابر در نظر گرفته شوند.

۲. کلاس پایه ValueObject

این کلاس، پایه اشیاء ارزشی را فراهم می‌کند. همانطور که پیش از این توضیح داده شد، این اشیاء هویت ندارند و بر اساس برابری مقادیرشان تعریف می‌شوند.
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 را پیاده‌سازی کرده و ویژگی‌هایی که برای مقایسه مهم هستند را بازگردانید.

۳. واسط IAggregateRoot

این واسط، یک نشانه (Marker Interface) است که در DDD برای شناسایی ریشه‌های تجمیع استفاده می‌شود. این واسط هیچ متد یا ویژگی‌ای ندارد و فقط به عنوان یک قرارداد برای جداسازی Aggregate ها عمل می‌کند.
namespace OnlineStore.Core.Domain
{
    public interface IAggregateRoot { }
}
توضیح: این واسط به تیم توسعه و ابزارهای آن‌ها کمک می‌کند تا به راحتی Aggregate ها را شناسایی کنند. این کار به اجرای قوانینی مانند "یک Repository فقط باید Aggregate Root را برگرداند" کمک می‌کند.

با استفاده از این تعاریف پایه، اکنون می‌توانید مدل دامنه خود را به صورت کامل و بر اساس اصول DDD پیاده‌سازی کنید.

نظرات

  • وحید نصیری در ۱۴۰۴/۰۶/۰۱ ۰۹:۱۰
    یک مثال عملی: قصد داریم با یک مثال کلاس‌محور، خطای رایج «مدل‌سازی ناکافی Aggregate» را ببینیم، آن را در EF Core بازتولید کنیم، پیامدش را لمس کنیم و سپس قدم‌به‌قدم آن‌را درمان کنیم.

    سناریو دام‌گستر: «سفارش و اقلام سفارش»
    • دامنه: فروش ساده
    • Aggregate Root: Order
    • Entity درون Aggregate است: OrderLine

    قاعده‌های بدیهی (Invariants):
    • مجموع مبلغ سفارش باید از مجموع اقلام محاسبه شود؛ دستی تغییر نمی‌کند.
    • همه اقلام باید هم‌ارز با ارز سفارش باشند.
    • مقدار هر قلم باید بیشتر از 0 باشد.
    • OrderLine فقط از طریق Order اضافه/حذف می‌شود.

    سؤال راهنما: اگر OrderLine را بیرون از Order تغییر دهیم، چه بر سر قواعد فوق می‌آید؟

    نسخه‌ی ضدالگو (Anti-Pattern): Aggregate سوراخ‌دار (نشتی دار)
    در این نسخه، هم 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();
    تأمل: چه کسی نگهبان وضعیت معتبر است؟ وقتی Aggregate Root نقش «دروازه‌بان» را بازی نکند، داده معتبر باقی نمی‌ماند.

    نسخه‌ی شسته‌رفته: Aggregate نفوذناپذیر (Encapsulated)
    • فقط 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) { }
    }

    نگاشت EF Core: احترام به کپسوله‌سازی
    • تنها 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;
        }
    }

    لمس خطا در عمل: تستی که باید «قرمز» شود
    تلاش برای نقض قاعده از بیرون باید با Exception شکست بخورد.
    [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های داخلی Aggregate
    • public set روی مجموعۀ اقلام یا ویژگی‌های حساس
    • محاسبۀ «Total» به‌صورت دستی در لایه‌های بیرونی

    بوی طراحی بهتر:
    • رفتارهای غنی روی Aggregate (AddLine, ChangeLineQuantity)
    • Value Objectها برای مفاهیم اتمیک (Money)
    • نگاشت EF به Backing Field و Owned Types

    پرسش‌هایی برای اندیشه:

    اگر لازم شد برخی گزارش‌ها مستقیم روی OrderLine فیلتر شوند چه؟
    • پاسخ: Read Model جداگانه (CQRS Read) که از View/Projection تغذیه شود؛ نه «باز کردن» Aggregate.

    «اگر تیم BI درخواست دسترسی مستقیم به OrderLine داد چه می‌کنید؟»
    • پاسخ: «یک Read Model اختصاصی یا Materialized View می‌سازم؛ دامنه را نمی‌شکنم.»

    «اگر Performance ایجاب کرد Lazy Loading فعال کنیم چه؟»
    • پاسخ: «باز هم مرز Aggregate را نمی‌شکنیم؛ Loadهای ضروری را صریحاً با Include یا Specification می‌آوریم.»

    چک‌لیست پایانی هنگام کدنویسی
    • تنها Aggregate Root در DbSet است.
    • مجموعه‌های داخلی با Backing Field نگاشت شده‌اند.
    • رفتارها (Methods) مالک اعمال روی داده‌اند؛ نه لایه‌های بیرونی.
    • Invariantها در یک‌جا (Aggregate/Entity) enforce شده‌اند.
    • مقادیر مشتق‌شده set عمومی ندارند.
    • گزارش‌گیری جدا از مدل نوشتاری (Read Model/CQRS).

    یک نکته: تفاوت «Validations بیرونی» و «Invariants دامنه»
    • Validationهای UI/API (مانند Required/Regex) محترم‌اند ولی کافی نیستند.
    • Invariantها باید در قلب دامنه (Aggregate/Entity) برقرار شوند؛ جایی که EF هم دورش نمی‌تواند بزند.
  • وحید نصیری در ۱۴۰۴/۰۶/۰۱ ۰۹:۳۳
    بررسی مثالی دیگر: در مثال قبلی، دیدیم که چگونه یک سبد خرید می‌تواند به دلیل طراحی نادرست، دچار "نشتی" شود و قوانین کسب‌وکار را زیر پا بگذارد. در ادامه، می‌خواهیم به سراغ یک سناریوی کاملاً متفاوت اما با همان چالش اساسی برویم: مدیریت یک پست وبلاگ (BlogPost) و نظرات (Comments) آن.
    اینجا هم قرار است با هم یک ضدالگو را کالبدشکافی کنیم، ببینیم چگونه می‌تواند باعث ایجاد وضعیت‌های نامعتبر شود و سپس، با اصول DDD و به کمک EF Core، آن را به یک ساختار قدرتمند و نفوذناپذیر تبدیل کنیم.

    سناریو دام‌گستر: چرخه حیات یک پست وبلاگ

    یک پست وبلاگ را تصور کنید. این پست می‌تواند در وضعیت‌های مختلفی باشد: پیش‌نویس (Draft)، منتشر شده (Published) یا بایگانی شده (Archived). در این سیستم، یک قانون کسب‌وکار بسیار مهم وجود دارد:
    "فقط برای پستی که «منتشر شده» است می‌توان نظر ثبت کرد."
    این قانون، یک Invariant یا "اصل پایدار" برای ماست (باید همیشه و تحت هر شرایطی برقرار باشد). منطقی هم به نظر می‌رسد؛ چرا باید بتوان برای یک پیش‌نویس که هنوز کسی آن را ندیده، نظر گذاشت؟
    در اینجا، BlogPost به همراه تمام Commentهایش، یک واحد مفهومی را تشکیل می‌دهد. پس، BlogPostAggregate Root ماست و هرگونه تعامل با نظرات (مثل افزودن یک نظر جدید) باید از طریق آن انجام شود تا از این قانون مهم محافظت کند.

    نسخه‌ی ضدالگو (Anti-Pattern): Aggregate سوراخ‌دار (نشتی دار)

    بیایید ببینیم یک توسعه‌دهنده که با عجله کد می‌نویسد، ممکن است این مدل را چگونه پیاده‌سازی کند:
    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); // خروجی: 1
    این کد مثل این است که یک روزنامه‌نگار، یادداشت‌های شخصی و منتشر نشده‌ی شما را بردارد و در حاشیه آن چیزی بنویسد. این یادداشت‌ها هنوز عمومی نشده‌اند و نباید هیچ تعاملی با دنیای بیرون داشته باشند.

    پیامدهای واقعی

    این طراحی "نشت‌دار" می‌تواند مشکلات جدی در دنیای واقعی ایجاد کند:
    • نمایش نظرات نامرتبط: ممکن است در بخشی از UI، به اشتباه نظرات یک پست پیش‌نویس نمایش داده شود و باعث سردرگمی کاربران شود.
    • از دست رفتن منطق کسب‌وکار: شاید در آینده بخواهیم هنگام افزودن نظر، یک ایمیل اطلاع‌رسانی برای نویسنده پست ارسال کنیم. اگر منطق افزودن نظر در سراسر برنامه پخش شده باشد، اعمال این تغییر جدید بسیار دشوار و خطازا خواهد بود.
    • داده‌های ناسازگار: تصور کنید پستی حذف می‌شود اما نظرات آن به دلیل دستکاری مستقیم، در سیستم باقی می‌مانند و به یک پست یتیم تبدیل می‌شوند.

    نسخه‌ی شسته‌رفته: Aggregate نفوذناپذیر (Encapsulated)

    وقت آن است که این ساختار شکننده را به یک قلعه محتوای استوار تبدیل کنیم. با چند تغییر کلیدی، کنترل را به دست Aggregate Root برمی‌گردانیم.
    public 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("کاربر مشتاق", "منتظر انتشار این پست هستم!");
        });
    }
    • در نسخه نشت‌دار: این تست شکست می‌خورد (Fail)، چون هیچ مکانیزمی جلوی افزودن نظر به پیش‌نویس را نمی‌گرفت و هیچ استثنایی پرتاب نمی‌شد.
    • در نسخه نفوذناپذیر: این تست با افتخار پاس می‌شود (Pass)، زیرا متد AddComment به درستی وضعیت پست را بررسی کرده و InvalidOperationException را پرتاب می‌کند. این دقیقاً همان رفتار مورد انتظار ماست.

    جمع‌بندی ذهنی

    بیایید یک بار دیگر یافته‌هایمان را مرور کنیم:
    • Aggregate یک نگهبان هویت و قوانین است. هویت BlogPost به وضعیت آن (پیش‌نویس، منتشر شده) گره خورده و قوانین آن (مثل شرایط نظردهی) باید توسط خودش محافظت شوند.
    • متدهای Aggregate باید داستان کسب‌وکار را تعریف کنند. نام‌هایی مانند Publish یا AddComment بسیار معنادارتر از SetStatus یا GetComments().Add() هستند. آن‌ها قصد و نیت را بیان می‌کنند.
    • کپسوله‌سازی، فقط پنهان کردن داده‌ها نیست؛ بلکه حفاظت از اعتبار و سلامت آنهاست.

    بوی ضدالگوها:

    این رایحه‌ها باید زنگ خطر را برای شما به صدا درآورند:
    • یک کلاس BlogPostService که منطق بررسی وضعیت پست قبل از افزودن نظر را در خود دارد. (این منطق باید داخل BlogPost باشد!)
    • ارائه کلکسیون‌های داخلی با public List یا public ICollection.
    • متدهای عمومی که فقط داده‌ها را تنظیم می‌کنند بدون اینکه هیچ قانون کسب‌وکاری را اجرا کنند (Anemic Domain Model).

    بوی طراحی بهتر:

    و این رایحه‌ها نشان از یک طراحی سالم و قوی دارند:
    • کلاس BlogPost خودش می‌تواند تصمیم بگیرد که آیا نظری پذیرفته می‌شود یا خیر.
    • عدم وجود set های عمومی برای پراپرتی‌هایی که منطق کسب‌وکاری پیچیده‌ای دارند (مثل Status یا Comments).
    • هر متد عمومی، Aggregate را در یک وضعیت کاملاً معتبر رها می‌کند.

    چک‌لیست پایانی هنگام کدنویسی

    دفعه بعدی که در حال طراحی یک مفهوم پیچیده در دامنه‌تان هستید، این سوالات را از خود بپرسید:
    • [ ] آیا این مجموعه‌ای از کلاس‌ها یک واحد مفهومی و تراکنشی را تشکیل می‌دهند؟ (پس یک Aggregate هستند)
    • [ ] آیا دروازه اصلی (Aggregate Root) را به درستی شناسایی کرده‌ام؟
    • [ ] آیا تمام راه‌های نفوذ به داخل Aggregate را بسته‌ام و فقط از طریق متدهای کنترل‌شده اجازه تعامل می‌دهم?
    • [ ] آیا متدهای من، بیانگر زبان کسب‌وکار (Ubiquitous Language) هستند؟
    • [ ] آیا قوانین پایدار (Invariants) در داخل خود Aggregate محافظت می‌شوند، حتی اگر یک توسعه‌دهنده دیگر سعی در نقض آن‌ها داشته باشد؟

    با هر بار طراحی آگاهانه یک Aggregate، شما نه تنها یک قطعه کد، بلکه یک نگهبان قابل اعتماد برای قوانین کسب‌وکارتان می‌سازید که در طول زمان، استوار و قابل فهم باقی خواهد ماند.