Domain-Driven Design (DDD)؛ بخش هفتم: Domain Events
نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۶/۰۲ ۰۸:۱۰
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
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 فقط به کاری که خودش باید انجام دهد (پرداخت) تمرکز دارد. این کلاس هیچ اطلاعی از اینکه چه بخشهای دیگری به این تغییر نیاز دارند، ندارد.OrderPaidEvent گوش میدهد و موجودی کالا را کم میکند.OrderPaidEvent گوش میدهد و یک ایمیل تأیید برای مشتری ارسال میکند.OrderPaidEvent بسازیم، بدون اینکه مجبور به تغییر در کلاس Order باشیم.«برای اینکه ChargeBill نقش Aggregate Root داشته باشد، چه ویژگیها و رفتارهایی باید در خودش نگه دارد؟»
| عنصر نقش | دلیل وجود |
Id | شناسه یکتا (کلید اصلی) |
UnitId | شناسایی واحد مربوطه |
Amount | میزان مبلغ شارژ |
DueDate | تاریخ سررسید |
IsPaid | وضعیت پرداخت |
LatePenalty | جریمه (اگر ایجاد شده باشد) |
| Behaviors | Issue, Pay, RegisterLatePayment |
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
}
}ChargeBills نگاشت کنیم. ولی چیزی که معماریمحور است، منطقها (Behaviors) هستند که در کلاس پیاده شدهاند، نه صرفاً دیتا.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();
});
}
}| رویداد | زمانیکه رخ میدهد |
ChargeBillIssued | هنگام ایجاد قبض جدید |
ChargeBillPaid | بعد از پرداخت قبض |
public record ChargeBillIssued(Guid ChargeBillId, Guid UnitId, decimal Amount, DateTime DueDate); public record ChargeBillPaid(Guid ChargeBillId, Guid UnitId, decimal Amount, DateTime PaidAt);
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();
}DbContext پس از SaveChanges()، به Aggregateها نگاه میکند، Eventها را جمع میکند و به یک Dispatcher تحویل میدهد.public interface IDomainEventDispatcher
{
Task DispatchAsync(object domainEvent);
}public class SimpleDomainEventDispatcher : IDomainEventDispatcher
{
public async Task DispatchAsync(object domainEvent)
{
// در این نسخه ساده فقط Log میکنیم
Console.WriteLine($"Domain Event: {domainEvent.GetType().Name} dispatched.");
await Task.CompletedTask;
}
}SaveChangesAsyncدر DbContextpublic 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);
});
}
}ChargeBillPaid بسازیم که بعد از پرداخت، یک ایمیل (یا نوتیفیکیشن) ارسال کند. ایده ساده است:Dispatcher وقتی رویدادی از نوع ChargeBillPaid را تشخیص داد، آن را به یک Handler اختصاصی ارسال میکند.public interface IDomainEventHandler<TEvent>
{
Task HandleAsync(TEvent domainEvent);
}ChargeBillPaidpublic 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);
}
}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 میکند.)dotnet add package MediatR dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
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;
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);
}
}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;
}
}builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(ChargeBillIssued).Assembly));
builder.Services.AddScoped<IEmailService, FakeEmailService>();PayChargeBill).public record PayChargeBillCommand(Guid ChargeBillId) : IRequest;
چون میخواهیم با MediatR کار کنیم، Command از IRequest ارثبری میکند (یعنی یک عملیات که انتظار پاسخ خاصی ندارد).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;
}
}builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(PayChargeBillCommandHandler).Assembly));[HttpPost("{id}/pay")]
public async Task<IActionResult> PayChargeBill(Guid id, [FromServices] IMediator mediator)
{
await mediator.Send(new PayChargeBillCommand(id));
return Ok();
}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]OrderPaidEvent در جدول Outbox ایجاد میشود.DbContext اصلی که تغییرات دامنه را ذخیره میکند، قرار گیرد.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
}
}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() به 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;
}Outbox و ارسال آنها به MediatR است.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);
}
}
}
}OutboxPublisherService را به عنوان یک سرویس Hosted در Program.cs یا Startup.cs ثبت میکنیم.// In Program.cs // ... (other service registrations) ... // Register the Outbox Service builder.Services.AddHostedService<OutboxPublisherService>(); var app = builder.Build(); // ...
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 برای لاگینگ ساختاریافته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 و ... برای پشتیبانی از قابلیتهای پیشرفتهتر است.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);
});.UseOutboxSweeper(opt => { opt.BatchSize = 10; });.UseOutboxArchiver<DbTransaction>(new NullOutboxArchiveProvider(), opt => opt.MinimumAge = TimeSpan.FromMinutes(1));