C# Geeks (.NET) – Telegram
⚖️ Domain Events در مقابل Integration Events

ممکن است قبلاً دربارهٔ Integration Events شنیده باشید و اکنون این سؤال برایتان پیش آمده باشد که تفاوت آن‌ها با Domain Events در چیست.

از نظر معنایی (Semantically)، هر دو یکی هستند:
نمایانگر چیزی‌اند که در گذشته اتفاق افتاده است.
اما هدف (Intent) آن‌ها متفاوت است — و درک این تفاوت اهمیت زیادی دارد.

🧩 Domain Events:

• درون یک Domain واحد منتشر و مصرف (Consume) می‌شوند.
• از طریق یک in-memory message bus ارسال می‌شوند.
• می‌توانند به‌صورت synchronous یا asynchronous پردازش شوند.

🔗 Integration Events:

• توسط زیرسیستم‌ها (مانند microservices یا Bounded Contexts) مصرف می‌شوند.
• از طریق message broker و روی queue ارسال می‌شوند.
• به‌صورت کاملاً asynchronous پردازش می‌شوند.

💡 بنابراین، اگر نمی‌دانید چه نوع Eventی باید منتشر کنید، به هدف (Intent) و اینکه چه کسی باید آن را مدیریت کند فکر کنید.

در واقع، Domain Events می‌توانند برای تولید Integration Events نیز مورد استفاده قرار گیرند - رویدادهایی که از مرز (boundary) Domain خارج می‌شوند.

🏗 پیاده‌سازی Domain Events

روشی که من برای پیاده‌سازی Domain Events ترجیح می‌دهم، ایجاد یک abstraction به نام IDomainEvent و پیاده‌سازی آن از MediatR.INotification است.

مزیت این کار این است که می‌توان از قابلیت publish-subscribe در MediatR برای انتشار یک Notification به یک یا چند Handler استفاده کرد.
using MediatR;

public interface IDomainEvent : INotification
{
}

اکنون می‌توانید یک Domain Event مشخص (Concrete) را پیاده‌سازی کنید.

در هنگام طراحی Domain Events، چند نکتهٔ مهم را باید در نظر بگیرید 👇

⚙️ نکات کلیدی طراحی Domain Events:

🔹️Immutability (تغییرناپذیری) :
چون Domain Event یک واقعیت (Fact) است، باید غیرقابل‌تغییر باشد.

🔹️Fat vs Thin Domain Events :
چقدر اطلاعات لازم دارید؟ (Eventها را بیش از حد سنگین یا بیش از حد سبک طراحی نکنید.)

🔹️نام‌گذاری با زمان گذشته :
برای نام Event از past tense استفاده کنید.

🔸 مثال:
public class CourseCompletedDomainEvent : IDomainEvent
{
public Guid CourseId { get; init; }
}


🚀 Raising Domain Events

پس از اینکه Domain Event‌های خود را ایجاد کردید، باید بتوانید آن‌ها را از درون Domain فراخوانی (raise) کنید.

روش پیشنهادی من این است که یک کلاس پایه به نام Entity بسازید،
چون فقط Entityها مجازند Domain Event ایجاد کنند.

برای کپسوله‌سازی (Encapsulation) بهتر، متد RaiseDomainEvent را می‌توان protected تعریف کرد تا فقط از داخل کلاس یا زیرکلاس‌ها قابل فراخوانی باشد.

در این پیاده‌سازی، ما Domain Eventها را در یک لیست داخلی (internal collection) نگهداری می‌کنیم تا هیچ بخش دیگری از سیستم نتواند مستقیماً به آن دسترسی داشته باشد.

متد GetDomainEvents یک snapshot از لیست داخلی برمی‌گرداند،
و متد ClearDomainEvents برای پاک‌سازی لیست داخلی استفاده می‌شود.
public abstract class Entity : IEntity
{
private readonly List<IDomainEvent> _domainEvents = new();

public IReadOnlyList<IDomainEvent> GetDomainEvents()
{
return _domainEvents.ToList();
}

public void ClearDomainEvents()
{
_domainEvents.Clear();
}

protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
}

اکنون Entity‌های شما می‌توانند از این کلاس پایه ارث‌بری کرده و Domain Event‌ها را raise کنند 👇
public class Course : Entity
{
public Guid Id { get; private set; }

public CourseStatus Status { get; private set; }

public DateTime? CompletedOnUtc { get; private set; }

public void Complete()
{
Status = CourseStatus.Completed;
CompletedOnUtc = DateTime.UtcNow;

RaiseDomainEvent(new CourseCompletedDomainEvent { CourseId = this.Id });
}
}

حالا تنها کاری که باقی مانده، انتشار (Publish) کردن Domain Eventها است.
🚀 نحوهٔ انتشار (Publish) Domain Eventها با EF Core

یک راه‌حل زیبا برای انتشار Domain Eventها استفاده از EF Core است.
از آنجا که EF Core مانند یک Unit of Work عمل می‌کند،
می‌توانید از آن برای جمع‌آوری تمام Domain Eventهای موجود در تراکنش فعلی و انتشار آن‌ها استفاده کنید.

من دوست ندارم این فرآیند را پیچیده کنم،
بنابراین به سادگی متد SaveChangesAsync را override می‌کنم تا بعد از ذخیره شدن تغییرات در پایگاه داده، Domain Eventها منتشر شوند.
البته می‌توانید از interceptor نیز استفاده کنید.
public class ApplicationDbContext : DbContext
{
public override async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
// چه زمانی باید Domain Eventها را منتشر کنید؟
//
// 1. قبل از فراخوانی SaveChangesAsync
// - Domain Eventها بخشی از همان تراکنش هستند
// - تضمین immediate consistency
// 2. بعد از فراخوانی SaveChangesAsync
// - Domain Eventها در تراکنش جداگانه اجرا می‌شوند
// - eventual consistency
// - احتمال خطا در Handlerها

var result = await base.SaveChangesAsync(cancellationToken);

await PublishDomainEventsAsync();

return result;
}
}

مهم‌ترین تصمیمی که در این مرحله باید بگیرید این است که چه زمانی Domain Eventها منتشر شوند.

من شخصاً ترجیح می‌دهم بعد از فراخوانی SaveChangesAsync آن‌ها را منتشر کنم،
یعنی پس از آن‌که تغییرات در پایگاه داده ذخیره شدند.

این کار البته معایب و مزایایی دارد 👇

Eventual consistency:
چون پیام‌ها پس از پایان تراکنش اصلی پردازش می‌شوند.

⚠️ ریسک ناسازگاری داده‌ها (Database inconsistency): چون ممکن است اجرای Domain Eventها با خطا مواجه شود.
با eventual consistency می‌توانم کنار بیایم،
اما ریسک inconsistency در پایگاه داده موضوع مهمی است.
برای حل این مشکل می‌توان از الگوی Outbox استفاده کرد 💡

در این الگو، تغییرات در پایگاه داده به‌صورت اتمیک (Atomic) به همراه Domain Eventها (به عنوان پیام‌های Outbox) ذخیره می‌شوند.
سپس Domain Eventها به صورت ناهمزمان (asynchronously) توسط یک background job پردازش می‌شوند.

اگر می‌خواهید بدانید داخل متد PublishDomainEventsAsync چه اتفاقی می‌افتد 👇
private async Task PublishDomainEventsAsync()
{
var domainEvents = ChangeTracker
.Entries<Entity>()
.Select(entry => entry.Entity)
.SelectMany(entity =>
{
var domainEvents = entity.GetDomainEvents();

entity.ClearDomainEvents();

return domainEvents;
})
.ToList();

foreach (var domainEvent in domainEvents)
{
await _publisher.Publish(domainEvent);
}
}

🧩 نحوهٔ Handle کردن Domain Eventها

با تمام زیرساختی که تا اینجا ایجاد کردیم،
اکنون آماده‌ایم که Handler مربوط به Domain Eventها را پیاده‌سازی کنیم.
خوشبختانه این مرحله ساده‌ترین بخش کار است

کافی است کلاسی بسازید که <INotificationHandler<Tرا پیاده‌سازی کند
و نوع Domain Event خود را به عنوان پارامتر generic مشخص نمایید.

در مثال زیر، یک Handler برای CourseCompletedDomainEvent داریم
که پس از وقوع Domain Event، یک CourseCompletedIntegrationEvent منتشر می‌کند
تا سایر سیستم‌ها را مطلع سازد 👇
public class CourseCompletedDomainEventHandler
: INotificationHandler<CourseCompletedDomainEvent>
{
private readonly IBus _bus;

public CourseCompletedDomainEventHandler(IBus bus)
{
_bus = bus;
}

public async Task Handle(
CourseCompletedDomainEvent domainEvent,
CancellationToken cancellationToken)
{
await _bus.Publish(
new CourseCompletedIntegrationEvent(domainEvent.CourseId),
cancellationToken);
}
}
🧠 جمع‌بندی

Domain Event
ها به شما کمک می‌کنند تا سیستمی loosely coupled بسازید.
می‌توانید از آن‌ها برای جدا کردن منطق اصلی دامنه از اثرات جانبی (side effects) استفاده کنید،
اثراتی که می‌توانند به صورت asynchronous مدیریت شوند.

لازم نیست برای پیاده‌سازی Domain Eventها از صفر شروع کنید؛
می‌توانید از ترکیب EF Core و MediatR استفاده کنید تا این قابلیت را به‌سادگی بسازید.

باید تصمیم بگیرید که چه زمانی می‌خواهید Domain Eventها را منتشر کنید:
قبل یا بعد از ذخیره شدن تغییرات در پایگاه داده — هرکدام مزایا و معایب خاص خود را دارند.
من شخصاً ترجیح می‌دهم بعد از ذخیره‌سازی تغییرات Domain Eventها را منتشر کنم
و برای اطمینان از تراکنش اتمیک، از الگوی Outbox استفاده می‌کنم.

این رویکرد باعث ایجاد eventual consistency می‌شود،
اما در عین حال قابل اعتمادتر است.

امیدوارم این مطلب برایتان مفید بوده باشد 🙌

🔖هشتگ‌ها:
#DomainEvents #EFCore #OutboxPattern #EventDriven #DesignPatterns #LooselyCoupled
When you forget the WHERE clause... 😅
«استاد، من کتاب‌های زیادی خوانده‌ام… اما بیشترشان را فراموش کرده‌ام. پس فایده‌ی خواندن چیست؟»

این پرسشِ شاگردی کنجکاو بود از استادش.
استاد پاسخی نداد، فقط در سکوت به او نگاه کرد.
چند روز بعد، کنار رودخانه‌ای نشسته بودند.
پیرمرد ناگهان گفت:
«تشنه‌ام. برایم کمی آب بیاور… اما با آن آب‌کش قدیمی که آنجاست.»
شاگرد متعجب شد. درخواست عجیبی بود — چطور می‌شد با آب‌کشِ پر از سوراخ، آب آورد؟
اما جرئت نکرد مخالفت کند.
آب‌کش را برداشت و تلاش کرد.
یک‌بار… دو‌بار… بارها و بارها…
سریع‌تر دوید، زاویه‌اش را عوض کرد، حتی سعی کرد سوراخ‌ها را با انگشت‌هایش بپوشاند.
هیچ‌کدام کارساز نشد. نتوانست حتی یک قطره آب نگه دارد.
خسته و ناامید، آب‌کش را کنار پای استاد انداخت و گفت:
«متأسفم، نتوانستم. غیرممکن بود.»
استاد با مهربانی به او نگریست و گفت:
«تو شکست نخوردی. به آب‌کش نگاه کن.»
شاگرد نگاهی انداخت… و چیزی دید.
آب‌کشِ قدیمی و سیاه و کثیف، حالا می‌درخشید.
آب، هرچند در آن نمانده بود، اما بارها و بارها از آن گذشته و شسته بودش تا براق شده بود.
استاد ادامه داد:
«خواندن هم همین‌گونه است.
مهم نیست اگر هر جزئیات را به خاطر نسپاری،
مهم نیست اگر دانسته‌هایت مثل آب از ذهنت بیرون می‌روند…
زیرا در هنگام خواندن، ذهنت تازه می‌شود،
روحت نیرو می‌گیرد،
افکارت نفس می‌کشند،
و حتی اگر بلافاصله متوجه نشوی، درونت در حال دگرگونی است.»
این است معنای واقعیِ خواندن —
نه برای پُر کردن حافظه،
بلکه برای شستن و غنی‌ساختنِ روح.
💡Value Objects در .NET (مبانی DDD)

Value Object
ها یکی از اجزای اصلی Domain-Driven Design (DDD) هستند.DDD یک رویکرد در توسعهٔ نرم‌افزار است که برای حل مسائل در دامنه‌های پیچیده (complex domains) استفاده می‌شود.

Value Object
ها مجموعه‌ای از مقادیر اولیه (primitive values) و قوانین یا محدودیت‌های مرتبط (invariants) را در خود نگه می‌دارند.

چند نمونهٔ رایج از Value Objectها:

💰 Money (که شامل amount و currency است)
📅 DateRange (که شامل start date و end date است)

🧠 Value Objectها چه هستند؟

بیایید با تعریف اصلی از کتاب Domain-Driven Design شروع کنیم:

شیئی که نمایانگر جنبه‌ای توصیفی از دامنه باشد و هیچ هویت مفهومی (conceptual identity) نداشته باشد، Value Object نامیده می‌شود.
این اشیاء برای نمایش عناصری از طراحی استفاده می‌شوند که برای ما فقط به خاطر ماهیتشان مهم‌اند، نه به خاطر اینکه چه کسی یا کدام نمونه هستند.
— Eric Evans

Value Object
ها با Entityها تفاوت دارند؛
زیرا هیچ مفهوم هویتی (identity) ندارند.
در واقع، آن‌ها نوع‌های اولیه (primitive types) را در دامنه کپسوله می‌کنند و از primitive obsession جلوگیری می‌کنند.

ویژگی‌های اصلی Value Objectها

دو ویژگی کلیدی در همهٔ Value Objectها وجود دارد:
Immutable بودن :
پس از ایجاد، مقدار آن‌ها دیگر تغییر نمی‌کند.

نداشتن Identity : هویت مستقل یا کلید یکتا ندارند.

یک ویژگی مهم دیگر نیز وجود دارد:
⚖️ برابری ساختاری (Structural Equality)

دو Value Object زمانی برابر محسوب می‌شوند که مقادیر درونی آن‌ها یکسان باشد.

البته در عمل، این ویژگی کم‌اهمیت‌تر از دو مورد قبلی است،
اما در بعضی موقعیت‌ها لازم است که تنها برخی از مقادیر داخلی در مقایسهٔ برابری در نظر گرفته شوند.

🧱 پیاده‌سازی Value Objectها

مهم‌ترین ویژگی Value Objectها تغییرناپذیری (immutability) است. مقادیر یک Value Object پس از ساخته شدن، دیگر نمی‌توانند تغییر کنند.
اگر بخواهید یکی از مقادیر را تغییر دهید، باید کل Value Object را جایگزین کنید.

در مثال زیر، یک موجودیت به نام Booking داریم که شامل مقادیر ابتدایی (primitive values) برای آدرس و تاریخ شروع و پایان رزرو است:
public class Booking
{
public string Street { get; init; }
public string City { get; init; }
public string State { get; init; }
public string Country { get; init; }
public string ZipCode { get; init; }

public DateOnly StartDate { get; init; }
public DateOnly EndDate { get; init; }
}

می‌توانیم این مقادیر اولیه را با دو Value Object به نام‌های Address و DateRange جایگزین کنیم:
public class Booking
{
public Address Address { get; init; }

public DateRange Period { get; init; }
}

اما چطور باید Value Objectها را پیاده‌سازی کرد؟

⚙️ استفاده از C# Records

در #C می‌توان از record‌ها برای نمایش Value Objectها استفاده کرد. Recordها به‌صورت پیش‌فرض immutable هستند و از برابری ساختاری (structural equality) پشتیبانی می‌کنند — دو ویژگی‌ای که دقیقاً برای Value Objectها نیاز داریم.

برای مثال، می‌توانیم یک Value Object به نام Address را با استفاده از record و primary constructor تعریف کنیم:
public record Address(
string Street,
string City,
string State,
string Country,
string ZipCode);

مزیت این روش در اختصار و سادگی آن است.
اما زمانی که بخواهید قوانین (invariants) را هنگام ساخت Value Object اعمال کنید، باید سازندهٔ خصوصی (private constructor) تعریف کنید — که در این حالت مزیت اختصار از بین می‌رود.

مشکل دیگر در استفاده از recordها، امکان دور زدن این قوانین از طریق عبارت with است.
در مثال زیر، پیاده‌سازی کامل‌تر Address را می‌بینید که در آن یک متد factory برای ایجاد نمونه‌ها استفاده شده است:
public record Address
{
private Address(
string street,
string city,
string state,
string country,
string zipCode)
{
Street = street;
City = city;
State = state;
Country = country;
ZipCode = zipCode;
}

public string Street { get; init; }
public string City { get; init; }
public string State { get; init; }
public string Country { get; init; }
public string ZipCode { get; init; }

public static Result<Address> Create(
string street,
string city,
string state,
string country,
string zipCode)
{
// بررسی معتبر بودن آدرس

return new Address(street, city, state, country, zipCode);
}
}


🏗 کلاس پایه (Base Class)

روش جایگزین برای پیاده‌سازی Value Objectها، استفاده از یک کلاس پایه به نام ValueObject است.
این کلاس، وظیفهٔ مقایسهٔ ساختاری (structural equality) را از طریق متد انتزاعی GetAtomicValues بر عهده دارد.

کلاس‌های مشتق‌شده از ValueObject باید این متد را پیاده‌سازی کنند و اجزایی را که در مقایسهٔ برابری مؤثر هستند، مشخص نمایند.

مزایای استفاده از کلاس پایه

صراحت (Explicitness): به‌وضوح مشخص است که کدام کلاس‌ها در دامنه (domain) شما نقش Value Object دارند.

کنترل بر اجزای برابری: می‌توانید دقیقاً تعیین کنید چه مقادیری باید در مقایسهٔ برابری در نظر گرفته شوند.

در زیر یک پیاده‌سازی نمونه از کلاس پایهٔ ValueObject را مشاهده می‌کنید که در بسیاری از پروژه‌ها کاربرد دارد:
public abstract class ValueObject : IEquatable<ValueObject>
{
public static bool operator ==(ValueObject? a, ValueObject? b)
{
if (a is null && b is null)
{
return true;
}

if (a is null || b is null)
{
return false;
}

return a.Equals(b);
}

public static bool operator !=(ValueObject? a, ValueObject? b) =>
!(a == b);

public virtual bool Equals(ValueObject? other) =>
other is not null && ValuesAreEqual(other);

public override bool Equals(object? obj) =>
obj is ValueObject valueObject && ValuesAreEqual(valueObject);

public override int GetHashCode() =>
GetAtomicValues().Aggregate(
default(int),
(hashcode, value) =>
HashCode.Combine(hashcode, value.GetHashCode()));

protected abstract IEnumerable<object> GetAtomicValues();

private bool ValuesAreEqual(ValueObject valueObject) =>
GetAtomicValues().SequenceEqual(valueObject.GetAtomicValues());
}


🧩 نمونه پیاده‌سازی Value Object آدرس

در ادامه، Address را به عنوان یک Value Object پیاده‌سازی کرده‌ایم که از ValueObject ارث‌بری می‌کند و متد GetAtomicValues را برای مقایسهٔ اجزای داخلی بازنویسی می‌کند:
public sealed class Address : ValueObject
{
public string Street { get; init; }
public string City { get; init; }
public string State { get; init; }
public string Country { get; init; }
public string ZipCode { get; init; }

protected override IEnumerable<object> GetAtomicValues()
{
yield return Street;
yield return City;
yield return State;
yield return Country;
yield return ZipCode;
}
}
🧠 چه زمانی از Value Objectها استفاده کنیم؟

من از Value Objectها برای حل مشکل primitive obsession و کپسوله‌سازی (encapsulation) قوانین دامنه (domain invariants) استفاده می‌کنم.
کپسوله‌سازی بخش مهمی از هر مدل دامنه است. شما نباید بتوانید یک Value Object را در حالت نامعتبر ایجاد کنید.

Value Object
ها همچنین type safety را فراهم می‌کنند. به امضای متد زیر دقت کنید:
public interface IPricingService
{
decimal Calculate(Apartment apartment, DateOnly start, DateOnly end);
}

حالا به امضای متد زیر نگاه کنید که در آن از Value Objectها استفاده شده است. می‌توانید ببینید که این نسخه از IPricingService بسیار واضح‌تر (explicit) است. همچنین از مزیت type safety برخوردار می‌شوید. هنگام کامپایل کد، Value Objectها احتمال بروز خطا را کاهش می‌دهند.
public interface IPricingService
{
PricingDetails Calculate(Apartment apartment, DateRange period);
}

در ادامه چند نکته دیگر را ببینید که باید هنگام تصمیم‌گیری برای استفاده از Value Objectها در نظر بگیرید:

• پیچیدگی قوانین (invariants): اگر نیاز به اعمال قوانین پیچیده دارید، استفاده از Value Object را در نظر بگیرید.

• تعداد مقادیر اولیه (primitives): زمانی که تعداد زیادی مقدار اولیه وجود دارد، استفاده از Value Object منطقی است.

• تعداد تکرارها: اگر نیاز دارید قوانین را فقط در چند نقطه از کد اعمال کنید، می‌توانید بدون Value Object هم آن را مدیریت کنید.

💾 ذخیره‌سازی Value Objectها با EF Core

Value Object
ها بخشی از موجودیت‌های دامنه (domain entities) هستند و باید در پایگاه داده ذخیره شوند.
در اینجا نحوه استفاده از EF Owned Types و Complex Types برای ذخیره‌سازی Value Objectها آورده شده است.

🧱 Owned Types

Owned Type
ها را می‌توان با فراخوانی متد OwnsOne در هنگام پیکربندی موجودیت تنظیم کرد.
این کار به EF اعلام می‌کند که باید Value Objectهای Address و Price را در همان جدول موجودیت Apartment ذخیره کند.
Value Object
ها با ستون‌های اضافی در جدول apartments نمایش داده می‌شوند.
public void Configure(EntityTypeBuilder<Apartment> builder)
{
builder.ToTable("apartments");

builder.OwnsOne(property => property.Address);

builder.OwnsOne(property => property.Price, priceBuilder =>
{
priceBuilder.Property(money => money.Currency)
.HasConversion(
currency => currency.Code,
code => Currency.FromCode(code));
});
}


چند نکته در مورد Owned Typeها:

• Owned Typeها دارای یک کلید پنهان هستند.

• از نوع اختیاری (nullable) پشتیبانی نمی‌کنند.

• مجموعه‌های متعلق پشتیبانی می‌شوند و با OwnsMany قابل پیکربندی هستند.

•Table splitting
به شما اجازه می‌دهد Owned Typeها را در جدول جداگانه ذخیره کنید.

🧩 Complex Types

ویژگی جدیدی در EF هستند که در 8 NET. در دسترس‌اند.
آن‌ها با مقدار کلید (key value) شناسایی یا پیگیری نمی‌شوند.
اینها باید بخشی از نوع موجودیت (entity type) باشند.Complex Type ها برای نمایش Value Objectها در EF مناسب‌تر هستند.

در اینجا نحوه پیکربندی Address به عنوان یک Complex Type را می‌بینید:
public void Configure(EntityTypeBuilder<Apartment> builder)
{
builder.ToTable("apartments");

builder.ComplexProperty(property => property.Address);
}

چند محدودیت برای Complex Typeها وجود دارد:

🔹️ از مجموعه‌ها (collections) پشتیبانی نمی‌کنند.
🔹️ از مقادیر اختیاری (nullable values) پشتیبانی نمی‌کنند.

🧾 نتیجه‌گیری

Value Object
ها به شما کمک می‌کنند تا یک مدل دامنه غنی طراحی کنید.
می‌توانید از آن‌ها برای حل مشکل primitive obsession و کپسوله‌سازی قوانین دامنه استفاده کنید.Value Objectها با جلوگیری از ایجاد اشیای دامنه نامعتبر، احتمال خطا را کاهش می‌دهند.

شما می‌توانید برای نمایش Value Objectها از record یا کلاس پایه ValueObject استفاده کنید.انتخاب بین آن‌ها باید بر اساس نیازها و پیچیدگی دامنه شما باشد.
من معمولاً به‌صورت پیش‌فرض از record استفاده می‌کنم مگر زمانی که به ویژگی‌های خاص کلاس پایه نیاز داشته باشم.
برای مثال، کلاس پایه زمانی مفید است که بخواهید اجزای برابری (equality components) را کنترل کنید.

🔖هشتگ‌ها:
#DomainDrivenDesign #ValueObject #CleanCode #EntityVsValueObject #ImmutableObjects
مثالی پر قدرت😅
بسیاری از توسعه‌دهندگان این اشتباه را مرتکب می‌شوند:
آیا می‌دانستید که با ساخت پروژه‌های جانبی فقط برای پر کردن رزومه، در واقع از چیزی که سریع‌تر باعث رشد شما می‌شود، غافل می‌شوید؟

یادگیری واقعی، حل مسئله، و خوداتکایی.

این را امتحان کنید:

از پروژه‌های ساده (حتی همان برنامه‌ی خسته‌کننده‌ی to-do!) استفاده کنید تا با تکنولوژی‌های جدید آزمایش کنید و تجربه‌ی کار با آن‌ها را به دست آورید.

چیزی بسازید که واقعاً در آن گیر کنید، و خودتان را مجبور کنید با دیباگ کردن از آن خارج شوید — جایی که بیشترین یادگیری اتفاق می‌افتد.

تأمل کنید که چرا آن را ساختید و چه چیزی یاد گرفتید، نه فقط اینکه چه چیزی را تمام کردید.

این کار هیچ کمکی نمی‌کند اگر بگذارید هوش مصنوعی یا آموزش‌ها تمام کار را انجام دهند.
وقتی هرگز تقلا نمی‌کنید، هرگز یاد نمی‌گیرید.

هدف، تجربه‌ی واقعی و توسعه‌ی مهارت است، نه فقط اضافه کردن چند خط دیگر به رزومه.
هرچند داشتن چند تیک بیشتر روی CV می‌تواند اثر جانبی مفیدی باشد.
Request-Response Messaging Pattern With MassTransit📨
🛰 الگوی Request-Response Messaging با MassTransit

ساخت برنامه‌های توزیع‌شده (Distributed Applications) در نگاه اول ساده به نظر می‌رسد فقط چند سرور هستند که با هم صحبت می‌کنند، درست است؟
اما در عمل، این نوع سیستم‌ها مجموعه‌ای از مشکلات بالقوه را ایجاد می‌کنند که باید به آن‌ها توجه کنید.
اگر شبکه دچار اختلال شود چه؟ اگر یک سرویس به‌طور غیرمنتظره کرش کند چه؟ اگر بخواهید سیستم را scale کنید و همه چیز زیر فشار از هم بپاشد چه؟
اینجاست که نحوه ارتباط سرویس‌ها در سیستم توزیع‌شده اهمیت حیاتی پیدا می‌کند. ⚙️
ارتباط‌های synchronous سنتی جایی که سرویس‌ها مستقیماً یکدیگر را فراخوانی می‌کنند ذاتاً شکننده‌اند.
این رویکرد باعث tight coupling می‌شود و در نتیجه، کل سیستم در برابر هرگونه نقطه شکست (single point of failure) آسیب‌پذیر می‌شود.
برای مقابله با این مشکل، می‌توانیم از messaging توزیع‌شده (Distributed Messaging) استفاده کنیم.
(البته این کار خودش مجموعه جدیدی از چالش‌ها را هم ایجاد می‌کند، ولی آن موضوع را می‌گذاریم برای مقاله‌ای دیگر 😄)
یکی از ابزارهای قدرتمند برای انجام این کار در دنیای NET. ، کتابخانه‌ی MassTransit است. 🚀
در این مقاله، به بررسی پیاده‌سازی الگوی Request-Response در MassTransit می‌پردازیم.

📨 مقدمه‌ای بر الگوی Request-Response Messaging Pattern

بیایید ابتدا توضیح دهیم که این الگو چگونه کار می‌کند.
الگوی Request-Response بسیار شبیه به فراخوانی یک تابع معمولی است، با این تفاوت که این فراخوانی از طریق شبکه انجام می‌شود.
در این الگو:

• یک سرویس به‌عنوان Requestor (درخواست‌دهنده)، یک پیام درخواست (Request Message) ارسال می‌کند

•و منتظر پیام پاسخ (Response Message) از سمت Responder (پاسخ‌دهنده) می‌ماند.
از دید Requestor، این فرآیند synchronous است.

مزایا:

🔹️Loose Coupling:
سرویس‌ها نیازی به دانستن مستقیم یکدیگر ندارند؛ تنها کافی است قرارداد پیام (message contract) را بشناسند.
این ویژگی باعث سهولت در تغییر و افزایش مقیاس (scalability) می‌شود.

🔹️Location Transparency:
درخواست‌دهنده نیازی ندارد بداند پاسخ‌دهنده در کجا قرار دارد در نتیجه انعطاف‌پذیری سیستم افزایش می‌یابد.

⚠️ معایب:

🔹️Latency:
سربار پیام‌رسانی باعث افزایش اندکی در زمان پاسخ می‌شود.

🔹️Complexity:
افزودن یک سیستم پیام‌رسان و مدیریت زیرساخت‌های مربوط به آن می‌تواند پیچیدگی پروژه را افزایش دهد.
🛰 پیام‌رسانی Request-Response با MassTransit

کتابخانه‌ی MassTransit به‌صورت پیش‌فرض از الگوی پیام‌رسانی Request-Response پشتیبانی می‌کند.
ما می‌توانیم از یک Request Client برای ارسال درخواست‌ها و انتظار برای پاسخ استفاده کنیم.
این Request Client به‌صورت asynchronous است و از کلیدواژه‌ی await پشتیبانی می‌کند.
به‌صورت پیش‌فرض، درخواست دارای timeout سی‌ثانیه‌ای است تا از انتظار بیش‌از‌حد برای پاسخ جلوگیری شود.

بیایید یک سناریو را تصور کنیم که در آن سیستمی برای پردازش سفارش دارید و باید آخرین وضعیت یک سفارش را بازیابی کنید.
ما می‌توانیم وضعیت را از سرویس Order Management دریافت کنیم.
با MassTransit، شما یک Request Client ایجاد می‌کنید تا این فرآیند را آغاز کند.
این کلاینت پیامی از نوع GetOrderStatusRequest را روی bus ارسال می‌کند.
public record GetOrderStatusRequest
{
public string OrderId { get; init; }
}

در سمت Order Management، یک responder (یا consumer) در حال گوش دادن به پیام‌های GetOrderStatusRequest است.
این consumer درخواست را دریافت می‌کند، احتمالاً به پایگاه داده کوئری می‌زند تا وضعیت را بگیرد، و سپس پیامی از نوع GetOrderStatusResponse را دوباره روی bus ارسال می‌کند.
کلاینت درخواست‌دهنده در انتظار این پاسخ خواهد بود و می‌تواند آن را مطابق نیاز پردازش کند.
public class GetOrderStatusRequestConsumer : IConsumer<GetOrderStatusRequest>
{
public async Task Consume(ConsumeContext<GetOrderStatusRequest> context)
{
// Get the order status from a database.

await context.ResponseAsync<GetOrderStatusResponse>(new
{
// Set the respective response properties.
});
}
}


👥 دریافت سطح دسترسی کاربران در Modular Monolith

در اینجا یک سناریوی واقعی وجود دارد که تیم ما تصمیم گرفت این الگو را پیاده‌سازی کند.
ما در حال ساخت یک modular monolith بودیم و یکی از ماژول‌ها مسئول مدیریت user permissions بود.
سایر ماژول‌ها می‌توانستند برای دریافت سطح دسترسی کاربر به ماژول Users فراخوانی انجام دهند.
و این تا زمانی که داخل یک سیستم monolith هستیم به‌خوبی کار می‌کند.

اما در مقطعی نیاز داشتیم یکی از ماژول‌ها را به یک سرویس مجزا استخراج کنیم.
این بدان معنا بود که ارتباط با ماژول Users از طریق method call‌های ساده دیگر کار نخواهد کرد.

خوشبختانه، ما از قبل از MassTransit و RabbitMQ برای پیام‌رسانی درون سیستم استفاده می‌کردیم.

بنابراین تصمیم گرفتیم از قابلیت request-response در MassTransit برای پیاده‌سازی این ارتباط استفاده کنیم.

سرویس جدید یک <IRequestClient<GetUserPermissions تزریق می‌کند.
ما می‌توانیم از آن برای ارسال پیام GetUserPermissions و انتظار برای پاسخ استفاده کنیم.

یکی از ویژگی‌های قدرتمند MassTransit این است که می‌توانید منتظر بیش از یک نوع پاسخ باشید.
در این مثال، ما منتظر PermissionsResponse یا Error هستیم.
این عالی است، چون راهی برای مدیریت خطاها در consumer نیز داریم.
internal sealed class PermissionService(
IRequestClient<GetUserPermissions> client)
: IPermissionService
{
public async Task<Result<PermissionsResponse>> GetUserPermissionsAsync(
string identityId)
{
var request = new GetUserPermissions(identityId);

Response<PermissionsResponse, Error> response =
await client.GetResponse<PermissionsResponse, Error>(request);

if (response.Is(out Response<Error> errorResponse))
{
return Result.Failure<PermissionsResponse>(errorResponse.Message);
}

if (response.Is(out Response<PermissionsResponse> permissionResponse))
{
return permissionResponse.Message;
}

return Result.Failure<PermissionsResponse>(NotFound);
}
}
در ماژول Users، می‌توانیم به‌راحتی GetUserPermissionsConsumer را پیاده‌سازی کنیم.
این کلاس در صورت یافتن permissions با PermissionsResponse پاسخ می‌دهد و در صورت خطا با Error پاسخ خواهد داد.
public sealed class GetUserPermissionsConsumer(
IPermissionService permissionService)
: IConsumer<GetUserPermissions>
{
public async Task Consume(ConsumeContext<GetUserPermissions> context)
{
Result<PermissionsResponse> result =
await permissionService.GetUserPermissionsAsync(
context.Message.IdentityId);
if (result.IsSuccess)
{
await context.RespondAsync(result.Value);
}
else
{
await context.RespondAsync(result.Error);
}
}
}


💭 نتیجه‌گیری

با پذیرش الگوهای پیام‌رسانی با MassTransit، شما بر روی یک پایه‌ی بسیار مقاوم‌تر ساختار می‌دهید.
سرویس‌های NET. شما اکنون کمتر tightly coupled هستند و این انعطاف را دارید که آن‌ها را به‌صورت مستقل توسعه دهید و در برابر خطاهای شبکه یا service outage‌های ناگهانی مقاوم‌تر باشید.

الگوی request-response ابزاری قدرتمند در مجموعه ابزارهای پیام‌رسانی شماست. MassTransit پیاده‌سازی آن را به‌طرز قابل‌توجهی ساده می‌کند و اطمینان می‌دهد که درخواست‌ها و پاسخ‌ها به‌طور قابل‌اعتماد منتقل می‌شوند.

ما می‌توانیم از request-response برای پیاده‌سازی ارتباط بین ماژول‌ها در یک modular monolith استفاده کنیم.
با این حال، در استفاده از آن زیاده‌روی نکنید، زیرا ممکن است سیستم شما با latency بیشتر مواجه شود.

کوچک شروع کنید، آزمایش کنید، و ببینید که چطور قابلیت اطمینان و انعطاف‌پذیری پیام‌رسانی می‌تواند تجربه‌ی توسعه‌ی شما را متحول کند.
عالی بمونید! 🚀

🔖هشتگ‌ها:
#MassTransit #RequestResponse #MessagingPattern
📌معرفی YARP

YARP (Yet Another Reverse Proxy)
یک کتابخانه reverse proxy بسیار قابل تنظیم برای NET. است. این کتابخانه طراحی شده تا یک چارچوب پروکسی قابل اعتماد، منعطف، مقیاس‌پذیر، امن و آسان برای استفاده ارائه دهد. YARP به توسعه‌دهندگان کمک می‌کند تا راهکارهای reverse proxy قدرتمند و بهینه متناسب با نیازهای خاص خود ایجاد کنند. 🚀

کارکرد یک Reverse Proxy

یک reverse proxy سروری است که بین دستگاه‌های مشتری و سرورهای بک‌اند قرار می‌گیرد. این سرور درخواست‌های مشتری را به سرور مناسب منتقل می‌کند و سپس پاسخ سرور را به مشتری برمی‌گرداند. Reverse proxy چندین مزیت دارد:

🔹️Routing:
هدایت درخواست‌ها به سرورهای مختلف بک‌اند بر اساس قوانین از پیش تعریف شده، مانند الگوهای URL یا هدرهای درخواست. برای مثال، درخواست‌های /images، /api و /db می‌توانند به سرورهای image، api و database هدایت شوند.

🔹️Load Balancing:
توزیع ترافیک ورودی بین چند سرور بک‌اند برای جلوگیری از بارگذاری بیش از حد یک سرور خاص. این توزیع باعث افزایش عملکرد و قابلیت اطمینان می‌شود.

🔹️Scalability:
با توزیع ترافیک بین چند سرور، reverse proxy به برنامه کمک می‌کند تا بتواند کاربران و بار بیشتر را مدیریت کند. سرورهای بک‌اند می‌توانند بدون تأثیر بر مشتری اضافه یا حذف شوند.

🔹️SSL/TLS Termination:
بار رمزنگاری و رمزگشایی TLS را از سرورهای بک‌اند برداشته و بار آن‌ها را کاهش می‌دهد.

🔹️Connection abstraction, decoupling and control over URL-space:
درخواست‌های ورودی از مشتری و پاسخ‌های خروجی از بک‌اند مستقل هستند. این استقلال امکان:

• استفاده از نسخه‌های مختلف HTTP (HTTP/1.1، HTTP/2، HTTP/3) و ارتقا یا کاهش نسخه‌ها

• مدیریت طول عمر اتصال‌ها، مانند نگه داشتن اتصال طولانی در بک‌اند در حالی که اتصالات کوتاه مشتری حفظ می‌شوند

• کنترل URL: URLهای ورودی می‌توانند قبل از ارسال به بک‌اند تغییر داده شوند، و نقشه داخلی خدمات می‌تواند بدون تأثیر بر URL خارجی تغییر کند

🔹️Security:
نقاط انتهایی داخلی می‌توانند از دید خارجی مخفی بمانند و از برخی حملات سایبری مانند DDoS محافظت کنند 🔒

🔹️Caching:
منابع پر درخواست می‌توانند کش شوند تا بار روی سرورهای بک‌اند کاهش یابد و زمان پاسخ بهبود یابد

🔹️Versioning:
نسخه‌های مختلف یک API با استفاده از نگاشت‌های مختلف URL پشتیبانی می‌شوند

🔹️Simplified maintenance:
Reverse proxies
می‌توانند SSL/TLS Termination و وظایف دیگر را مدیریت کنند، که پیکربندی و نگهداری سرورهای بک‌اند را ساده می‌کند. برای مثال، گواهی‌نامه‌ها و سیاست‌های امنیتی می‌توانند در سطح پروکسی مدیریت شوند به جای هر سرور جداگانه 🛠

نحوه مدیریت HTTP توسط Reverse Proxy

یک reverse proxy درخواست‌ها و پاسخ‌های HTTP را به این صورت مدیریت می‌کند:

🔸️دریافت درخواست‌ها: پروکسی روی پورت‌ها و endpoints مشخص به درخواست‌های HTTP مشتریان گوش می‌دهد.

🔸️Terminating Connections:
اتصالات HTTP ورودی در پروکسی خاتمه می‌یابند و اتصالات جدید برای درخواست‌های خروجی ایجاد می‌شوند.

🔸️Routing requests:
بر اساس قوانین و تنظیمات از پیش تعریف شده، پروکسی تعیین می‌کند که کدام سرور بک‌اند یا خوشه‌ای از سرورها باید درخواست را پردازش کنند.

🔸️Forwarding requests:
پروکسی درخواست مشتری را به سرور مناسب ارسال می‌کند و مسیر و هدرها را در صورت نیاز تغییر می‌دهد.

🔸️Connection pooling:
اتصالات خروجی به صورت pooled مدیریت می‌شوند تا بار اتصال کاهش یابد و از مزایای HTTP 1.1 و درخواست‌های موازی HTTP/2 و HTTP/3 استفاده شود

🔸️Processing responses:
سرور بک‌اند درخواست را پردازش کرده و پاسخ را به پروکسی ارسال می‌کند

🔸️Returning responses:
پروکسی پاسخ را از سرور دریافت و به مشتری برمی‌گرداند و در صورت نیاز تغییرات لازم روی پاسخ اعمال می‌کند

این روند اطمینان می‌دهد که مشتری با پروکسی تعامل دارد نه مستقیماً با سرورهای بک‌اند، و مزایای load balancing، امنیت، versioning و غیره را فراهم می‌کند.
چرا YARP را انتخاب کنیم؟

مزایای منحصر به فردی ارائه می‌دهد که آن را برای توسعه‌دهندگان جذاب می‌کند:

🔹️Customization: YARP
بسیار قابل تنظیم است و توسعه‌دهندگان می‌توانند پروکسی را با حداقل تلاش به نیازهای خود تطبیق دهند

🔸️Integration with .NET:
بر پایه ASP.NET Core ساخته شده و به خوبی با اکوسیستم NET. یکپارچه می‌شود

🔹️Extensibility:
نقاط توسعه‌ی گسترده برای افزودن منطق و ویژگی‌های سفارشی با استفاده از کد #C فراهم می‌کند

🔸️Scalability:
قابلیت direct forwarding extensibility امکان مقیاس‌پذیری نام دامنه و سرورهای بک‌اند را فراهم می‌کند، چیزی که با اکثر reverse proxy‌ها ممکن نیست

🔹️Active development: YARP
به طور فعال توسط مایکروسافت نگهداری و توسعه داده می‌شود
🔸️Comprehensive maintained documentation:
مستندات جامع و مثال‌ها، شروع سریع و پیاده‌سازی ویژگی‌های پیشرفته را آسان می‌کند

🔹️Open source:
و مستندات آن متن‌باز هستند و مشارکت، بازبینی و بازخورد پذیرفته می‌شود 💻

🔖هشتگ‌ها:
#YARP #ReverseProxy
The Idempotent Consumer Pattern in .NET
🧩 الگوی Idempotent Consumer در NET. (و چرا به آن نیاز دارید)

سیستم‌های توزیع‌شده ذاتاً غیرقابل اعتماد هستند. ⚠️

من همیشه توصیه می‌کنم برای درک بهتر اشتباهات رایج، مقاله‌ی معروف Fallacies of Distributed Computing را مطالعه کنید.

یکی از چالش‌های کلیدی در این سیستم‌ها، اطمینان از این است که هر پیام دقیقاً یک‌بار پردازش شود که از نظر تئوری در بیشتر سیستم‌ها غیرممکن است. 😅

در اینجا وارد مباحثی مثل CAP Theorem یا Two Generals Problem نمی‌شویم، اما کافی است بدانید که در دنیای واقعی:

پیام‌ها ممکن است خارج از ترتیب برسند 🌀
پیام‌ها ممکن است تکراری شوند 🔁
تحویل پیام‌ها ممکن است با تأخیر انجام شود 🕒

اگر سیستم خود را طوری طراحی کنید که فرض کند هر پیام دقیقاً یک‌بار پردازش می‌شود، در واقع دارید زمینه را برای خرابی داده‌های پنهان فراهم می‌کنید. 💥

اما می‌توانیم سیستم خود را طوری طراحی کنیم که اثرات جانبی (side effects) فقط یک‌بار اعمال شوند با استفاده از الگوی قدرتمند Idempotent Consumer. 💡

بیایید با هم بررسی کنیم:
🔹 چه خطاهایی ممکن است رخ دهند
🔹 چگونه message brokerها در مدیریت idempotency کمک می‌کنند
🔹 و چطور می‌توانیم یک Idempotent Consumer در NET. بسازیم

🚨 چه چیزی ممکن است هنگام انتشار پیام اشتباه پیش برود؟

فرض کنید سرویس شما زمانی که یک یادداشت جدید ایجاد می‌شود، یک event منتشر می‌کند:
await publisher.PublishAsync(new NoteCreated(note.Id, note.Title, note.Content));

در اینجا اهمیتی ندارد که publisher یا message broker شما چه پیاده‌سازی‌ای دارد می‌تواند RabbitMQ، SQS، یا Azure Service Bus باشد.

حالا تصور کنید سناریوی زیر رخ دهد:

• Publisher
پیام را به broker ارسال می‌کند

• Broker
پیام را ذخیره کرده و یک ACK (تأیید دریافت) برمی‌گرداند

• یک اختلال شبکه باعث می‌شود ACK هرگز به producer نرسد 🌐

• Producer timeout
می‌شود و انتشار را دوباره تلاش می‌کند 🔁

حالا broker دو event از نوع NoteCreated دارد 😬

از دید producer، فقط یک timeout رفع شده است.
اما از دید consumer، دو پیام دریافت شده که هر دو مربوط به ایجاد یک یادداشت هستند.

و این تنها یکی از مسیرهای خرابی ممکن است!

ممکن است پیام‌های تکراری به دلایل زیر هم اتفاق بیفتند:

• ارسال مجدد توسط broker
• خرابی consumer و اجرای مجدد در retryها

بنابراین حتی اگر در سمت publisher همه چیز را “درست” انجام دهید، باز هم consumer باید محافظه‌کارانه طراحی شود تا در برابر تکرارها مقاوم باشد. 🧱
⚙️ Publisher-Side Idempotency (بگذارید Broker آن را مدیریت کند)

بسیاری از message brokerها از قبل از طریق قابلیت message deduplication، از انتشار idempotent پشتیبانی می‌کنند؛ البته اگر در پیام خود یک شناسه‌ی یکتا (unique message ID) قرار دهید.

به‌عنوان مثال، Azure Service Bus می‌تواند پیام‌های تکراری را تشخیص داده و انتشار مجدد پیام‌هایی با MessageId یکسان را در بازه‌ی زمانی مشخص‌شده نادیده بگیرد.Amazon SQS و سایر brokerها نیز تضمین‌های مشابهی ارائه می‌دهند. 💪

شما نیازی ندارید این منطق را دوباره در برنامه‌ی خود پیاده‌سازی کنید.
نکته‌ی کلیدی این است که برای هر پیام، یک شناسه‌ی پایدار (stable identifier) اختصاص دهید که به‌طور یکتا رویداد منطقی مورد نظر را نمایش دهد.

به‌عنوان مثال، هنگام انتشار یک event از نوع NoteCreated:
var message = new NoteCreated(note.Id, note.Title, note.Content)
{
MessageId = Guid.NewGuid() // یا می‌توانید از note.Id استفاده کنید
};

await publisher.PublishAsync(message);


اگر شبکه پس از ارسال پیام قطع شود 🌐، ممکن است برنامه‌ی شما ارسال را مجدداً تلاش کند (retry).
اما زمانی که broker همان MessageId را مشاهده کند، متوجه می‌شود که این پیام تکراری است و آن را به‌صورت ایمن نادیده می‌گیرد.

به این ترتیب، شما deduplication را بدون نیاز به جداول ردیابی (tracking tables) یا state اضافی در سرویس خود به‌دست می‌آورید.

این نوع idempotency در سطح broker، بخش بزرگی از مشکلات سمت producer را حل می‌کند مثل:

• retryهای شبکه 🔁
• خطاهای موقتی ⚠️
• انتشارهای تکراری 🌀

اما چیزی که این مکانیسم پوشش نمی‌دهد، retryهای سمت consumer است یعنی زمانی که پیام‌ها مجدداً تحویل داده می‌شوند یا سرویس شما هنگام پردازش دچار crash می‌شود 💥.

اینجاست که الگوی Idempotent Consumer وارد عمل می‌شود. 🎯

🧠 Implementing an Idempotent Consumer in .NET

در اینجا یک نمونه از Idempotent Consumer برای eventی از نوع NoteCreated آورده شده است:
internal sealed class NoteCreatedConsumer(
TagsDbContext dbContext,
HybridCache hybridCache,
ILogger<Program> logger) : IConsumer<NoteCreated>
{
public async Task ConsumeAsync(ConsumeContext<NoteCreated> context)
{
// 1. بررسی اینکه آیا این پیام قبلاً توسط این consumer پردازش شده است
if (await dbContext.MessageConsumers.AnyAsync(c =>
c.MessageId == context.MessageId &&
c.ConsumerName == nameof(NoteCreatedConsumer)))
{
return;
}

var request = new AnalyzeNoteRequest(
context.Message.NoteId,
context.Message.Title,
context.Message.Content);

try
{
using var transaction = await dbContext.Database.BeginTransactionAsync();

// 2. پردازش قطعی (Deterministic): استخراج تگ‌ها از محتوای یادداشت
var tags = AnalyzeContentForTags(request.Title, request.Content);

// 3. ذخیره‌سازی تگ‌ها در پایگاه داده
var tagEntities = tags.Select(ProjectToTagEntity(request.NoteId)).ToList();
dbContext.Tags.AddRange(tagEntities);

// 4. ثبت اینکه این پیام پردازش شده است
dbContext.MessageConsumers.Add(new MessageConsumer
{
MessageId = context.MessageId,
ConsumerName = nameof(NoteCreatedConsumer),
ConsumedAtUtc = DateTime.UtcNow
});

await dbContext.SaveChangesAsync();
await transaction.CommitAsync();

// 5. به‌روزرسانی Cache
await CacheNoteTags(request, tags);
}
catch (Exception ex)
{
logger.LogError(ex, "Error analyzing note {NoteId}", request.NoteId);
throw;
}
}
}

این یک نمونه‌ی متداول از Idempotent Consumer است که شامل چند نکته‌ی کلیدی است 🧩