عنوان:

‫Domain-Driven Design (DDD)؛ بخش هفتم: Domain Events


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۰۲ ۰۸:۱۰
آدرس: www.dntips.ir
Domain Events: داستان‌سرایی از تغییرات مهم در دامنه 📢

تا به حال به این فکر کرده‌اید که چگونه بخش‌های مختلف یک سیستم بزرگ از تغییرات یکدیگر آگاه می‌شوند؟
در یک سیستم فروشگاهی، وقتی یک سفارش (Order) پرداخت می‌شود، اتفاقات زیادی باید رخ دهد:
  • موجودی انبار باید به‌روز شود.
  • یک ایمیل تأیید برای مشتری ارسال شود.
  • یک اعلان برای بخش ارسال کالا ارسال شود.
اگر تمام این منطق را در یک متد واحد قرار دهیم، آن متد به زودی به یک "کلاس بزرگ" و غیرقابل مدیریت تبدیل می‌شود. اینجاست که Domain Events (رویدادهای دامنه) وارد میدان می‌شوند.

Domain Event چیست؟

یک Domain Event، چیزی است که در گذشته رخ داده است و برای کسب‌وکار اهمیت دارد. این رویداد، یک خبررسان از یک تغییر مهم در دامنه است.
به جای اینکه بخش‌های مختلف سیستم به صورت مستقیم به یکدیگر وابسته باشند، آن‌ها به رویدادها گوش می‌دهند.
مثال: به جای اینکه کلاس Order به صورت مستقیم کلاس InventoryService و EmailService را صدا بزند، یک رویداد به نام OrderPaidEvent منتشر می‌کند.
public class Order
{
    public void Pay()
    {
        // ... Logic for payment ...
        this.Status = OrderStatus.Paid;

        // Publish a domain event
        AddDomainEvent(new OrderPaidEvent(this.OrderId));
    }
}
در این مدل، کلاس Order فقط به کاری که خودش باید انجام دهد (پرداخت) تمرکز دارد. این کلاس هیچ اطلاعی از اینکه چه بخش‌های دیگری به این تغییر نیاز دارند، ندارد.

چگونه به رویدادها گوش می‌دهیم؟

بخش‌های دیگر سیستم، که به آن‌ها Handler یا Listener می‌گویند، به این رویدادها گوش می‌دهند و کارهای مربوط به خود را انجام می‌دهند.
  • InventoryHandler: به رویداد OrderPaidEvent گوش می‌دهد و موجودی کالا را کم می‌کند.
  • EmailHandler: به رویداد OrderPaidEvent گوش می‌دهد و یک ایمیل تأیید برای مشتری ارسال می‌کند.
این رویکرد به ما کمک می‌کند تا وابستگی‌ها را به شدت کاهش دهیم. اگر در آینده نیاز به اضافه کردن یک قابلیت جدید (مثلاً ارسال پیامک) داشته باشیم، فقط کافی است یک Handler جدید برای رویداد OrderPaidEvent بسازیم، بدون اینکه مجبور به تغییر در کلاس Order باشیم.

چرا Domain Events اهمیت دارند؟

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

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

نظرات

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

    تبدیل مدل مفهومی به ساختار EF-Core (Aggregate)
    ابتدا از Aggregate اصلی شروع می‌کنیم یعنی ChargeBill. سؤال ذهنی که می‌پرسیم:
    «برای اینکه ChargeBill نقش Aggregate Root داشته باشد، چه ویژگی‌ها و رفتارهایی باید در خودش نگه دارد؟»
    عناصر اصلی این Aggregate:
    عنصر نقشدلیل وجود
    Idشناسه یکتا (کلید اصلی)
    UnitIdشناسایی واحد مربوطه
    Amountمیزان مبلغ شارژ
    DueDateتاریخ سررسید
    IsPaidوضعیت پرداخت
    LatePenaltyجریمه (اگر ایجاد شده باشد)
    BehaviorsIssue, Pay, RegisterLatePayment
    تعریف Entity در EF-Core
    public class ChargeBill
    {
        public Guid Id { get; private set; }
        public Guid UnitId { get; private set; }
        public decimal Amount { get; private set; }
        public DateTime DueDate { get; private set; }
        public bool IsPaid { get; private set; }
        public decimal LatePenalty { get; private set; }
    
        private ChargeBill() { } // For EF
    
        public static ChargeBill Issue(Guid unitId, decimal amount, DateTime dueDate)
        {
            return new ChargeBill
            {
                Id = Guid.NewGuid(),
                UnitId = unitId,
                Amount = amount,
                DueDate = dueDate,
                IsPaid = false,
                LatePenalty = 0M
            };
        }
    
        public void Pay()
        {
            if (IsPaid)
                throw new InvalidOperationException("ChargeBill is already paid.");
    
            IsPaid = true;
            // raise event ChargeBillPaid
        }
    
        public void RegisterLatePayment(decimal penaltyAmount)
        {
            if (DateTime.UtcNow <= DueDate)
                throw new InvalidOperationException("Due date has not passed yet.");
    
            LatePenalty = penaltyAmount;
            // raise event LatePaymentRegistered
        }
    }
    💡 نکته: در EF-Core، خیلی راحت می‌توانیم این Aggregate را مستقیماً به جدول ChargeBills نگاشت کنیم. ولی چیزی که معماری‌محور است، منطق‌ها (Behaviors) هستند که در کلاس پیاده شده‌اند، نه صرفاً دیتا.

    DbContext (به عنوان Entry Point EF)
    public class BuildingContext : DbContext
    {
        public DbSet<ChargeBill> ChargeBills { get; set; }
    
        public BuildingContext(DbContextOptions<BuildingContext> options) 
            : base(options)
        {
        }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<ChargeBill>(entity =>
            {
                entity.HasKey(c => c.Id);
                entity.Property(c => c.Amount).IsRequired();
                entity.Property(c => c.DueDate).IsRequired();
            });
        }
    }

    در گام بعدی، قسمت رویدادها (Domain Events) را هم به مدل اضافه می‌کنیم تا حلقه DDD کامل شود. در این مثال، دو رویداد مهم داریم:

    رویدادزمانیکه رخ می‌دهد
    ChargeBillIssuedهنگام ایجاد قبض جدید
    ChargeBillPaidبعد از پرداخت قبض
    ۱. تعریف کلاس‌های Event
    public record ChargeBillIssued(Guid ChargeBillId, Guid UnitId, decimal Amount, DateTime DueDate);
    
    public record ChargeBillPaid(Guid ChargeBillId, Guid UnitId, decimal Amount, DateTime PaidAt);

    ۲. اضافه کردن Eventها داخل Aggregate
    public class ChargeBill
    {
        private readonly List<object> _domainEvents = new();
        public IReadOnlyCollection<object> DomainEvents => _domainEvents.AsReadOnly();
    
        public Guid Id { get; private set; }
        public Guid UnitId { get; private set; }
        public decimal Amount { get; private set; }
        public DateTime DueDate { get; private set; }
        public bool IsPaid { get; private set; }
        public decimal LatePenalty { get; private set; }
    
        private ChargeBill(){}
    
        public static ChargeBill Issue(Guid unitId, decimal amount, DateTime dueDate)
        {
            var chargeBill = new ChargeBill
            {
                Id = Guid.NewGuid(),
                UnitId = unitId,
                Amount = amount,
                DueDate = dueDate,
                IsPaid = false,
                LatePenalty = 0
            };
    
            chargeBill.AddDomainEvent(
                new ChargeBillIssued(chargeBill.Id, chargeBill.UnitId, chargeBill.Amount, chargeBill.DueDate));
    
            return chargeBill;
        }
    
        public void Pay()
        {
            if (IsPaid)
                throw new InvalidOperationException("ChargeBill is already paid.");
    
            IsPaid = true;
    
            AddDomainEvent(
                new ChargeBillPaid(this.Id, this.UnitId, this.Amount, DateTime.UtcNow));
        }
    
        private void AddDomainEvent(object @event)
        {
            _domainEvents.Add(@event);
        }
    
        public void ClearDomainEvents() => _domainEvents.Clear();
    }

    پس از اینکار، یک Dispatcher ساده را اضافه می‌کنیم تا بعد از ذخیره شدن تغییرات در EF-Core، رویدادها منتشر شوند. ایده خیلی سرراست است:
    DbContext پس از SaveChanges()، به Aggregateها نگاه می‌کند، Eventها را جمع می‌کند و به یک Dispatcher تحویل می‌دهد.

    ۱. ایجاد یک اینترفیس عمومی برای Dispatcher
    public interface IDomainEventDispatcher
    {
        Task DispatchAsync(object domainEvent);
    }

    ۲. یک Dispatcher ساده (مثلاً با استفاده از MediatR یا بدون وابستگی)
    در این نمونه، فعلاً بدون MediatR یک Dispatcher خیلی ساده می‌سازیم:
    public class SimpleDomainEventDispatcher : IDomainEventDispatcher
    {
        public async Task DispatchAsync(object domainEvent)
        {
            // در این نسخه ساده فقط Log می‌کنیم
            Console.WriteLine($"Domain Event: {domainEvent.GetType().Name} dispatched.");
            await Task.CompletedTask;
        }
    }

    ۳. override کردن متدSaveChangesAsyncدر DbContext
    public class BuildingContext : DbContext
    {
        private readonly IDomainEventDispatcher _dispatcher;
    
        public DbSet<ChargeBill> ChargeBills { get; set; }
    
        public BuildingContext(DbContextOptions<BuildingContext> options,
                               IDomainEventDispatcher dispatcher)
            : base(options)
        {
            _dispatcher = dispatcher;
        }
    
        public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
        {
            // تمام Aggregateهایی که DomainEvents دارند پیدا می‌کنیم
            var aggregates = ChangeTracker
                .Entries<ChargeBill>()
                .Where(x => x.Entity.DomainEvents.Any())
                .Select(x => x.Entity)
                .ToList();
    
            // SaveChanges معمولی
            var result = await base.SaveChangesAsync(cancellationToken);
    
            // بعد از ذخیره، همه Eventها را Dispatch می‌کنیم
            foreach (var aggregate in aggregates)
            {
                foreach (var domainEvent in aggregate.DomainEvents)
                    await _dispatcher.DispatchAsync(domainEvent);
    
                aggregate.ClearDomainEvents();
            }
    
            return result;
        }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<ChargeBill>(entity =>
            {
                entity.HasKey(c => c.Id);
            });
        }
    }

    اکنون بیاییم یک Handler واقعی برای رویداد ChargeBillPaid بسازیم که بعد از پرداخت، یک ایمیل (یا نوتیفیکیشن) ارسال کند. ایده ساده است:
    Dispatcher وقتی رویدادی از نوع ChargeBillPaid را تشخیص داد، آن را به یک Handler اختصاصی ارسال می‌کند.

    ۱. یک اینترفیس عمومی برای Event Handlerها
    public interface IDomainEventHandler<TEvent>
    {
        Task HandleAsync(TEvent domainEvent);
    }

    ۲. Handler اختصاصی برایChargeBillPaid
    public class ChargeBillPaidEmailHandler : IDomainEventHandler<ChargeBillPaid>
    {
        private readonly IEmailService _emailService;
    
        public ChargeBillPaidEmailHandler(IEmailService emailService)
        {
            _emailService = emailService;
        }
    
        public async Task HandleAsync(ChargeBillPaid domainEvent)
        {
            // در دنیای واقعی آدرس ایمیل مالک را از Database می‌گیرید
            var toEmail = "owner@example.com";
            var subject = "پرداخت قبض ثبت شد ✔️";
            var body    = $"قبض به مبلغ {domainEvent.Amount} در تاریخ {domainEvent.PaidAt:yyyy-MM-dd} پرداخت شد.";
    
            await _emailService.SendAsync(toEmail, subject, body);
        }
    }

    ۳. به‌روزرسانی Dispatcher برای پیدا کردن Handler مناسب
    public class SimpleDomainEventDispatcher : IDomainEventDispatcher
    {
        private readonly IServiceProvider _serviceProvider;
    
        public SimpleDomainEventDispatcher(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
        }
    
        public async Task DispatchAsync(object domainEvent)
        {
            var eventType = domainEvent.GetType();
            var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(eventType);
    
            // تمام Handlerهای ثبت‌شده برای این Event را دریافت می‌کنیم
            var handlers = _serviceProvider.GetServices(handlerType);
    
            foreach (var handler in handlers)
            {
                // HandleAsync را به صورت دینامیکی صدا می‌زنیم
                var method = handlerType.GetMethod("HandleAsync");
                var task   = (Task)method.Invoke(handler, new[] { domainEvent });
                await task;
            }
        }
    }

    نکته نهایی: در Program.cs (یا Startup)، فقط کافی‌ست Handler را نیز Register کنیم:
    builder.Services.AddScoped<IDomainEventDispatcher, SimpleDomainEventDispatcher>();
    builder.Services.AddScoped<IDomainEventHandler<ChargeBillPaid>, ChargeBillPaidEmailHandler>();
    builder.Services.AddScoped<IEmailService, FakeEmailService>();
    (و برای شروع می‌توان یک FakeEmailService نوشت که فقط Console.WriteLine می‌کند.)

  • وحید نصیری در ۱۴۰۴/۰۶/۰۲ ۰۹:۳۲
    حالا نسخه‌ی حرفه‌ای‌تر مثال فوق را با MediatR پیاده سازی می‌کنیم؛ تا از دیسپیچر دستی خلاص شویم و انتشار رویدادها، به‌صورت خودکار و type-safe انجام شوند.

    مرحله ۱: نصب پکیج
    ابتدا پکیج زیر را به پروژه اضافه می‌کنیم:
    dotnet add package MediatR
    dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

    مرحله ۲: Eventها را به Notification تبدیل می‌کنیم
    MediatR برای رویدادها از INotification استفاده می‌کند.
    using MediatR;
    
    public record ChargeBillIssued(Guid ChargeBillId, Guid UnitId, decimal Amount, DateTime DueDate) : INotification;
    
    public record ChargeBillPaid(Guid ChargeBillId, Guid UnitId, decimal Amount, DateTime PaidAt) : INotification;

    مرحله ۳: Handler برای ChargeBillPaid
    using MediatR;
    
    public class ChargeBillPaidEmailHandler : INotificationHandler<ChargeBillPaid>
    {
        private readonly IEmailService _emailService;
    
        public ChargeBillPaidEmailHandler(IEmailService emailService)
        {
            _emailService = emailService;
        }
    
        public async Task Handle(ChargeBillPaid notification, CancellationToken cancellationToken)
        {
            // آدرس ایمیل واقعی را معمولا از واحد یا مالک می‌گیریم
            var toEmail = "owner@example.com";
            var subject = "پرداخت قبض با موفقیت ثبت شد";
            var body    = $"قبض {notification.Amount} در تاریخ {notification.PaidAt:yyyy-MM-dd} پرداخت شد.";
    
            await _emailService.SendAsync(toEmail, subject, body);
        }
    }

    مرحله ۴: انتشار رویدادها از داخل DbContext
    در این نسخه، فقط کافی‌ست MediatR را تزریق کنیم و به‌جای یک Dispatcher دستی، این کار را به IMediator بسپاریم:
    public class BuildingContext : DbContext
    {
        private readonly IMediator _mediator;
    
        public BuildingContext(DbContextOptions<BuildingContext> options, IMediator mediator)
            : base(options)
        {
            _mediator = mediator;
        }
    
        public DbSet<ChargeBill> ChargeBills { get; set; }
    
        public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
        {
            var aggregates = ChangeTracker
                .Entries<ChargeBill>()
                .Where(entry => entry.Entity.DomainEvents.Any())
                .Select(entry => entry.Entity)
                .ToList();
    
            var result = await base.SaveChangesAsync(cancellationToken);
    
            foreach (var aggregate in aggregates)
            {
                foreach (var domainEvent in aggregate.DomainEvents)
                    await _mediator.Publish(domainEvent, cancellationToken);
    
                aggregate.ClearDomainEvents();
            }
    
            return result;
        }
    }

    مرحله ۵: ثبت MediatR در DI
    builder.Services.AddMediatR(cfg =>
        cfg.RegisterServicesFromAssembly(typeof(ChargeBillIssued).Assembly));
    
    builder.Services.AddScoped<IEmailService, FakeEmailService>();

    در ادامه بیاییم حلقه را با یک Command + Command Handler کامل کنیم (برای مثال PayChargeBill).
    ۱. تعریف Command
    public record PayChargeBillCommand(Guid ChargeBillId) : IRequest;
    چون می‌خواهیم با MediatR کار کنیم، Command از IRequest ارث‌بری می‌کند (یعنی یک عملیات که انتظار پاسخ خاصی ندارد).

    2. Command Handler
    public class PayChargeBillCommandHandler : IRequestHandler<PayChargeBillCommand>
    {
        private readonly BuildingContext _context;
    
        public PayChargeBillCommandHandler(BuildingContext context)
        {
            _context = context;
        }
    
        public async Task<Unit> Handle(PayChargeBillCommand request, CancellationToken cancellationToken)
        {
            var chargeBill = await _context.ChargeBills
                .FirstOrDefaultAsync(b => b.Id == request.ChargeBillId, cancellationToken);
    
            if (chargeBill is null)
                throw new InvalidOperationException("ChargeBill not found.");
    
            chargeBill.Pay(); // Behavior از داخل Aggregate
    
            await _context.SaveChangesAsync(cancellationToken);
    
            return Unit.Value;
        }
    }

    ۳. ثبت Handler در DI
    (نکته: MediatR خودش آن را به شرط حضور در اسمبلی پیدا می‌کند، اما اگر Assembly جدا دارید:)
    builder.Services.AddMediatR(cfg =>
        cfg.RegisterServicesFromAssembly(typeof(PayChargeBillCommandHandler).Assembly));

    ۴. فراخوانی از Application Layer
    (مثلاً در یک endpoint یا در یک Controller)
    [HttpPost("{id}/pay")]
    public async Task<IActionResult> PayChargeBill(Guid id, [FromServices] IMediator mediator)
    {
        await mediator.Send(new PayChargeBillCommand(id));
        return Ok();
    }

    حالا معماری کامل شد:
    Command ➝ Handler ➝ Aggregate Behavior ➝ Domain Event ➝ MediatR ➝ Notification Handler

    در ادامه نمودار جریان ساده (Flow Diagram) زیر، مسیر کامل درخواست تا انتشار رویداد را نشان می‌دهد:
    flowchart TD
        A[Client / Controller] --> B(Send PayChargeBillCommand)
        B --> C(MediatR)
        C --> D(PayChargeBillCommandHandler)
        D --> E[Aggregate: ChargeBill.Pay()]
        E --> F[Add DomainEvent (ChargeBillPaid)]
        F --> G(SaveChangesAsync در DbContext)
        G --> H[MediatR.Publish(DomainEvents)]
        H --> I(ChargeBillPaidEmailHandler)
        I --> J[ارسال Email یا Notification]

  • علی یگانه مقدم در ۱۴۰۴/۰۶/۱۱ ۱۹:۳۰
    کم شدن از موجودی رو جز رویدادها در نظر گرفتن، مشکلی ایجاد نمیکنه؟ بهتر نیست این کار در کامند اصلی انجام شود؟
    چه ضمانتی برای اجرای موفق رویداد و فوری کسر موجودی از انبار وجود دارد؛ حتی با الگویی مثل outbox می‌تواند این اتفاق در یک لوپ بیهوده گرفتار شود.
    • وحید نصیری در ۱۴۰۴/۰۶/۱۱ ۱۹:۴۱
      استفاده از Domain Events برای کم کردن موجودی، یک رویکرد کاملاً معتبر و صحیح در DDD است، اما همانطور که اشاره کردید، چالش‌هایی را نیز به همراه دارد که باید به دقت مدیریت شوند. بیایید به این موضوع بپردازیم و تضمین‌های لازم را بررسی کنیم.

      چرا کم کردن موجودی یک رویداد است، نه یک فرمان؟

      1. جداسازی مسئولیت‌ها:
      کلاس Order مسئولیت پردازش سفارش را دارد، نه مدیریت موجودی انبار. اگر منطق کم کردن موجودی را مستقیماً در متد ()Pay قرار دهیم، Order به طور مستقیم به سرویس مدیریت موجودی وابسته می‌شود. این کار اصل جداسازی (Decoupling) را نقض می‌کند.
      با استفاده از یک رویداد (OrderPaidEvent)، کلاس Order فقط یک "اعلامیه" منتشر می‌کند و دیگر به جزئیات اینکه چه کسی به این اعلامیه گوش می‌دهد، اهمیتی نمی‌دهد.

      2. انعطاف‌پذیری و مقیاس‌پذیری:
      با استفاده از رویدادها، می‌توانیم در آینده بدون تغییر در کلاس Order، قابلیت‌های جدیدی را اضافه کنیم. مثلاً ارسال پیامک تأیید، به‌روزرسانی گزارشات فروش یا حتی اطلاع‌رسانی به سیستم مالی. هر یک از این عملیات می‌توانند به OrderPaidEvent گوش دهند و کار خود را انجام دهند.

      3. مدیریت پیچیدگی:
      اگر کاهش موجودی نیاز به چک‌های اضافی داشته باشد (مثل چک کردن حداقل موجودی یا تریگر کردن سفارش جدید)، Event می‌تواند این منطق را توزیع کند و Command را ساده نگه دارد.


      معایب و مشکلات احتمالی:
      • تاخیر در اجرا (Latency): Eventها معمولاً asynchronous هستند؛ یعنی کاهش موجودی فوری اتفاق نمی‌افتد و ممکن است در صف پیام (Message Queue) منتظر بمانند. اگر نیاز به کسر فوری موجودی باشد (مثل جلوگیری از overselling در فروش آنلاین)، این مورد می‌تواند مشکل‌ساز شده و منجر به ناهماهنگی موقتی (Eventual Consistency) شود.
      • پیچیدگی بیشتر: اضافه کردن Event Handlerها و زیرساخت messaging (مثل Kafka یا RabbitMQ) سیستم را پیچیده‌تر می‌کند. اگر سیستم ساده باشد، این over-engineering حساب می‌شود.
      • ریسک شکست: اگر Event پردازش نشود (مثل مشکلات شبکه یا downtime سرویس)، موجودی کسر نشده و سیستم inconsistent می‌شود.
      پس، مشکل ایجاد می‌کند اگر سیستم‌ شما نیاز به عملیات synchronous و اتمیک داشته باشد؛ اما اگر معماری توزیع‌شده (Microservices) باشد، Eventها اغلب بهترین انتخاب هستند.


      آیا بهتر نیست این کار در Command اصلی انجام شود؟
      بله، در موارد ساده بهتر است: اگر کاهش موجودی بخش جدایی‌ناپذیر از Command اصلی باشد (مثل "PlaceOrder" که باید موجودی را چک و کسر کند)، مستقیماً در Command Handler پیاده‌سازی کنید. این کار:
      • فوری و اتمیک: همه چیز در یک Transaction دیتابیس اتفاق می‌افتد، بدون نیاز به messaging.
      • ساده‌تر: کد کمترو نقطه شکست کمتر.
      • مثال: در یک Monolith ساده، Command Handler می‌تواند موجودی را چک کند، کسر کند و اگر لازم باشد، Event منتشر کند (ولی کسر اصلی در Command باشد).
      اما نه همیشه: اگر سیستم توزیع‌شده باشد یا نیاز به اطلاع‌رسانی به bounded contextهای دیگر داشته باشید، Event بهتراست. ترکیبی هم می‌تواند باشد: کسر اولیه در Command، و Event برای اطلاع‌رسانی‌های بعدی.
      در نهایت، انتخاب، بستگی به context دامنه دارد. اگر کاهش موجودی "core" دامنه‌ است و نیاز به فوریت داشته باشد، آن‌را در Command نگه دارد؛ اگر "side effect" است، به Event منتقل کنید.


      چه تضمینی برای اجرای موفق رویداد وجود دارد؟

      اینجاست که چالش اصلی نمایان می‌شود. اگر اجرای رویداد یک فرآیند غیرهمزمان (Asynchronous) باشد، نمی‌توانیم تضمین کنیم که بلافاصله و با موفقیت انجام می‌شود. برای حل این مشکل، از الگوی Outbox Pattern استفاده می‌شود.

      الگوی Outbox
      Outbox یک جدول در دیتابیس است که رویدادها را قبل از انتشار در آن ذخیره می‌کنیم. مراحل اجرای یک رویداد به شکل زیر است:
      • در داخل همان تراکنش دیتابیس که وضعیت سفارش به "پرداخت شده" تغییر می‌کند، یک رکورد برای رویداد OrderPaidEvent در جدول Outbox ایجاد می‌شود.
      • با موفقیت تراکنش، هر دو تغییر (تغییر وضعیت سفارش و ایجاد رکورد رویداد) به صورت اتمی ذخیره می‌شوند.
      • سپس، یک سرویس مجزا (مثلاً یک Background Job) به جدول Outbox نگاه می‌کند.
      • این سرویس، رویدادهای ذخیره شده را می‌خواند و آن‌ها را به سرویس‌های دیگر (مثلاً سرویس مدیریت موجودی) ارسال می‌کند.
      • اگر انتشار رویداد با شکست مواجه شود، سرویس مجزا می‌تواند با تلاش مجدد (Retry) این کار را انجام دهد.

      ضمانت‌های کلیدی:
      • اتمیسیتی (Atomicity): استفاده از یک تراکنش واحد برای ذخیره تغییرات در مدل دامنه و ایجاد رکورد رویداد، تضمین می‌کند که یا هر دو موفق می‌شوند یا هیچکدام. این کار از "بودن در یک برزخ" (being in limbo) جلوگیری می‌کند.
      • تحمل‌پذیری خطا (Fault Tolerance): سرویس Background Job با مکانیزم تلاش مجدد (Retry) تضمین می‌کند که حتی در صورت شکست‌های موقت، رویداد در نهایت به مقصد می‌رسد.
      • ثبات نهایی (Eventual Consistency): با استفاده از این الگو، ما از Consistency فوری به سمت Consistency نهایی حرکت می‌کنیم. به این معنی که اگرچه تغییرات در لحظه در تمام سیستم اعمال نمی‌شوند، اما در نهایت به یک وضعیت سازگار می‌رسند. برای بسیاری از سناریوها مانند کم کردن موجودی انبار، این سطح از Consistency کاملاً قابل قبول است.

      بنابراین، با استفاده از الگوی Outbox، می‌توانید از مزایای Domain Events بهره‌مند شوید و در عین حال، تضمین کنید که رویدادها به صورت قابل اعتماد و در نهایت موفقیت‌آمیز به مقصد می‌رسند.

      • وحید نصیری در ۱۴۰۴/۰۶/۱۱ ۲۰:۲۲
        یک نمونه پیاده سازی Outbox Pattern

        Outbox Pattern یک راه حل قدرتمند برای تضمین اتمیسیتی (Atomicity) در سیستم‌های توزیع‌شده‌است. در اینجا، یک پیاده‌سازی ساده و در عین حال کامل از این الگو به همراه یک سرویس پس‌زمینه (Background Service) برای ارسال رویدادها در ASP.NET Core ارائه شده است.

        ۱. پیاده‌سازی جدول Outbox و Entity مربوط به آن
        ابتدا باید یک جدول در دیتابیس برای نگهداری رویدادها ایجاد کنیم. این جدول باید در همان DbContext اصلی که تغییرات دامنه را ذخیره می‌کند، قرار گیرد.

        OutboxMessage Entity
        این کلاس نماینده یک رکورد در جدول Outbox است.
        namespace OnlineStore.Infrastructure.Outbox
        {
            public class OutboxMessage
            {
                public Guid Id { get; private set; }
                public DateTime OccurredOnUtc { get; private set; }
                public string Type { get; private set; }
                public string Data { get; private set; }
                public DateTime? ProcessedOnUtc { get; private set; }
                public string Error { get; private set; }
        
                public OutboxMessage(Guid id, DateTime occurredOnUtc, string type, string data)
                {
                    this.Id = id;
                    this.OccurredOnUtc = occurredOnUtc;
                    this.Type = type;
                    this.Data = data;
                }
                
                public OutboxMessage() { } // Required for EF Core
            }
        }

        DbContext

        جدول OutboxMessages را به DbContext اضافه می‌کنیم.
        // In OrderingDbContext.cs
        using Microsoft.EntityFrameworkCore;
        using OnlineStore.Ordering.Domain;
        using OnlineStore.Infrastructure.Outbox; // Assuming Outbox is in Infrastructure
        
        namespace OnlineStore.Ordering.Infrastructure.Context
        {
            public class OrderingDbContext : DbContext
            {
                public DbSet<Order> Orders { get; set; }
                public DbSet<OutboxMessage> OutboxMessages { get; set; }
                
                // ... (other configurations) ...
            }
        }

        ۲. ذخیره‌سازی رویداد در زمان SaveChanges
        برای تضمین اتمیسیتی، باید رویداد را قبل از SaveChanges() به DbContext اضافه کنیم. این کار در متد SaveChanges بازنویسی‌شده انجام می‌شود.
        // In OrderingDbContext.cs
        using Newtonsoft.Json;
        using OnlineStore.Core.Domain;
        using OnlineStore.Infrastructure.Outbox;
        
        public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
        {
            // Capture all Domain Events from Entities
            var domainEvents = ChangeTracker
                .Entries<IAggregateRoot>()
                .SelectMany(e => e.Entity.DomainEvents)
                .ToList();
        
            // Add events to OutboxMessages
            foreach (var domainEvent in domainEvents)
            {
                var outboxMessage = new OutboxMessage(
                    Guid.NewGuid(),
                    DateTime.UtcNow,
                    domainEvent.GetType().Name,
                    JsonConvert.SerializeObject(domainEvent));
                
                this.OutboxMessages.Add(outboxMessage);
            }
            
            // Save changes to database (atomically)
            var result = await base.SaveChangesAsync(cancellationToken);
            
            // Clear domain events from entities
            domainEvents.ForEach(e => e.ClearEvents());
            
            return result;
        }

        نتیجه
        • کاهش موجودی همیشه در تراکنش اصلی و بلافاصله انجام می‌شود؛ بنابراین invariantها حفظ می‌شوند.
        • رویداد همیشه همراه با فرمان ثبت می‌شود؛ در نتیجه ناسازگاری داده‌ای پیش نمیاد.
        • انتشار Event ممکنه کمی تأخیر داشته باشد (چون Outbox async هست)، ولی هیچ‌وقت گم نمی‌شود.


        ۳. سرویس پس‌زمینه (Background Service)

        این سرویس مسئول خواندن مداوم رویدادها از جدول Outbox و ارسال آن‌ها به MediatR است.

        OutboxPublisherService
        using MediatR;
        using Microsoft.EntityFrameworkCore;
        using Microsoft.Extensions.DependencyInjection;
        using Microsoft.Extensions.Hosting;
        using Newtonsoft.Json;
        using OnlineStore.Ordering.Infrastructure.Context;
        
        namespace OnlineStore.Infrastructure.Outbox
        {
            public class OutboxPublisherService : BackgroundService
            {
                private readonly IServiceProvider _serviceProvider;
                private readonly ILogger<OutboxPublisherService> _logger;
        
                public OutboxPublisherService(IServiceProvider serviceProvider, ILogger<OutboxPublisherService> logger)
                {
                    _serviceProvider = serviceProvider;
                    _logger = logger;
                }
        
                protected override async Task ExecuteAsync(CancellationToken stoppingToken)
                {
                    while (!stoppingToken.IsCancellationRequested)
                    {
                        await ProcessOutboxMessages(stoppingToken);
                        await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); // Poll every 5 seconds
                    }
                }
        
                private async Task ProcessOutboxMessages(CancellationToken stoppingToken)
                {
                    using (var scope = _serviceProvider.CreateScope())
                    {
                        var dbContext = scope.ServiceProvider.GetRequiredService<OrderingDbContext>();
                        var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
        
                        var messages = await dbContext.OutboxMessages
                            .Where(m => m.ProcessedOnUtc == null)
                            .OrderBy(m => m.OccurredOnUtc)
                            .Take(100) // Process in batches
                            .ToListAsync(stoppingToken);
        
                        foreach (var message in messages)
                        {
                            try
                            {
                                var domainEventType = Type.GetType(message.Type);
                                var domainEvent = JsonConvert.DeserializeObject(message.Data, domainEventType);
        
                                await mediator.Publish(domainEvent, stoppingToken);
        
                                message.ProcessedOnUtc = DateTime.UtcNow;
                            }
                            catch (Exception ex)
                            {
                                _logger.LogError(ex, "Failed to process outbox message: {MessageId}", message.Id);
                                message.Error = ex.Message;
                            }
                        }
                        
                        await dbContext.SaveChangesAsync(stoppingToken);
                    }
                }
            }
        }

        ۴. پیکربندی در Startup

        در نهایت، OutboxPublisherService را به عنوان یک سرویس Hosted در Program.cs یا Startup.cs ثبت می‌کنیم.
        // In Program.cs
        // ... (other service registrations) ...
        
        // Register the Outbox Service
        builder.Services.AddHostedService<OutboxPublisherService>();
        
        var app = builder.Build();
        // ...

        با این پیاده‌سازی، هرگاه یک تغییر در Aggregate رخ دهد و یک Domain Event ایجاد شود، آن رویداد به صورت اتمی همراه با تغییرات دامنه در یک تراکنش ذخیره می‌شود. سپس، سرویس پس‌زمینه به صورت دوره‌ای، این رویدادها را از دیتابیس می‌خواند و آن‌ها را به Handlers مربوطه ارسال می‌کند. این رویکرد، تضمین Delivery (رساندن) رویداد را فراهم می‌کند، حتی در صورت بروز خطا در حین ارسال.
        Outbox تضمین می‌کند که "فرمان" و "رویداد" یا هر دو انجام شوند یا هیچ‌کدام. اما اجرای فوری Subscriberها تضمین نمی‌شود. بنابراین برای منطقی حیاتی (مثل جلوگیری از فروش بیش‌ازحد)، باید داخل همان Command/Transaction انجام شود و نه در Subscriber.
        • وحید نصیری در ۱۴۰۴/۰۶/۱۲ ۱۳:۵۷
          الگوی Outbox در Brighter V10

          الگوی Outbox راهی برای تضمین ارسال پیام با «حداقل یک بار» (at-least-once delivery) و حفظ یکپارچگی تراکنشی در سیستم‌های توزیع‌شده است. به‌جای ارسال مستقیم پیام به سامانه‌ی صف یا پیام‌رسان، پیام‌ها ابتدا در جدول SQL ذخیره می‌شوند و سپس پروسه‌ای جداگانه (Sweeper) آن‌ها را به مقصد ارسال می‌کند.

          نیازمندی‌ها و پکیج‌های مورد استفاده در V10
          برای استفاده از Outbox در PostgreSQL با Brighter V10، موارد زیر ضروری‌اند:
          • .NET 8+
          • پکیج‌های NuGet:
          • Paramore.Brighter.Outbox.Hosting ۱۰.۰.۰-preview.۸
          • Paramore.Brighter.Outbox.PostgreSql ۱۰.۰.۰-preview.۸
          • Paramore.Brighter.MessagingGateway.RMQ.Async ۱۰.۰.۰-preview.۸
          • Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection و .Hosting (برای DI و Generic Host)
          • Serilog.AspNetCore برای لاگینگ ساختاریافته

          مراحل کاربردی تنظیم Outbox در PostgreSQL
          ۱. ایجاد جدول Outbox
          شما مسئول ایجاد جدول outboxmessages در PostgreSQL هستید. Brighter این کار را خود انجام نمی‌دهد. مثال ساختار جدول:
          CREATE TABLE IF NOT EXISTS "outboxmessages" (
            Id bigserial PRIMARY KEY,
            MessageId character varying(255) UNIQUE NOT NULL,
            Topic character varying(255),
            MessageType character varying(32),
            Timestamp timestamptz,
            CorrelationId character varying(255),
            ReplyTo character varying(255),
            ContentType character varying(128),
            PartitionKey character varying(128),
            WorkflowId character varying(255),
            JobId character varying(255),
            Dispatched timestamptz,
            HeaderBag text,
            Body text,
            Source character varying(255),
            Type character varying(255),
            DataSchema character varying(255),
            Subject character varying(255),
            TraceParent character varying(255),
            TraceState character varying(255),
            Baggage text
          );
          این جدول شامل ستون‌های جدیدی مثل PartitionKey، WorkflowId، TraceParent و ... برای پشتیبانی از قابلیت‌های پیشرفته‌تر است.

          ۲. پیکربندی در DI (Dependency Injection)
          در فایل ثبت سرویس‌ها، Outbox برای PostgreSQL به‌صورت زیر ثبت می‌شود:
          var conn = new RelationalDatabaseConfiguration(connectionString, "brightertests", "outboxmessages");
          
          services
            .AddSingleton<IAmARelationalDatabaseConfiguration>(conn)
            .AddConsumers(opt => { … })
            .AddProducers(opt =>
            {
              opt.ConnectionProvider = typeof(PostgreSqlUnitOfWork);
              opt.TransactionProvider = typeof(PostgreSqlUnitOfWork);
              opt.Outbox = new PostgreSqlOutbox(outbox);
            });
          در اینجا:
          • ارتباط با پایگاه، فراهم‌کننده تراکنش و Outbox معرفی می‌شوند.
          • Brighter V10 از الگوی singleton برای Outbox استفاده می‌کند؛ بنابراین تراکنش‌های مشترک برای ارسال اتمیک چند پیام دیگر به‌سادگی امکان‌پذیر نیست.

          ۳. پیکربندی Sweeper
          Sweeper کار ارسال پیام‌های ذخیره‌شده در Outbox به مقصد (مثلا RabbitMQ) را انجام می‌دهد:
          .UseOutboxSweeper(opt => { opt.BatchSize = 10; });
          در محیط‌های چند ماشینه، باید مراقب باشید که تنها یک instance وظیفه‌ی Sweeping را برعهده داشته باشد، یا از distributed locking استفاده کنید.

          ۴. آرشیو Outbox
          برای نگهداری و آرشیو پیام‌های قدیمی، می‌توان از Outbox Archiver استفاده کرد:
          .UseOutboxArchiver<DbTransaction>(new NullOutboxArchiveProvider(), opt => opt.MinimumAge = TimeSpan.FromMinutes(1));
          این روش امکان حذف یا منتقل کردن پیام‌های قدیمی‌تر از حد مشخص را فراهم می‌کند.

          نتیجه‌گیری
          • برای اجرای Outbox در PostgreSQL با Brighter V10، حتما ابتدا جدول را با ساختار مناسب ایجاد کنید.
          • Singleton بودن Outbox نیاز به بازنگری در استراتژی تراکنش پیام‌ها دارد (مثلاً استفاده از GitHub Issue برای بحث درباره اتمیک بودن).
          • Sweeper و Archiver ابزارهایی حیاتی برای ارسال مطمئن و مدیریت پیام‌ها هستند.
          • قابلیت‌های جدید ستون‌ها و پیکربندی، به چشم‌انداز قابل رصد شدن و مقیاس‌پذیری افزوده است.