3️⃣ Violation of Single Responsibility (SRP)
❗️ شما ممکن است استدلال کنید که چون ما از interfaceها (مثل IEmailService) استفاده میکنیم، decoupled هستیم. این برای جزئیات پیادهسازی درست است، اما برای orchestration اشتباه است.
UserService در حال حاضر دو دلیل برای تغییر دارد:
• Core Domain Logic:
"اکنون علاوه بر email به username نیز نیاز داریم."
• Notification Policy:
"تیم Marketing میخواهد علاوه بر Email یک SMS هم ارسال شود."
UserService
باید فقط مسئول state change باشد (ایجاد کاربر).نباید مسئول orchestrating کردن side effectها باشد.
🔥 این نقض کامل اصل Single Responsibility است.
Level 1: Logical Decoupling with Domain Events
اولین گام برای رفع مشکل، معکوسکردن کنترل است.
به جای اینکه UserService به سرویسهای دیگر دستور بدهد چه کنند، فقط اعلام میکند که چه اتفاقی افتاده است.
برای این کار از Domain Events استفاده میکنیم.
در اینجا نسخهٔ refactor شدهٔ UserService را میبینید:
public class UserService(
IUserRepository userRepository,
IDomainEventDispatcher dispatcher,
IUnitOfWork unitOfWork)
{
public async Task RegisterUser(string email, string password)
{
// 1. Create the User Entity
var user = new User(email, password);
// 2. Capture the side effect as an event object
var userRegisteredEvent = new UserRegisteredEvent(user.Id, user.Email);
// 3. Add the entity to the repository
await userRepository.AddAsync(user);
// 4. Dispatch the event (Assuming in-process dispatching here for simplicity)
// Note: Handlers for Email and Analytics are now completely separate classes.
await dispatcher.Dispatch(userRegisteredEvent);
await unitOfWork.SaveChangesAsync();
}
}
اکنون UserService پایدار است.
اگر فردا بخواهیم یک قابلیت جدید مثل "Loyalty Points" اضافه کنیم، این متد هیچ تغییری نمیکند.
فقط یک handler جدید برای UserRegisteredEvent اضافه میکنیم.
اما هنوز مشکل reliability حل نشده. اگر سیستم دقیقاً بعد از Dispatch ولی قبل از SaveChangesAsync کرش کند چه؟
ممکن است email ارسال شود اما user ذخیره نشود.
یا برعکس: user ذخیره شود اما event از دست برود.
Level 2: Reliability with the Outbox Pattern
برای رفع این مشکل، ما به Atomicity نیاز داریم.Atomicity یعنی مجموعهای از عملیات یا همه باهم موفق شوند یا همه باهم شکست بخورند.
باید تضمین کنیم که اگر User ذخیره شد، UserRegisteredEvent نیز ذخیره شود.
اینجاست که Outbox Pattern وارد میشود.
به جای ارسال مستقیم event به message bus، ما event را در یک جدول OutboxMessages ذخیره میکنیم.
و این کار در همان transaction ذخیرهٔ user انجام میشود.
اینجا پیادهسازی کامل logic را میبینید:
public async Task RegisterUser(string email, string password)
{
// 1. Create the Domain Event
var user = new User(email, password);
var domainEvent = new UserRegisteredEvent(user.Id, user.Email);
// 2. Open a Transaction
using var transaction = dbContext.Database.BeginTransaction();
try
{
// 3. Save the User to the Users Table
dbContext.Users.Add(user);
// 4. Serialize the Event and Save to Outbox Table
var outboxMessage = new OutboxMessage
{
Id = Guid.NewGuid(),
Type = nameof(UserRegisteredEvent),
Content = JsonSerializer.Serialize(domainEvent),
OccurredOn = DateTime.UtcNow,
ProcessedOn = null // Null means it hasn't been handled yet
};
dbContext.OutboxMessages.Add(outboxMessage);
// 5. Commit BOTH changes atomically
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
اکنون یک background worker (در یک process جدا) جدول OutboxMessages را poll میکند.
پیام را برداشته و آن را به message bus (RabbitMQ، Azure Service Bus و...) publish میکند.
اگر email service از کار بیفتد؟worker دوباره تلاش میکند.با این روش به At-Least-Once Delivery رسیدهایم.