پست

Learning Domain Driven Design

Aligning Software Architecture and Business Strategy

Learning Domain Driven Design

توضیحات

ساختن نرم افزار سخت‌تر از همیشه شده است. به‌عنوان یک توسعه‌دهنده، نه تنها باید به دنبال گرایش‌های عمومی تکنولوژیکی باشید که همیشه در حال تغییر هستند، بلکه باید حوزه‌های تجاری پشت نرم‌افزار را نیز درک کنید. کتاب Learning Domain-Driven Design (آموزش طراحی دامنه محور)، مجموعه‌ای از الگوها، اصول و شیوه‌های اساسی را برای تجزیه و تحلیل حوزه‌های کسب‌وکار، درک استراتژی کسب‌وکار و مهم‌تر از همه، همسو کردن طراحی نرم‌افزار با نیازهای تجاری آن در اختیار شما قرار می‌دهد.
نویسنده کتاب Vlad Khononov به شما نشان می‌دهد که چگونه این شیوه‌ها منجر به پیاده سازی قوی منطق تجاری و کمک به طراحی و معماری نرم‌افزاری برای آینده می‌شوند. شما رابطه بین طراحی مبتنی بر دامنه (DDD) و سایر روش‌ها را بررسی می‌کنید تا اطمینان حاصل کنید که مطابق با نیازمندی‌های کسب و کار تصمیمات معماری را می‌گیرید. شما همچنین داستان واقعی پیاده سازی DDD در یک شرکت استارت آپی را بررسی خواهید کرد.

نظر

کتاب به خوبی و با متنی روان ddd را همراه با جزئیات توضیح می‌دهد و بخش‌های مختلف آن و همچنین چالش‌های واقعی را بیان می‌کند.

نظر

  • امتیاز : 00/10
  • به دیگران توصیه می‌کنم : بله
  • دوباره می‌خوانم : بله
  • ایده برجسته : تحلیل بیزینس قبل از شروع به کد
  • تاثیر در من : بیشتر دقت کردن به بیزینس زمان کدنویسی
  • نکات مثبت : دقت و توضیحات خوب
  • نکات منفی : -

مشخصات

  • نویسنده : Vlad Khononov
  • انتشارات : O’Reilly

بخش‌هایی از کتاب

فصل اول: تحلیل حوزه‌های کسب‌وکار

حوزه کسب‌وکار چیست؟

حوزه کسب‌وکار (Business Domain) حوزه اصلی فعالیت یک شرکت را تعریف می‌کند. به طور کلی، این خدمتی است که شرکت به مشتریان خود ارائه می‌دهد. به عنوان مثال:

  • FedEx خدمات پست پیشتاز ارائه می‌دهد
  • Starbucks بیشتر به خاطر قهوه‌اش شناخته می‌شود
  • Walmart یکی از شناخته‌شده‌ترین فروشگاه‌های خرده‌فروشی است

یک شرکت می‌تواند در چندین حوزه کسب‌وکار فعالیت کند. به عنوان مثال، آمازون هم خدمات خرده‌فروشی و هم خدمات رایانش ابری ارائه می‌دهد. اوبر یک شرکت اشتراک‌گذاری سواری است که همچنین خدمات تحویل غذا و اشتراک‌گذاری دوچرخه را ارائه می‌دهد.

نکته مهم این است که شرکت‌ها ممکن است اغلب حوزه‌های کسب‌وکار خود را تغییر دهند. یک نمونه کلاسیک از این موضوع نوکیا است که در طول سال‌ها در زمینه‌های متنوعی مانند پردازش چوب، تولید لاستیک، مخابرات و ارتباطات تلفن همراه فعالیت کرده است.

زیردامنه چیست؟

برای دستیابی به اهداف حوزه کسب‌وکار خود، یک شرکت باید در چندین زیردامنه (Subdomain) عمل کند. زیردامنه یک حوزه دقیق‌تر از فعالیت کسب‌وکار است. تمام زیردامنه‌های یک شرکت، حوزه کسب‌وکار آن را تشکیل می‌دهند: خدمتی که به مشتریان خود ارائه می‌دهد.

پیاده‌سازی یک زیردامنه واحد برای موفقیت یک شرکت کافی نیست؛ این فقط یک بلوک سازنده در سیستم کلی است. زیردامنه‌ها باید با یکدیگر تعامل کنند تا به اهداف شرکت در حوزه کسب‌وکارش برسند.

به عنوان مثال، استارباکس ممکن است بیشتر به خاطر قهوه‌اش شناخته شود، اما ساخت یک زنجیره کافی‌شاپ موفق نیازمند بیش از دانستن نحوه تهیه قهوه عالی است. شما همچنین باید املاک را در مکان‌های مؤثر خریداری یا اجاره کنید، پرسنل استخدام کنید، امور مالی را مدیریت کنید و از جمله سایر فعالیت‌ها. هیچ‌یک از این زیردامنه‌ها به تنهایی یک شرکت سودآور نمی‌سازد. همه آن‌ها با هم برای اینکه شرکت بتواند در حوزه(های) کسب‌وکار خود رقابت کند، ضروری هستند.

انواع زیردامنه‌ها

درست همانطور که یک سیستم نرم‌افزاری از اجزای معماری مختلف تشکیل شده است—پایگاه‌های داده، برنامه‌های فرانت‌اند، سرویس‌های بک‌اند و غیره—زیردامنه‌ها ارزش‌های استراتژیک/کسب‌وکاری متفاوتی دارند. طراحی مبتنی بر دامنه بین سه نوع زیردامنه تمایز قائل می‌شود: اصلی (Core)، عمومی (Generic) و پشتیبان (Supporting). بیایید ببینیم از دیدگاه استراتژی شرکت چگونه با هم متفاوت هستند.

زیردامنه‌های اصلی (Core Subdomains)

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

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

زیردامنه‌های اصلی اوبر بر سود خالص آن تأثیر می‌گذارد. اینگونه است که شرکت خود را از رقبایش متمایز می‌کند. این استراتژی شرکت برای ارائه خدمات بهتر به مشتریان و/یا به حداکثر رساندن سودآوری خود است. برای حفظ مزیت رقابتی، زیردامنه‌های اصلی شامل اختراعات، بهینه‌سازی‌های هوشمند، دانش کسب‌وکار یا سایر مالکیت‌های فکری است.

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

منابع مزیت رقابتی: مهم است که توجه کنید زیردامنه‌های اصلی لزوماً فنی نیستند. همه مشکلات کسب‌وکار از طریق الگوریتم‌ها یا سایر راه‌حل‌های فنی حل نمی‌شوند. مزیت رقابتی یک شرکت می‌تواند از منابع مختلفی ناشی شود.

زیردامنه‌های عمومی (Generic Subdomains)

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

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

زیردامنه‌های پشتیبان (Supporting Subdomains)

همانطور که از نام آن پیداست، زیردامنه‌های پشتیبان از کسب‌وکار شرکت پشتیبانی می‌کنند. با این حال، برخلاف زیردامنه‌های اصلی، زیردامنه‌های پشتیبان هیچ مزیت رقابتی ارائه نمی‌دهند.

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

ویژگی متمایز زیردامنه‌های پشتیبان، پیچیدگی منطق کسب‌وکار راه‌حل است. زیردامنه‌های پشتیبان ساده هستند. منطق کسب‌وکار آن‌ها عمدتاً شبیه به صفحات ورود داده و عملیات ETL (استخراج، تبدیل، بارگذاری) است؛ یعنی رابط‌های به اصطلاح CRUD (ایجاد، خواندن، به‌روزرسانی و حذف). این حوزه‌های فعالیت هیچ مزیت رقابتی برای شرکت فراهم نمی‌کنند و بنابراین نیازی به موانع ورود بالا ندارند.

مقایسه زیردامنه‌ها

اکنون که درک بیشتری از سه نوع زیردامنه کسب‌وکار داریم، بیایید تفاوت‌های آن‌ها را از زوایای اضافی بررسی کنیم و ببینیم چگونه بر تصمیمات طراحی استراتژیک نرم‌افزار تأثیر می‌گذارند.

نوع زیردامنهمزیت رقابتیپیچیدگیتغییرپذیریپیاده‌سازیمسئله
اصلی (Core)بلهبالابالاداخلیجالب
عمومی (Generic)خیربالاپایینخرید/اتخاذحل‌شده
پشتیبان (Supporting)خیرپایینپایینداخلی/برون‌سپاریواضح

مزیت رقابتی

تنها زیردامنه‌های اصلی مزیت رقابتی به شرکت ارائه می‌دهند. زیردامنه‌های اصلی استراتژی شرکت برای متمایز کردن خود از رقبایش هستند.

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

زیردامنه‌های پشتیبان نیز موانع ورود پایینی دارند و نمی‌توانند مزیت رقابتی ارائه دهند. معمولاً یک شرکت برایش مشکلی نیست که رقبایش زیردامنه‌های پشتیبان او را کپی کنند—این موضوع بر رقابت‌پذیری آن در صنعت تأثیری نخواهد گذاشت.

پیچیدگی

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

زیردامنه‌های پشتیبان: منطق کسب‌وکار ساده است. اینها عملیات اساسی ETL و رابط‌های CRUD هستند و منطق کسب‌وکار واضح است. اغلب، فراتر از اعتبارسنجی ورودی‌ها یا تبدیل داده‌ها از یک ساختار به ساختار دیگر نمی‌رود.

زیردامنه‌های عمومی: بسیار پیچیده‌تر هستند. باید دلیل خوبی وجود داشته باشد که چرا دیگران قبلاً زمان و تلاش خود را صرف حل این مشکلات کرده‌اند. این راه‌حل‌ها نه ساده و نه بدیهی هستند. به عنوان مثال، الگوریتم‌های رمزنگاری یا مکانیسم‌های احراز هویت را در نظر بگیرید.

زیردامنه‌های اصلی: پیچیده هستند. آن‌ها باید تا حد امکان برای رقبا سخت باشند که کپی کنند—سودآوری شرکت به آن بستگی دارد. به همین دلیل است که از نظر استراتژیک، شرکت‌ها به دنبال حل مسائل پیچیده به عنوان زیردامنه‌های اصلی خود هستند.


فصل اول - بخش دوم: شناسایی مرزهای زیردامنه‌ها و تحلیل دامنه

شناسایی مرزهای زیردامنه‌ها

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

زیردامنه‌ها و انواع آن‌ها توسط استراتژی کسب‌وکار شرکت تعریف می‌شوند: حوزه‌های کسب‌وکار آن و نحوه متمایز شدن برای رقابت با سایر شرکت‌ها در همان زمینه. در اکثریت قریب به اتفاق پروژه‌های نرم‌افزاری، به یک شکل یا شکل دیگر، زیردامنه‌ها “از قبل آنجا هستند”. این به این معنا نیست که همیشه شناسایی مرزهای آن‌ها آسان و مستقیم است. اگر از یک مدیرعامل بخواهید فهرستی از زیردامنه‌های شرکتش را به شما بدهد، احتمالاً با نگاهی خالی روبرو خواهید شد. آن‌ها از این مفهوم آگاه نیستند. بنابراین، باید خودتان تحلیل دامنه را انجام دهید تا زیردامنه‌های موجود را شناسایی و دسته‌بندی کنید.

نقطه شروع خوب: واحدهای سازمانی

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

با این حال، اینها حوزه‌های نسبتاً درشت‌دانه‌ای از فعالیت هستند. به عنوان مثال، دپارتمان خدمات مشتری را در نظر بگیرید. منطقی است که فرض کنیم این یک زیردامنه پشتیبان یا حتی یک زیردامنه عمومی باشد، زیرا این عملکرد اغلب به فروشندگان شخص ثالث برون‌سپاری می‌شود. اما آیا این اطلاعات برای ما کافی است تا تصمیمات طراحی نرم‌افزاری صحیحی بگیریم؟

تقطیر زیردامنه‌ها (Distilling Subdomains)

زیردامنه‌های درشت‌دانه یک نقطه شروع خوب هستند، اما شیطان در جزئیات نهفته است. ما باید مطمئن شویم که اطلاعات مهمی را که در پیچیدگی‌های عملکرد کسب‌وکار پنهان شده است، از دست نمی‌دهیم.

بیایید به مثال دپارتمان خدمات مشتری برگردیم. اگر عملکرد داخلی آن را بررسی کنیم، خواهیم دید که یک دپارتمان خدمات مشتری معمولی از اجزای دقیق‌تر تشکیل شده است، مانند:

  • سیستم Help Desk
  • مدیریت شیفت و زمان‌بندی
  • سیستم تلفنی
  • و غیره

زمانی که به عنوان زیردامنه‌های جداگانه نگاه می‌شوند، این فعالیت‌ها می‌توانند از انواع مختلفی باشند:

  • سیستم‌های Help Desk و تلفنی: زیردامنه‌های عمومی هستند
  • مدیریت شیفت: یک زیردامنه پشتیبان است
  • الگوریتم مسیریابی: ممکن است یک شرکت الگوریتم نابغه‌ای برای مسیریابی حوادث به نمایندگانی که با موارد مشابه در گذشته موفق بوده‌اند، توسعه دهد

الگوریتم مسیریابی نیازمند تحلیل موارد ورودی و شناسایی شباهت‌ها در تجربیات گذشته است—هر دوی اینها وظایف غیربدیهی هستند. از آنجا که الگوریتم مسیریابی به شرکت اجازه می‌دهد تجربه مشتری بهتری نسبت به رقبایش ارائه دهد، الگوریتم مسیریابی یک زیردامنه اصلی است.

1
2
3
4
5
دپارتمان خدمات مشتری
├── سیستم Help Desk (عمومی)
├── سیستم تلفنی (عمومی)
├── مدیریت شیفت (پشتیبان)
└── الگوریتم مسیریابی هوشمند (اصلی) ⭐

از طرف دیگر، ما نمی‌توانیم به طور نامحدود به دنبال بینش‌ها در سطوح پایین‌تر و پایین‌تر از دانه‌بندی باشیم. چه زمانی باید متوقف شوید؟

زیردامنه‌ها به عنوان مجموعه‌ای از Use Case های منسجم

از دیدگاه فنی، زیردامنه‌ها شبیه به مجموعه‌ای از Use Case های مرتبط و منسجم هستند. چنین مجموعه‌ای از Use Case ها معمولاً شامل موارد زیر می‌شوند:

  • همان بازیگر (Actor)
  • موجودیت‌های کسب‌وکاری مشابه
  • همه آن‌ها یک مجموعه داده نزدیک به هم را دستکاری می‌کنند

مثال: دیاگرام Use Case برای درگاه پرداخت کارت اعتباری

Use Case های زیر را در نظر بگیرید که همگی به زیردامنه پرداخت کارت اعتباری تعلق دارند:

1
2
3
4
5
6
7
8
9
10
11
بازیگران:
- مشتری
- سیستم بانکی
- فروشنده

Use Case ها:
├── پردازش پرداخت
├── بررسی اعتبار کارت
├── تأیید تراکنش
├── بازپرداخت
└── گزارش تراکنش‌ها

این Use Case ها به شدت توسط داده‌هایی که با آن کار می‌کنند و بازیگران درگیر به هم مرتبط هستند. بنابراین، همه Use Case ها زیردامنه پرداخت کارت اعتباری را تشکیل می‌دهند.

چه زمانی باید تقطیر را متوقف کنیم؟

می‌توانیم از تعریف “زیردامنه‌ها به عنوان مجموعه‌ای از Use Case های منسجم” به عنوان اصل راهنما برای زمانی که باید به دنبال زیردامنه‌های دقیق‌تر بگردیم استفاده کنیم. اینها دقیق‌ترین مرزهای زیردامنه‌ها هستند.

برای زیردامنه‌های اصلی: تقطیر کامل ضروری است

آیا همیشه باید بکوشید چنین مرزهای زیردامنه‌ای متمرکز را شناسایی کنید؟ برای زیردامنه‌های اصلی قطعاً ضروری است. زیردامنه‌های اصلی مهم‌ترین، متغیرترین و پیچیده‌ترین هستند. ضروری است که آن‌ها را تا حد امکان تقطیر کنیم زیرا این امر به ما اجازه می‌دهد همه عملکردهای عمومی و پشتیبان را استخراج کنیم و تلاش را روی عملکردهای بسیار متمرکزتری سرمایه‌گذاری کنیم.

برای زیردامنه‌های پشتیبان و عمومی: تقطیر آسان‌تر

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

مثال زیر را در نظر بگیرید:

1
2
3
4
5
سیستم Help Desk (عمومی)
├── مدیریت تیکت (عمومی)
├── پایگاه دانش (عمومی)
├── گزارش‌گیری (عمومی)
└── مدیریت SLA (عمومی)

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

تمرکز روی موارد ضروری

زیردامنه‌ها ابزاری هستند که فرآیند اتخاذ تصمیمات طراحی نرم‌افزار را تسهیل می‌کنند. همه سازمان‌ها احتمالاً عملکردهای کسب‌وکاری کاملاً زیادی دارند که مزیت رقابتی آن‌ها را هدایت می‌کنند اما هیچ ارتباطی با نرم‌افزار ندارند. سازنده جواهرات که قبلاً در این فصل بحث کردیم تنها یک مثال است.

هنگام جستجوی زیردامنه‌ها، مهم است که:

  1. عملکردهای کسب‌وکاری که به نرم‌افزار مرتبط نیستند را شناسایی کنید
  2. آن‌ها را به عنوان چنین چیزی تأیید کنید
  3. روی جنبه‌های کسب‌وکار که به سیستم نرم‌افزاری که روی آن کار می‌کنید مرتبط هستند، تمرکز کنید

فصل اول - بخش سوم: تحلیل دامنه با مثالهای عملی - Gigmaster و BusVNext

مثال اول: Gigmaster - شرکت فروش بلیت

معرفی شرکت

Gigmaster یک شرکت فروش و توزیع بلیت است. اپلیکیشن موبایلی آن کتابخانه موسیقی کاربران، حساب‌های سرویس‌های جریانی (مثل Spotify) و پروفایل‌های شبکه‌های اجتماعی را تحلیل می‌کند تا برنامه‌های موسیقی نزدیک را که کاربران علاقه‌مند به حضور در آن‌ها هستند، شناسایی کند.

کاربران Gigmaster نسبت به حریم‌خصوصی آن‌ها توجه زیادی دارند. بنابراین، تمام اطلاعات شخصی کاربران رمزگذاری می‌شوند. علاوه بر این، برای اطمینان از اینکه ترجیحات موسیقی “عجیب‌وغریب” کاربران به هیچ شرایطی فاش نشود، الگوریتم توصیه‌های شرکت منحصراً با داده‌های ناشناس‌شده کار می‌کند.

یک ماژول جدید اخیراً اضافه شده است که به کاربران امکان می‌دهد برنامه‌های موسیقی را که در گذشته حضور داشته‌اند را ثبت کنند، حتی اگر بلیت‌ها از طریق Gigmaster خریداری نشده باشند.

تحلیل دامنه

حوزه کسب‌وکار

حوزه کسب‌وکار Gigmaster فروش بلیت است. این خدمتی است که شرکت به مشتریانش ارائه می‌دهد.

زیردامنه‌های اصلی (Core Subdomains)

مزیت رقابتی اصلی Gigmaster الگوریتم توصیه‌های آن است. این الگوریتم توانایی شرکت را بر میل و علایق اضافی کاربران تا سطح دقیق دارند:

  • الگوریتم توصیه‌های هوشمند: این قلب سیستم است و کاربران را به برنامه‌هایی راهنمایی می‌کند که واقعاً علاقه‌مند هستند
  • ناشناس‌سازی داده‌ها: شرکت بر روی حریم‌خصوصی تأکید زیادی دارد و این عملکرد رقابتی است زیرا کاربران می‌دانند اطلاعات آن‌ها محافظت‌شده است
  • تجربه کاربری موبایل (UX): رابط کاربری تمیز و قابل استفاده یک عامل کلیدی در جذب و حفظ کاربران است

بنابراین، زیردامنه‌های اصلی Gigmaster عبارت‌اند از:

  • الگوریتم توصیه‌ها ⭐
  • ناشناس‌سازی داده‌ها ⭐
  • اپلیکیشن موبایل ⭐

زیردامنه‌های عمومی (Generic Subdomains)

اینها مسائلی هستند که هر شرکت فروش حل می‌کند و راه‌حل‌های آماده وجود دارند:

  • رمزنگاری: برای رمزگذاری تمام داده‌ها
  • حسابداری: چون شرکت در تجارت فروش است
  • Clearing: برای شارژ کردن مشتریان
  • احراز هویت و مجوزدهی: برای شناسایی کاربران

زیردامنه‌های پشتیبان (Supporting Subdomains)

اینها عملکردهای ساده هستند که به سیستم اصلی کمک می‌کنند اما هیچ مزیت رقابتی ندارند:

  • ادغام با سرویس‌های موسیقی جریانی: درخواست داده‌ها از Spotify، Apple Music و غیره
  • ادغام با شبکه‌های اجتماعی: خواندن پروفایل‌ها
  • ماژول برنامه‌های حضور یافته: ذخیره داده‌های تاریخی برنامه‌هایی که کاربر حضور داشته است

تصمیمات طراحی استراتژیک

از دانستن اینکه کدام زیردامنه‌ها در کدام دسته قرار دارند، می‌توانیم تصمیمات طراحی استراتژیک بگیریم:

زیردامنهنوعتصمیم طراحی
الگوریتم توصیه‌هااصلیباید با تکنیک‌های پیشرفته برنامه‌نویسی توسط تیم در‌خانه پیاده‌سازی شود
ناشناس‌سازیاصلیباید با دقت بالا در‌خانه پیاده‌سازی شود (حتی ممکن است متخصص امنیت لازم باشد)
تجربه موبایلاصلیطراحی رابط کاربری باید توسط تیم ماهر انجام شود
رمزنگاری، حسابداری، Clearingعمومیمی‌توان از راه‌حل‌های آماده یا کتابخانه‌های منتشرشده استفاده کرد
احراز هویتعمومیاستفاده از سرویس‌های مثل Auth0 یا OAuth معقول است
ادغام با سرویس‌های خارجیپشتیبانمی‌تواند برون‌سپاری شود
ماژول برنامه‌های حضور یافتهپشتیبانمی‌تواند برون‌سپاری شود

مثال دوم: BusVNext - شرکت حمل‌ونقل عمومی

معرفی شرکت

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

مشتری BusVNext می‌تواند از طریق اپلیکیشن موبایل یک سفر سفارش دهد. در زمان حضور برنامه‌ریزی‌شده، مسیر یک اتوبوس نزدیک به صورت لحظه‌ای تنظیم می‌شود تا مسافر را در زمان تعیین‌شده برداشت کند.

چالش اصلی BusVNext پیاده‌سازی الگوریتم مسیریابی بود. الزامات آن یک گونه از مسئله فروشنده دوره‌گرد (Travelling Salesman Problem) است. منطق مسیریابی به طور مستمر تنظیم و بهینه‌سازی می‌شود. مثلاً آمارها نشان می‌دهند دلیل اصلی لغو سفرها زمان انتظار زیاد برای اتوبوس است. بنابراین شرکت الگوریتم مسیریابی را برای اولویت دادن به برداشت سریع تغییر داد، حتی اگر این معنای تاخیری بیشتر در رسیدن باشد.

تحلیل دامنه

حوزه کسب‌وکار

حوزه کسب‌وکار BusVNext حمل‌ونقل عمومی بهینه‌شده است.

زیردامنه‌های اصلی (Core Subdomains)

مزیت رقابتی اصلی BusVNext از بین رفتن یا کاهش دادن این مسائل است:

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

بنابراین، زیردامنه‌های اصلی BusVNext عبارت‌اند از:

  • مسیریابی ⭐
  • تحلیل داده‌ها ⭐
  • تجربه کاربری موبایل ⭐
  • مدیریت ناوگان ⭐

زیردامنه‌های عمومی (Generic Subdomains)

  • داده‌های ترافیک و هشدارهای بلادرنگ: شرکت از شرکت‌های شخص ثالث برای دریافت اطلاعات ترافیکی استفاده می‌کند
  • حسابداری: مدیریت درآمد‌ها
  • صورتحساب: ایجاد صورتحسابهایی برای مشتریان
  • احراز هویت و مجوزدهی

زیردامنه‌های پشتیبان (Supporting Subdomains)

  • مدیریت تخفیف‌ها و پیش‌نهادهای ویژه: این ماژول از نظر منطق کسب‌وکار بسیار ساده است—فقط رابط کاربری CRUD برای مدیریت کدهای کوپن فعال. شرکت این تخفیف‌ها را برای جذب مشتریان جدید و تعدیل تقاضا در اوقات اوج و کم استفاده می‌کند

تصمیمات طراحی استراتژیک

زیردامنهنوعتصمیم طراحی
الگوریتم مسیریابیاصلیباید با بالاترین سطح تکنیک‌های مهندسی در‌خانه پیاده‌سازی شود
تحلیل داده‌هااصلیباید درون‌سازی شود (ممکن است به Machine Learning و Data Science نیاز باشد)
مدیریت ناوگاناصلیمتخصصان و سیستم‌های پیشرفته لازم است
تجربه کاربریاصلیتوسط تیم ماهر طراحی شود
داده‌های ترافیکعمومیاز سرویس‌های شخص ثالث (مثل Google Maps API) استفاده شود
حسابداری و صورتحسابعمومیاز نرم‌افزار حسابداری آماده یا سرویس ابری استفاده شود
احراز هویتعمومیاستفاده از سرویس‌های مثل OAuth یا شرکای احراز هویت
مدیریت تخفیف‌هاپشتیبانمی‌تواند برون‌سپاری شود یا توسط تیم درحال‌آموزش پیاده‌سازی شود
بررسی ترافیکعمومیخریداری از شرکت‌های بیرونی

خلاصه تحلیل‌های دو مثال

اهمیت این تحلیل‌ها

این دو مثال نشان می‌دهند که چگونه یک تحلیل دامنه صحیح می‌تواند تصمیمات زیادی را هدایت کند:

  1. بودجه‌بندی و تخصیص منابع: منابع بیشتر باید به زیردامنه‌های اصلی اختصاص داده شود

  2. تشکیل تیم: تیم‌های ماهر برای سیستم‌های اصلی، و تیم‌های کم‌تجربه برای سیستم‌های پشتیبان

  3. معماری سیستم: زیردامنه‌های اصلی نیاز به معماری و طراحی پیچیده‌تری دارند

  4. برون‌سپاری یا توسعه درون‌سازی: تصمیم واضح درباره آنچه باید درون‌سازی شود و آنچه باید خریداری شود


کارشناسان دامنه چه کسانی هستند؟

کارشناس دامنه یعنی فرد (یا افرادی) که ریزه‌کاری‌های کسب‌وکاریِ «دامنه‌ای که قرار است نرم‌افزارش را بسازیم» را عمیقاً می‌شناسند و مرجع اصلی دانش همان کسب‌وکار هستند. این افراد نه تحلیلگر نیازمندی‌اند و نه مهندس نرم‌افزار؛ آن‌ها نماینده «کسب‌وکار» هستند و دانشی که تحلیلگران و مهندسان با آن کار می‌کنند، نهایتاً از ذهن و تجربه همین افراد می‌آید. به بیان دقیق‌تر، تحلیلگرها و مهندسان تلاش می‌کنند مدل ذهنیِ کارشناس دامنه از کسب‌وکار را به نیازمندی‌ها و سپس به کد تبدیل کنند.

چرا نقششان حیاتی است؟

هدف نرم‌افزار حل مسائل و نیازهای همان کسب‌وکار است، پس اگر منبع دانش درست در فرایند حضور نداشته باشد، تیم فنی ناچار می‌شود بر اساس حدس، برداشت‌های ناقص یا اصطلاحات مبهم تصمیم‌گیری کند. کتاب تأکید می‌کند که دانش دامنه «منشأ» دارد و آن منشأ، کارشناسان دامنه‌اند؛ بنابراین کیفیت طراحی و پیاده‌سازی به کیفیت ارتباط و انتقال دانش از آن‌ها وابسته است.

چه کسانی معمولاً کارشناس دامنه‌اند؟

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

نکته آموزشی برای شما

از همین‌جا یک پیام عملی برای طراحی نرم‌افزار بیرون می‌آید: وقتی «زیردامنه‌ها» را تشخیص می‌دهید، باید هم‌زمان مشخص کنید برای هر زیردامنه چه کسی «مرجع حقیقت» است (چه کسی واقعاً جواب درست را می‌داند). در عمل، هرچه زیردامنه به سمت «اصلی/رقابتی» بودن می‌رود، نیاز به دسترسی نزدیک‌تر و دائمی‌تر به کارشناسان دامنه بیشتر می‌شود، چون همان‌جا ابهام و تغییر زیاد است.


تمرین‌های ۱ تا ۳ (چندگزینه‌ای)

۱) کدام زیردامنه(ها) هیچ مزیت رقابتی ایجاد نمی‌کنند؟

پاسخ: عمومی و پشتیبان.
منطق: زیردامنهٔ اصلی همان جایی است که شرکت «متفاوت» عمل می‌کند و مزیت رقابتی می‌سازد؛ اما زیردامنهٔ عمومی و پشتیبان قرار نیست شرکت را از رقبا متمایز کنند.

۲) برای کدام زیردامنه ممکن است همهٔ رقبا از راه‌حل یکسان استفاده کنند؟

پاسخ: زیردامنهٔ عمومی.
منطق: عمومی یعنی مسئله‌ای حل‌شده و استاندارد که شرکت‌ها معمولاً از راه‌حل‌های آماده/متداول استفاده می‌کنند (مثل احراز هویت یا رمزنگاری).

۳) کدام زیردامنه انتظار می‌رود بیشترین تغییر را داشته باشد؟

پاسخ: زیردامنهٔ اصلی.
منطق: چون منبع مزیت رقابتی است، دائماً باید تکامل پیدا کند؛ شرکت‌ها مدام آن را بهینه می‌کنند تا عقب نمانند.

تمرین‌های ۴ تا ۷ (سناریوی WolfDesk)

کتاب می‌گوید WolfDesk یک شرکت «سیستم مدیریت تیکت‌های هلپ‌دسک» ارائه می‌دهد. برای حل تمرین‌ها، ابتدا یک روش گام‌به‌گام داشته باشید:

1) اول «خدمت اصلی به مشتری» را پیدا کنید ⇒ این می‌شود حوزهٔ کسب‌وکار.
2) بعد بپرسید «چه چیزی باعث برتری این شرکت نسبت به رقبا می‌شود؟» ⇒ این می‌شود زیردامنهٔ اصلی.
3) سپس «چه چیزهایی استاندارد و قابل خریدن است؟» ⇒ زیردامنهٔ عمومی.
4) و در نهایت «چه چیزهایی لازم است ولی مزیت رقابتی نیست و منطق ساده دارد؟» ⇒ زیردامنهٔ پشتیبان.

حالا پاسخ‌ها:

۴) حوزهٔ کسب‌وکار WolfDesk چیست؟

به احتمال زیاد: مدیریت تیکت‌های پشتیبانی/هلپ‌دسک (Help Desk Ticket Management).
یعنی ارزش پیشنهادی شرکت: دریافت، پیگیری، اولویت‌بندی و حل درخواست‌های پشتیبانی مشتریان.

۵) زیردامنه(های) اصلی WolfDesk چیست؟

این بستگی دارد WolfDesk دقیقاً با چه چیزی رقابت می‌کند، اما معمولاً موارد زیر می‌تواند «اصلی» باشد اگر واقعاً عامل تمایز شرکت باشد:

  • الگوریتم/منطق مسیریابی هوشمند تیکت‌ها (تخصیص تیکت به بهترین کارشناس بر اساس مهارت/تجربه/موضوع).
  • اتوماسیون گردش‌کار و قوانین پیچیدهٔ SLA (اگر واقعاً نوآورانه و خاص باشد).
  • تجربهٔ کاربری بسیار برتر برای اپراتورها و مدیران (اگر مزیت رقابتی اصلی محصول باشد).

نکتهٔ کلیدی: «فقط داشتن صفحهٔ ثبت تیکت» معمولاً مزیت رقابتی نیست؛ مزیت رقابتی در هوشمندی، اتوماسیون، و کیفیت تجربهٔ کاربری یا بینش‌های تحلیلی است.

۶) زیردامنه(های) پشتیبان WolfDesk چیست؟

مواردی که لازم‌اند اما معمولاً منطق ساده‌تری دارند و شرکت با آن‌ها متمایز نمی‌شود، مثل:

  • مدیریت کاربران سازمانی/تیم‌ها/شیفت‌ها (اگر صرفاً CRUD باشد).
  • تنظیمات پروژه‌ها، دسته‌بندی‌ها، برچسب‌ها.
  • مدیریت قالب‌های پاسخ و متن‌های آماده (اگر ساده باشد). اینها معمولاً همان «کارهای ضروری ولی نه استراتژیک» هستند.

۷) زیردامنه(های) عمومی WolfDesk چیست؟

موارد استاندارد و قابل خرید/اتخاذ که همه دارند:

  • احراز هویت و مجوزدهی (SSO، OAuth، SAML و…).
  • ارسال ایمیل/پیامک/نوتیفیکیشن (اغلب از سرویس‌های عمومی استفاده می‌شود).
  • پرداخت، صورتحساب و حسابداری (اگر SaaS باشد و فروش اشتراک داشته باشد).
  • گاهی جست‌وجوی متن یا گزارش‌گیری عمومی هم می‌تواند عمومی تلقی شود اگر راه‌حل‌های آمادهٔ قوی وجود داشته باشد.


فصل دوم: کشف دانش دامنه - بخش اول: مسائل کسب‌وکاری و اهمیت ارتباط

مقدمهٔ فصل دوم با یک نقل‌قول الهام‌بخش از Alberto Brandolini شروع می‌شود:

«کدی که از سوءفهم توسعه‌دهندگان به‌وجود می‌آید، نه از دانش صحیح کارشناسان دامنه، وارد محیط تولیدی می‌شود.»

این جمله خلاصهٔ کل فصل دوم است: مشکل اصلی پروژه‌های نرم‌افزاری انتقال دانش است.

تاریخچه‌ای از فصل یک

فصل قبل (فصل اول) بر روی سطح استراتژیک تمرکز داشت: تحلیل حوزه کسب‌وکار، شناسایی زیردامنه‌ها و انواع آن‌ها.

فصل دوم سطح عمیق‌تری را مطالعه می‌کند: درون یک زیردامنه چه اتفاقی رخ می‌دهد؟ منطق کسب‌وکاری و فرآیندهایش چگونه است؟

مسائل کسب‌وکاری (Business Problems)

اول باید دقیق کنیم: «مسئلهٔ کسب‌وکار» با «مسئلهٔ ریاضی» متفاوت است.

یک مسئلهٔ ریاضی چیزی است که می‌توانید حل کنید و تمام باشد. اما مسئلهٔ کسب‌وکار چیزی است که شرکت‌ها مدام با آن دست‌وپنجه‌نرم می‌کنند.

نمونه‌های مسائل کسب‌وکاری:

  • بهینه‌سازی جریان‌های کاری و فرآیندها
  • کاهش کار دستی
  • مدیریت منابع
  • پشتیبانی در تصمیم‌گیری
  • مدیریت و نظم‌دهی داده‌ها

این مسائل در سطح حوزهٔ کسب‌وکار کلان و در سطح زیردامنه‌های خاص نمایان می‌شوند.

نمونه ملموس: شرکت FedEx (از فصل یک) برای حل مسئلهٔ کسب‌وکاریِ مشتریانش («نیاز به فرستادن بسته‌ها در زمان محدود») فرآیند حمل‌ونقل را بهینه‌سازی کرد.

کشف دانش (Knowledge Discovery)

این بخش هسته‌ی اصلی فصل دوم است:

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

اما این دانش از کجا می‌آید؟ از کارشناسان دامنه (Domain Experts).

نکتهٔ حیاتی:

ما (توسعه‌دهندگان و معماران) نباید کارشناس دامنه شویم. این غیرعملی است و هدف نیست.

اما باید کارشناسان دامنه را درک کنیم و از همان اصطلاحات کسب‌وکاری که آن‌ها استفاده می‌کنند، استفاده کنیم.

دلیل:

نرم‌افزار باید از همان روش فکری کارشناس دامنه (مدل ذهنی آن‌ها) تبعیت کند.

اگر ما نفهمیم چرا کسب‌وکار این‌طور کار می‌کند، راه‌حل‌های غلط یا ناقصخواهیم ساخت. و نتیجهٔ آن شکستِ پروژه است.


فصل دوم - بخش دوم: ارتباط و چالش‌های شکاف دانش

مسئلهٔ ارتباط در پروژه‌های نرم‌افزاری

پس از فهمیدن اینکه چرا به دانش حوزه نیاز داریم، حالا بیایید واقعیت‌های سخت را ببینیم: چگونه این دانش از کارشناسان دامنه به توسعه‌دهندگان برسد؟

تقریباً تمام پروژه‌های نرم‌افزاری نیاز به همکاری افراد با نقش‌های متفاوت دارند: کارشناسان دامنه، مالکین محصول، مهندسان نرم‌افزار، طراحان UI/UX، مدیران پروژه و تست‌کنندگان.

موفقیت پروژه بستگی به این دارد که این گروه‌های مختلف چقدر خوب با هم کار کنند.

سؤالات اساسی‌ای که باید پاسخ داد:

  • آیا همه نفر‌ها درک یکسانی از مسئله‌ای که حل می‌کنند دارند؟
  • آیا درباره راه‌حل فرضیات متناقضی دارند؟
  • آیا از نیازمندی‌های تابعی و غیرتابعی بر سر یک نظر هستند؟

توافق و هم‌راستایی درباره تمام موارد مرتبط با پروژه برای موفقیت ضروری است.

مشکل: فقدان ارتباط مستقیم

تحقیقات نشان می‌دهند که بخش اعظم شکست‌های پروژه‌های نرم‌افزاری از ارتباط ناکافی ناشی می‌شود.

با این حال، به تعجب خیلی، ارتباط مؤثر در اکثر پروژه‌های نرم‌افزاری مشاهده نمی‌شود.

مسئلهٔ واسطه‌گران

معمولاً افراد کسب‌وکار و مهندسان هیچ ارتباط مستقیمی با یکدیگر ندارند. به جای آن، دانش حوزه از واسطه‌گران (میانجیان) منتقل می‌شود:

  • تحلیلگران سیستم
  • مالکین محصول
  • مدیران پروژه

مشکل این رویکرد: هرجا که ترجمه‌ای صورت گیرد، اطلاعات از دست می‌رود.

دانش حوزه‌ای که برای حل مسائل کسب‌وکاری ضروری است در راه به سراغ مهندسان کاهش یافته یا تحریف می‌شود.

مسئلهٔ چندگانه‌بودن ترجمه‌ها

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

  1. دانش حوزه → 2. مدل تحلیل‌شده
  2. مدل تحلیل‌شده → 3. طراحی سیستم
  3. طراحی سیستم → 4. کد پیاده‌سازی‌شده

مسئلهٔ اضافی اینکه اسناد و مستندات دیگر سریع از تاریخ می‌افتند و کود باقی‌مانده منبع اطلاع برای مهندسانی است که بعداً روی پروژه کار می‌کنند.

بازی تلفن (Telephone Game)

این فرآیند دقیقاً مثل بازی کودکانه “تلفن” است:

  • نفر اول پیام‌ای را در سر شخص دوم زمزمه می‌کند
  • دوم برای سوم تکرار می‌کند
  • و … تا آخر
  • نفر آخر پیام را برای همه اعلام می‌کند
  • پیام نهایی اغلب کاملاً متفاوت از اصلی است

نتیجه: توسعه‌دهندگان به جای حل مسئلهٔ درست، یا راه‌حل غلط را می‌سازند، یا راه‌حل درستی را برای مسئلهٔ اشتباه پیاده می‌کنند.

در هر دو صورت، پروژه ناکام می‌ماند.

سؤال کلیدی: چطور می‌توانیم این ترجمه‌های بی‌پایان و اطلاعات‌گم‌شدهٔ آن را متوقف کنیم؟

پاسخ: زبان یکپارچه (Ubiquitous Language)


فصل دوم - بخش سوم: زبان یکپارچه (Ubiquitous Language) - راه‌حل DDD

مشکل و راه‌حل

بعد از بررسی مسائل ارتباطی و بازی تلفنی (ترجمه‌های متوالی)، حالا راه‌حل DDD را معرفی می‌کنم:

«اگر طرف‌های مختلف باید کارآمد ارتباط کنند، به جای اینکه روی ترجمه‌ها تکیه کنند، باید یک زبان واحد صحبت کنند.»

تعریف زبان یکپارچه (Ubiquitous Language)

زبان یکپارچه یعنی:

تمام افراد درگیر در پروژه (مهندسان نرم‌افزار، مالکین محصول، کارشناسان دامنه، طراحان UI/UX) باید از یک **زبانِ واحد و مشترک برای توصیف دامنهٔ کسب‌وکار استفاده کنند.**

ویژگی کلیدی:

این زبان باید زبان کسب‌وکار باشد، نه زبان فنی.

مثال‌های درست (زبان کسب‌وکار):

  • «یک کمپین تبلیغاتی می‌تواند مواد خلاقانهٔ مختلف را نمایش دهد.»
  • «یک کمپین تنها می‌تواند منتشر شود اگر حداقل یک placement فعال داشته باشد.»
  • «کمیسیون‌های فروش پس از تصویب تراکنش‌ها محاسبه می‌شود.»

مثال‌های نادرست (زبان فنی):

  • «iframe تبلیغات یک فایل HTML را نمایش می‌دهد.»
  • «یک کمپین می‌تواند منتشر شود اگر حداقل یک record در جدول active_placements داشته باشد.»
  • «کمیسیون‌ها بر اساس related records از جداول transactions و approved_sales محاسبه می‌شود.»

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

ویژگی‌های زبان یکپارچه

۱. دقت و سازگاری (Precision & Consistency)

هر اصطلاح در زبان یکپارچه باید یک معنای واحد داشته باشد.

مثال از عدم‌سازگاری:

اگر اصطلاح «policy» در کسب‌وکار دو معنای متفاوت داشته باشد:

  • معنی ۱: «قانون نظارتی»
  • معنی ۲: «قرارداد بیمه»

حل: به جای استفاده از یک اصطلاح مبهم، دو اصطلاح مختلف استفاده کنید:

  • «regulatory rule»
  • «insurance contract»

مثال از اصطلاحات مترادف:

اگر برای مفهوم یکسان اصطلاحات متفاوتی استفاده شود:

  • «user»
  • «visitor»
  • «account»

مشکل: این اصطلاحات ممکن است مفاهیم متفاوتی را نشان دهند. مثلاً:

  • «visitor» = کاربرانِ ثبت‌نام‌نشده (فقط برای تحلیل)
  • «account» = کاربرانِ ثبت‌نام‌شده (که سیستم را واقعاً استفاده می‌کنند)

حل: هر اصطلاح را در زمینهٔ خاص خود استفاده کنید و نام‌های مختلف برای مفاهیم مختلف بگذارید.

مدل‌سازی دامنهٔ کسب‌وکار

یک مدل چیست؟

مدل نسخهٔ ساده‌شدهٔ یک چیز یا پدیده‌ای است که اهداف خاصی را در نظر گرفته و سایر جنبه‌ها را نادیده می‌گیرد.

مثال: نقشه‌ها

نقشه‌های مختلف (نقشهٔ جاده‌ای، نقشهٔ زمان، نقشهٔ زیرزمینی) هیچ‌یک دقیقِ همهٔ جزئیات زمین را نشان نمی‌دهند. اما هر یک برای مقصودِ خود مناسب است.

مدل‌سازی موثر

یک مدل موثر:

  • فقط جزئیات مورد نیاز برای حل مسئلهٔ خاص را شامل می‌شود
  • سایر جزئیات غیرضروری را کنار می‌گذارد
  • هدف آن حل مسئله است، نه کپی دقیق از دنیای واقعی

زبان یکپارچه = مدل دامنه

زمانی که یک زبان یکپارچه را تعریف می‌کنید، شما در حقیقت یک مدل از دامنهٔ کسب‌وکار می‌سازید.

این مدل باید:

  • ذهن‌های کارشناسان دامنه را منعکس کند
  • موجودیت‌های کسب‌وکاری و رفتار آن‌ها را شامل شود
  • روابط علت‌ومعلول را نشان دهد
  • محدودیت‌های کسب‌وکاری (invariants) را تعریف کند

اما: مدل شما نباید هر جزئیهٔ دامنه را شامل شود؛ فقط آنقدر جزئیات شامل کنید که برای حل مسئلهٔ خاص کافی باشد.

کوشش مستمر

تشکیل یک زبان یکپارچه یک فرآیند جاری است، نه یک‌باره:

  • فقط تعامل با کارشناسان دامنه می‌تواند عدم‌دقت‌ها، فرض‌های غلط، یا سوءفهم‌های کلی را فاش کند
  • تمام تیم باید مستمراً از این زبان در تمام ارتباطات پروژه استفاده کنند: گفت‌وگو، اسناد، تست‌ها، کد
  • همیشه باید اعتبارسنجی و تکامل داده شود: وقتی درک عمیق‌تری از دامنه حاصل شود، زبان باید با آن تطبیق یابد

فصل ۳: مدیریت پیچیدگی دامین (Managing Domain Complexity) - بخش اول

چالش مدل‌های ناسازگار (Inconsistent Models)

فصل با یادآوری اهمیت «زبان مشترک» (Ubiquitous Language) آغاز می‌شود. هدف ما این است که مدلی بسازیم که دقیقاً بازتاب‌دهنده‌ی مدل ذهنی متخصصان کسب‌وک‌کار (Domain Experts) باشد. اما یک مشکل بزرگ وجود دارد: ذهنیت متخصصان کسب‌وکار همیشه یکپارچه نیست.

نویسنده برای توضیح این مشکل، مثال شرکت «بازاریابی تلفنی» (Telemarketing) را که در فصل‌های قبل مطرح شده بود، دوباره باز می‌کند. در این شرکت دو دپارتمان اصلی وجود دارد:

  1. دپارتمان مارکتینگ (Marketing): مسئول تولید سرنخ (Lead) از طریق تبلیغات آنلاین.
  2. دپارتمان فروش (Sales): مسئول تماس با این سرنخ‌ها و تبدیل آن‌ها به مشتری.

نکته‌ی کلیدی و ظریف اینجاست که واژه‌ی Lead (سرنخ) در این دو دپارتمان معنای کاملاً متفاوتی دارد:

  • از دید مارکتینگ: یک Lead صرفاً یک «رویداد» یا نوتیفیکیشن است که می‌گوید “یک نفر علاقه نشان داده است”. همین که اطلاعات تماس فرد را داشته باشند، کافی است.
  • از دید فروش: یک Lead یک موجودیت پیچیده و بلندمدت است. دارای چرخه حیات است (تماس گرفته شد، علاقه‌مند بود، جلسه ست شد، و غیره). برای آن‌ها Lead یک فرآیند است، نه فقط یک رخداد ساده.

دوراهی طراحی (The Design Dilemma)

چگونه باید یک «زبان مشترک» برای این سیستم ساخت؟

  • اگر مدل پیچیده‌ی فروش را به مارکتینگ تحمیل کنیم، سیستم مارکتینگ را با جزئیاتی که نیازی به آن‌ها ندارد (مثل وضعیت مذاکره) پیچیده و سربار کرده‌ایم (Over-engineered).
  • اگر مدل ساده‌ی مارکتینگ را مبنا قرار دهیم، نیازهای دپارتمان فروش برای مدیریت فرآیند فروش برآورده نمی‌شود (Under-engineered).

در روش‌های سنتی، معمولاً سعی می‌شد یک مدل واحد و بزرگ (Single Model) برای کل سازمان ساخته شود (مثلاً نمودارهای ERD عظیم که تمام دیوارهای اتاق را می‌پوشاند). نویسنده تأکید می‌کند که این مدل‌ها مصداق بارز ضرب‌المثل “همه کاره و هیچ کاره” هستند. آن‌ها آنقدر شلوغ و پیچیده می‌شوند که نگهداری آن‌ها کابوس است و هیچکس دقیقاً نمی‌داند چه خبر است.

راهکار: الگوی Bounded Context

راه‌حل Domain-Driven Design برای این مشکل ساده اما انقلابی است: به جای تلاش برای ساختن یک مدل بزرگ و کامل، زبان مشترک را به چندین زبان کوچکتر تقسیم کنید و هر کدام را به یک «کانتکست» (Context) مشخص اختصاص دهید.

به این الگو، Bounded Context گفته می‌شود.

در مثال بالا، ما باید دو کانتکست جداگانه داشته باشیم:

  1. کانتکست مارکتینگ: در اینجا Lead معنای ساده‌ی خودش را دارد.
  2. کانتکست فروش: در اینجا Lead معنای پیچیده و فرآیند-محور خودش را دارد.

تا زمانی که واژه‌ی Lead در داخل مرزهای هر کانتکست معنای واحد و مشخصی داشته باشد، ابهامی وجود نخواهد داشت. در DDD، ما این تضادها را پنهان نمی‌کنیم، بلکه آن‌ها را به عنوان بخش صریحی از طراحی مدل می‌پذیریم.

مرزهای مدل (Model Boundaries)

نویسنده یک جمله‌ی بسیار عمیق در این بخش دارد:

“یک مدل نمی‌تواند بدون مرز (Boundary) وجود داشته باشد؛ در غیر این صورت گسترش می‌یابد تا به کپیِ خودِ دنیای واقعی تبدیل شود.”

همانطور که نقشه‌های جغرافیایی انواع مختلفی دارند (نقشه مترو، نقشه ناهمواری‌ها، نقشه سیاسی) و هر کدام فقط در کانتکست خودشان مفید هستند، مدل‌های نرم‌افزاری هم باید مرز مشخص داشته باشند. یک مدل در کانتکست مارکتینگ ممکن است برای کانتکست فروش کاملاً بی‌فایده یا گمراه‌کننده باشد.

بنابراین، Bounded Context یعنی مرزِ اعمال‌شدنِ یک مدل خاص.

تعریف دقیق‌تر زبان مشترک (Ubiquitous Language Refined)

با معرفی Bounded Context، تعریف ما از «زبان مشترک» کامل‌تر می‌شود. زبان مشترک به این معنی نیست که یک اصطلاح در کل سازمان یک معنی داشته باشد (که عملاً غیرممکن است). بلکه به این معنی است که آن اصطلاح در داخل مرزهای Bounded Context خودش، معنایی واحد، شفاف و بدون ابهام داشته باشد.

محدوده یا اسکوپ (Scope)

اندازه‌ی یک Bounded Context چقدر باید باشد؟ این یک تصمیم استراتژیک در طراحی است. مرزها می‌توانند وسیع باشند (شامل چندین زیرمجموعه کاری) یا باریک و متمرکز.

  • مزیت مرزهای وسیع: یکپارچگی بیشتر بین اجزای داخلی.
  • مزیت مرزهای باریک (کوچک): مدیریت راحت‌تر مدل‌ها و امکان توسعه مستقل.

اما نویسنده هشدار می‌دهد که نباید در کوچک کردن کانتکست‌ها زیاده‌روی کرد. اگر یک عملکرد منسجم (Coherent Functionality) را بی‌دلیل به چند کانتکست خرد کنید، سربار یکپارچه‌سازی (Integration Overhead) افزایش می‌یابد.


فصل ۳: مدیریت پیچیدگی دامین - بخش دوم

Bounded Context در مقابل Subdomain

این قسمت از فصل به یک سوال مهم می‌پردازد: آیا Bounded Context و Subdomain یکی هستند؟

در فصل‌های قبل درباره‌ی Subdomain (core، generic، supporting) خواندیم. الآن باید متوجه شویم که این دو مفهوم کاملاً متفاوت هستند، اگرچه اغلب اوقات با هم اشتباه گرفته می‌شوند.

Subdomain چیست؟

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

برای مثال:

  • در بانک، «پردازش وام» و «مدیریت حساب بانکی» دو Subdomain هستند.
  • در Uber، «مطابقت سرایندگی با مسافر» و «پردازش پرداخت» دو Subdomain متفاوت‌اند.

نکته مهم: Subdomain‌ها بر اساس استراتژی کسب‌وکار شرکت تعریف می‌شوند، نه بر اساس تصمیمات فنی ما.

Bounded Context چیست؟

Bounded Context برعکس، یک عنصر نرم‌افزاری و طراحی‌شده است. ما (تیم نرم‌افزار) تصمیم می‌گیریم که سیستم را به چه بخش‌هایی تقسیم کنیم، چگونه مدل‌ها را سازمان‌دهیم، و کدام اجزا مستقل‌اند.

نکته مهم: Bounded Context‌ها از جانب مهندسان نرم‌افزار طراحی می‌شوند، نه اینکه توسط کسب‌وکار تعریف شوند.

فاصله بین این دو

اگرچه Subdomain و Bounded Context مفاهیم متفاوتی هستند، اما می‌توانند با هم ارتباط داشته باشند. نویسنده سناریوهای مختلفی را نشان می‌دهد:

سناریو ۱: یک Bounded Context بزرگ برای کل سیستم

![سیستم یکپارچه] اگر سیستم کوچک است، ممکن است یک Bounded Context واحد برای کل سیستم کافی باشد، حتی اگر چندین Subdomain داخل آن وجود داشته باشد.

سناریو ۲: Bounded Context‌ها براساس تضاد در مدل‌ها

![Bounded Contexts متعدد] زمانی که متخصصان کسب‌وکار مدل‌های متفاوتی از یک مفهوم دارند (مثل مثال Lead)، ما Bounded Context‌های جداگانه می‌سازیم.

سناریو ۳: Bounded Context‌ها همسو با Subdomain‌ها

![Aligned Boundaries] در بسیاری از موارد عملی، هر Subdomain در یک Bounded Context جداگانه پیاده‌سازی می‌شود.

نکته حیاتی: نیست که یک Bounded Context باید دقیقاً با یک Subdomain تطابق داشته باشد. ما می‌توانیم:

  • یک Subdomain را در چند Bounded Context مختلف مدل‌سازی کنیم (اگر نیاز به حل‌های مختلفی برای مسائل مختلف داشته باشیم).
  • چندین Subdomain را در یک Bounded Context ادغام کنیم (اگر آن‌ها منسجم و بسیار تنگاتنگ باشند).

مرزها: فیزیکی و مالکانه (Physical and Ownership Boundaries)

تا الآن فقط در مورد Bounded Context به‌عنوان یک مرز مفهومی (برای سازمان‌دهی مدل) صحبت کردیم. اما Bounded Context‌ها دارای ابعاد فیزیکی و سازمانی نیز هستند.

مرزهای فیزیکی (Physical Boundaries)

فیزیکی به این معنی است که هر Bounded Context باید به‌عنوان یک موجودیت مستقل و قابل استقلال پیاده‌سازی شود. به‌عبارت دیگر:

  • هر Bounded Context یک پروژه جداگانه است.
  • هر Bounded Context می‌تواند با Stack فنی متفاوت پیاده‌سازی شود (اگر نیاز داشته باشد).
  • هر Bounded Context مستقل از دیگری نسخه‌ برداری می‌شود (versioning).
  • هر Bounded Context جداگانه Deploy می‌شود.

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

مرزهای مالکانه (Ownership Boundaries)

نویسنده یک قول معروف را نقل می‌کند: “Good fences do indeed make good neighbors” (نرده‌های خوب واقعاً همسایگان خوب می‌سازند).

در تیم‌های نرم‌افزار نیز همین اصل صدق می‌کند:

قانون طلایی: هر Bounded Context تنها توسط یک تیم می‌تواند مدیریت و توسعه داده شود. نه کمتر، نه بیشتر.

نکته مهم: یک تیم می‌تواند چندین Bounded Context را مدیریت کند، اما یک Bounded Context نمی‌تواند توسط دو تیم مختلف مدیریت شود.

چرا این قانون مهم است؟

  1. جلوگیری از فروپاشی ارتباط: اگر دو تیم روی یک Bounded Context کار کنند، هر تیم فرضیات خود را در مورد مدل می‌کند و این فرضیات اغلب غلط و متناقض هستند.
  2. مسئولیت واضح: هر کسی می‌داند کیست مسئول هر بخش.
  3. تصمیم‌گیری سریع: یک تیم می‌تواند تصمیمات مرتبط با مدل خود را بدون بحث بی‌انتها بگیرد.
  4. طراحی API مشخص: هر تیم باید API خود را به‌عنوان یک قرارداد واضح برای تیم‌های دیگر تعریف کند.

Bounded Context در دنیای واقعی

نویسنده یک داستان بسیار جالب از یکی از شاگردانش در کلاس DDD نقل می‌کند:

“شما گفتید DDD درباره‌ی تطابق طراحی نرم‌افزار با دامین‌های تجاری است. اما Bounded Context در دنیای واقعی کجا‌ند؟ در دنیای تجاری، Bounded Context وجود ندارد!”

پاسخ نویسنده جالب است: Bounded Context‌ها در دنیای واقعی وجود دارند، اما آن‌ها تا جایی که مدل‌های ذهنی متخصصان کسب‌وکار وجود دارند.

برای توضیح این مفهوم، نویسنده سه مثال غیرعادی از دنیای واقعی می‌آورد:

مثال ۱: دامین‌های معنایی (Semantic Domains) - گوجه‌فرنگی

این مثال بسیار جالب است و نشان می‌دهد که Bounded Context فقط در نرم‌افزار نیست، بلکه در طبیعت و جامعه‌ی انسانی هم رایج است.

کلمه‌ی گوجه‌فرنگی معنای مختلفی در کانتکست‌های مختلف دارد:

کانتکست تحقیقات گیاهی (Botany):

  • تعریف رسمی: یک میوه است (زیرا از گل گیاه رشد می‌کند و حداقل یک دانه دارد).
  • سبزی: هر بخش دیگر از گیاه (ریشه، ساقه، برگ).
  • نتیجه: گوجه‌فرنگی یک میوه است.

کانتکست آشپزی (Culinary Arts):

  • تعریف: میوه‌ها نرم، شیرین یا ترش و خام قابل خوردن هستند.
  • سبزی‌ها: بافت سخت‌تر، طعم خنثی، نیازمند پختن.
  • نتیجه: گوجه‌فرنگی یک سبزی است.

کانتکست مالیاتی (Taxation):

  • در سال ۱۸۸۳، ایالات متحده مالیات ۱۰٪ بر واردات سبزی‌ها وضع کرد، اما نه میوه‌ها.
  • برای جلوگیری از اینکه تاجران گوجه‌فرنگی را میوه اعلام کنند و مالیات نپردازند، قدیم‌ترین ستم‌اینی در تاریخ!
  • نتیجه: گوجه‌فرنگی یک سبزی است (براساس تصمیم دادگاه عالی سال ۱۸۹۳).

کانتکست تئاتر:

  • همانطور که یک دوست نویسنده می‌گفت، در تئاتر گوجه‌فرنگی یک مکانیزم بازخورد است (وقتی بازیگر بد است، تماشاچیان گوجه‌فرنگی می‌اندازند!).

درس: یک موجودیت واقعی می‌تواند معنای کاملاً متفاوتی در کانتکست‌های مختلف داشته باشد. و این نه تناقض است، بلکه طبیعی و منطقی است.

مثال ۲: فیزیک و نسبیت (Science)

نویسنده یک نقل‌قول از تاریخ‌دان معروف یوول نوح هاراری را می‌آورد:

“دانشمندان عموماً موافقند که هیچ نظریه‌ی ۱۰۰ درصد صحیح نیست. بنابراین آزمون واقعی علم نه حقیقت است، بلکه مفید بودن است.”

دو مثال:

فیزیک نیوتنی:

  • فضا و زمان مطلق هستند (مانند یک صحنه‌ی تئاتر ثابت).
  • بسیار دقیق برای حرکت اجسام روزمره.

نظریه‌ی نسبیت اینشتین:

  • فضا و زمان نسبی هستند (متفاوت برای ناظران مختلف).
  • برای سرعت‌های بسیار بالا و میدان‌های گرانشی قوی ضروری است.

آیا این دو نظریه متناقض هستند؟ بله. آیا هردو مفید هستند؟ بله، در کانتکست‌های متفاوت.

مثال ۳: خریدن یخچال (Buying a Refrigerator)

یکی از مثال‌های عملی و جالب که نویسنده می‌آورد، تجربه‌ی خودش در خریدن یخچال است:

آپارتمان نویسنده درب ورودی استاندارد به آشپزخانه ندارد. او نمی‌دانست یخچال جدیدی که می‌خواست بخرد (Siemens KG86NAI31L) از درب آشپزخانه عبور کند یا نه.

مدل اول - کاردبُرد: نویسنده یک تکه کاغذ را دقیقاً به ابعاد عرض و عمق یخچال بریده و آن را در درب آشپزخانه امتحان کرد.

  • آیا این کاغذ شبیه یخچال واقعی است؟ نه، اصلاً نه! بدون درب، بدون رنگ، بدون شکل اصلی.
  • آیا مفید بود؟ بسیار مفید!
  • چرا مفید است؟ زیرا مشکل خاصی را حل می‌کند: بررسی اینکه آیا یخچال عمود سطح زمین من از درب عبور می‌کند.

مدل دوم - متر نوار: بعد از بررسی عمق، نویسنده نگران قد یخچال بود. آیا خیلی بلند است؟

کاغذ برای این مشکل کار نمی‌کرد. نویسنده برای بررسی قد، یک متر نوار استفاده کرد. این مدل دوم ساده‌تر و راحت‌تر بود.

درس: این دقیقاً همان‌طور است که در DDD Bounded Context‌ها را طراحی می‌کنیم:

  • ما می‌توانیم چندین مدل متفاوت از یک موجودیت داشته باشیم.
  • هر مدل برای حل یک مشکل خاص طراحی می‌شود.
  • یک مدل پیچیده‌تر (مثل ساخت یک مدل سه‌بعدی دقیق) بیشتر مفید نیست، بلکه اضافی است (Over-engineering).

نتیجه‌گیری بخش دوم

سه درس کلیدی:

  1. Subdomain‌ها کشف می‌شوند؛ Bounded Context‌ها طراحی می‌شوند. Subdomain‌ها از تحلیل کسب‌وکار بیرون می‌آیند. Bounded Context‌ها توسط مهندسان نرم‌افزار تصمیم‌گیری می‌شوند.

  2. هیچ قاعده‌ای برای نسبت یک‌به‌یک بین آن‌ها نیست. یک Subdomain می‌تواند در چندین Bounded Context جدا مدل‌سازی شود. چندین Subdomain می‌تواند در یک Bounded Context ادغام شود.

  3. Bounded Context‌ها مرزهای مالکانه و فیزیکی دارند. هر Bounded Context یک پروژه و تیم جداگانه است. این استقلال باعث می‌شود که تیم‌ها مستقل کار کنند و سریع‌تر تصمیم بگیرند.


فصل ۳: مدیریت پیچیدگی دامین - بخش سوم

مرزها: طراحی معماری سیستم

نویسنده این بخش را با یک نقل‌قول از Ruth Malan (یک متخصص معماری نرم‌افزار) آغاز می‌کند:

“طراحی معماری ذاتاً درباره‌ی مرزها است… طراحی معماری، طراحی سیستم است. طراحی سیستم، طراحی متناسب با زمینه‌ی خاص است—ذاتاً درباره‌ی مرزها: چه چیزی درون است، چه چیزی بیرون، چه چیزی گسترده است، چه چیزی میان آن‌ها حرکت می‌کند، و درباره‌ی مصالحه‌ها. این به شکل چیزی که بیرون است تأثیر می‌گذارد، همانطور که شکل چیزی که درون است را تغییر می‌دهد.”

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

مرزهای فیزیکی (Physical Boundaries)

هر Bounded Context باید به‌عنوان یک موجودیت فیزیکی و مستقل پیاده‌سازی شود. این بدان معنی است:

۱. جداگانگی پروژه

هر Bounded Context یک پروژه جداگانه در سیستم کنترل نسخه (Git، SVN و غیره) است. نه‌اینکه یک پوشه درون یک پروژه بزرگ، بلکه واقعاً یک مخزن (Repository) جداگانه در سیستم کنترل نسخه.

۲. استقلال فناوری

این یکی از بزرگ‌ترین مزایای Bounded Context است. اگر Bounded Context A نیازمند Node.js است و Bounded Context B بهتر با Python کار می‌کند، هیچ مانعی وجود ندارد! هر تیم می‌تواند تکنولوژی بهترین برای نیازهای خود انتخاب کند.

برای مثال:

  • Bounded Context برای «پردازش تصاویر» ممکن است از Go یا Rust استفاده کند (برای سرعت).
  • Bounded Context برای «رابط کاربری پیشرفته» از React یا Vue استفاده کند.
  • Bounded Context برای «تحلیل داده‌ها» از Python استفاده کند.

نیازی نیست که تمام سیستم از یک تکنولوژی یکسان استفاده کند. این تنوع در معماری میکروسرویس‌ها (Microservices) بسیار معمول است.

۳. نسخه‌برداری مستقل

هر Bounded Context می‌تواند نسخه‌ی خود را به‌طور مستقل مدیریت کند. Bounded Context A می‌تواند در نسخه ۲.۳ باشد، در حالی‌که Bounded Context B در نسخه ۵.۱ است. نیازی نیست که تمام سیستم یک نسخه واحد داشته باشد.

۴. استقرار مستقل (Deployment)

یکی از مهم‌ترین مزایا این است که هر Bounded Context می‌تواند به‌طور مستقل اجرا شود. اگر تیم B چیزی را تغییر دهد، تیم A نیازی ندارد سیستم خود را دوباره استقرار دهد.

این باعث افزایش سرعت توسعه می‌شود و ریسک استقرار را کاهش می‌دهد.

مرزهای مالکانه (Ownership Boundaries)

قانون طلایی مالکیت

هر Bounded Context تنها توسط یک تیم می‌تواند مالکانه‌ی آن باشد.

اگر دو تیم روی یک Bounded Context کار کنند:

  • تیم A تصور می‌کند که مدل X باید به این شکل باشد.
  • تیم B تصور می‌کند که مدل X باید به آن شکل باشد.
  • هر دو تیم فرضیات‌های متفاوتی درباره‌ی رفتار مدل می‌سازند.
  • در نتیجه، مدل پراکنده می‌شود و کد به Spaghetti Code (کد الگوی مکارونی) تبدیل می‌شود.

قانون معکوس

یک تیم می‌تواند چندین Bounded Context را مدیریت کند.

نویسنده در متن اصلی نمودار ساده‌ای بیان می‌کند:

  • تیم ۱: مالک Bounded Context «مارکتینگ» و Bounded Context «بهینه‌سازی تبلیغات»
  • تیم ۲: مالک Bounded Context «فروش»

این ترتیب منطقی است، خصوصاً اگر کانتکست‌ها ارتباط تنگاتنگی داشته باشند و تیم آن‌ها بهتر درک کند.

چرا مالکیت واضح مهم است؟

۱. جلوگیری از فرضیات ضمنی: اگر دو تیم روی یک Bounded Context کار کنند، هر تیم فرضیات مختلفی درباره‌ی رفتار مدل می‌سازد. این فرضیات اغلب متناقض و پنهان هستند. زمانی که این دو مجموعه‌ی فرضیات با هم برخورد می‌کند، مسائل عمیقی رخ می‌دهد.

۲. مسئولیت واضح و شفاف: اگر چیزی در Bounded Context خراب شود، مشخص است کیست مسئول. نیست اینکه «هر دو تیم مسئول هستند» (که در واقع به معنای «هیچ‌کس مسئول نیست» است).

۳. تصمیم‌گیری سریع: مالک Bounded Context می‌تواند فوری و بدون تأخیر تصمیمات مهم بگیرد. نیازی ندارد برای موافقت تمام تیم‌ها منتظر باشد یا مذاکره‌های طولانی و پیچیده‌ای انجام دهد.

۴. طراحی API شفاف و منسجم: هر تیم می‌داند دقیقاً کدام API را برای تیم‌های دیگر آشکار می‌کند و کدام جزئیات درونی خصوصی است. این باعث می‌شود API‌ها طبق اصول بهتر و با منطق یکسان طراحی شوند.

جنبه‌های منطقی در مقابل فیزیکی

نویسنده یک تمایز دقیق و مهم می‌کند:

مرز منطقی (Logical Boundary)

اگر یک Bounded Context شامل چندین Subdomain باشد، آن‌ها می‌توانند منطقاً درون Namespace‌ها یا Modules یا Package‌ها (بسته‌ها) جداگانه سازمان‌دهی شوند.

برای مثال، در یک Bounded Context «فروش»:

1
2
3
4
5
6
7
8
9
10
Sales/
  ├── Orders/
  │   ├── Order.cs
  │   ├── OrderRepository.cs
  ├── Customers/
  │   ├── Customer.cs
  │   ├── CustomerRepository.cs
  ├── Invoicing/
  │   ├── Invoice.cs
  │   ├── InvoiceGenerator.cs

این Namespace‌ها مرزهای منطقی درون یک Bounded Context هستند.

مرز فیزیکی (Physical Boundary)

اما تمام این Namespace‌ها درون یک پروژه/مخزن فیزیکی قرار دارند:

1
2
3
4
5
SalesService/  <- Bounded Context فیزیکی
  ├── Orders/  <- مرز منطقی
  ├── Customers/  <- مرز منطقی
  ├── Invoicing/  <- مرز منطقی
  └── ...

اگر یک Subdomain بسیار بزرگ و پیچیده بود، ممکن بود آن را به Bounded Context جداگانه (مرز فیزیکی) تقسیم کنیم. اما معمولاً اول سعی می‌کنیم از مرزهای منطقی استفاده کنیم.

Bounded Context در دنیای واقعی

۱. دامین‌های معنایی (Semantic Domains)

Bounded Context بر اساس ایده‌ای از زبان‌شناسی (Linguistics) است که Semantic Domain (دامین معنایی) نامیده می‌شود.

تعریف: دامین معنایی = «ناحیه‌ای از معنا و کلماتی که برای بحث درباره‌ی آن استفاده می‌شوند.»

مثال‌های عملی:

  • کلمه Monitor (مانیتور):
    • در دامین نرم‌افزار: دستگاهی برای نمایش خروجی کامپیوتر
    • در دامین پزشکی: دستگاهی برای نظارت بر وضعیت بیمار
    • در دامین مدیریت: فردی که برای بررسی کیفیت کار می‌کند
  • کلمه Port (درگاه):
    • در دامین شبکه کامپیوتری: رابط ورودی/خروجی داده در یک کامپیوتر
    • در دامین دریانوردی: منطقه‌ای کناری دریا برای لنگرگاه کشتی‌ها
  • کلمه Processor (پردازنده):
    • در دامین فناوری: تراشه مرکزی در کامپیوتر
    • در دامین تولید: دستگاهی برای پردازش و تبدیل مواد خام

درس: یک واژه می‌تواند معنای کاملاً متفاوت در زمینه‌های کاری مختلف داشته باشد. این تناقض نیست، بلکه طبیعی و منطقی است و Bounded Context‌ها این واقعیت را بیان می‌کنند.

۲. گوجه‌فرنگی - مثال جامع

نویسنده این مثال را در چندین Bounded Context مختلف تشریح می‌کند:

Bounded Context ۱: تحقیقات گیاهی (Botany)

  • میوه: چیزی که از گل گیاه رشد می‌کند و حداقل یک دانه دارد
  • سبزی: بقیه‌ی اجزای خوردنی گیاه (ریشه، ساقه، برگ)
  • نتیجه: گوجه‌فرنگی = میوه

Bounded Context ۲: آشپزی (Culinary Arts)

  • میوه: غذایی با بافت نرم، طعم شیرین یا ترش، و خام خوردنی
  • سبزی: غذایی با بافت سفت‌تر، طعم بی‌رنگ، و اغلب نیازمند پختن
  • نتیجه: گوجه‌فرنگی = سبزی

Bounded Context ۳: مالیات و تجارت (Taxation)

در سال ۱۸۸۳، ایالات متحده ۱۰ درصد مالیات بر سبزی‌های واردشده وضع کرد، اما نه بر میوه‌ها.

تاجران برای اجتناب از مالیات، گوجه‌فرنگی را «میوه» اعلام می‌کردند.

تصمیم دادگاه عالی ایالات متحده (سال ۱۸۹۳): برای منظور مالیاتی، گوجه‌فرنگی = سبزی

(یکی از جالب‌ترین تصمیمات قانونی در تاریخ!)

Bounded Context ۴: تئاتر (Theatre)

همانطور که Romeu Moura (دوست نویسنده) می‌گفت، در تئاتر و نمایش‌های تئاتری، گوجه‌فرنگی = ابزار بازخورد منفی از تماشاچیان نسبت به بازی! (وقتی بازیگر بدی بازی می‌کند، تماشاچیان گوجه‌فرنگی می‌اندازند!)

درس: یک موجودیت فیزیکی واحد (گوجه‌فرنگی) توسط چهار Bounded Context مختلف با معانی و تعاریف مختلف درک می‌شود. این تناقض نیست؛ این ماهیت و طبیعت مدل‌سازی است. هر مدل برای حل یک مشکل خاص طراحی شده است.

۳. فیزیک و علم

نویسنده از Yuval Noah Harari (متفکر و مورخ معاصر) نقل می‌کند:

“دانشمندان عموماً موافقند که هیچ نظریه‌ی علمی ۱۰۰ درصد صحیح نیست. بنابراین آزمون و معیار واقعی دانش نه حقیقت مطلق است، بلکه سودمندی و کاربردی بودن است.”

مثال: نظریه‌های گرانش

Bounded Context ۱: فیزیک کلاسیک نیوتنی

  • فضا و زمان مطلق و یکسان برای همه هستند (مانند یک صحنه‌ی تئاتر ثابت)
  • صحیح برای سرعت‌های عادی و میدان‌های گرانشی ضعیف
  • معادله مشهور: F = ma

Bounded Context ۲: نسبیت عام اینشتین

  • فضا و زمان نسبی هستند و برای هر ناظر و شرایط متفاوت
  • صحیح برای سرعت‌های بسیار بالا و میدان‌های گرانشی بسیار قوی
  • معادله مشهور: E = mc²

آیا این دو نظریه متناقض هستند؟ بله، کاملاً. آیا هردو صحیح هستند؟ نه، نه هردو صحیح هستند. آیا هردو **سودمند و کاربردی هستند؟** بسیار بله!

برای پرتاب موشک به ماه، فیزیک نیوتنی کاملاً کافی است. برای دقت سیستم GPS (Global Positioning System)، باید از نسبیت اینشتین استفاده کنی. هر مدل در کانتکست خود بسیار سودمند است.

۴. خریدن یخچال - مثال عملی واقعی

نویسنده تجربه‌ی شخصی و بسیار عملی خود را تشریح می‌کند:

آپارتمان او درب ورودی استاندارد به آشپزخانه ندارد. او می‌خواست یخچال جدیدی بخرد: Siemens KG86NAI31L.

سؤال عملی: آیا این یخچال از درب عبور می‌کند یا نه؟

مدل اول: کاردبُرد (Cardboard Model)

نویسنده به‌جای خریدن و اندازه‌گیری، یک تکه کاغذ را دقیقاً به ابعاد عرض و عمق یخچال برید:

1
2
3
تکه کاغذ
├─ عرض: دقیقاً برابر با عرض یخچال
└─ عمق: دقیقاً برابر با عمق یخچال

سپس این مدل کاغذی را در درب آشپزخانه گذاشت و بررسی کرد آیا عبور می‌کند.

آیا این مدل شبیه یخچال واقعی است؟ نه، اصلاً نه! بدون درب، بدون رنگ، بدون شکل و ظاهر!

آیا مفید است؟ بسیار مفید! این مدل ساده می‌تواند بپاسخد: «آیا یخچال از لحاظ عرض و عمق، از درب عبور می‌کند؟»

مدل دوم: متر نوار (Tape Measure Model)

بعد از تأیید اینکه عرض و عمق مناسب است، نویسنده نگران قد (ارتفاع) یخچال شد: آیا خیلی بلند است و از درب عبور نمی‌کند؟

مدل کاغذی برای این مشکل کار نمی‌کرد! بنابراین نویسنده مدل دوم را استفاده کرد: متر نوار

1
2
3
متر نوار
├─ قد درب: اندازه‌گیری شده با متر نوار
└─ قد یخچال: اندازه‌گیری شده با متر نوار

مقایسه دو مدل:

مشخصهمدل کاغذیمدل متر نوار
پیچیدگیکمبسیار کم
هزینهتقریباً صفربسیار کم
سرعتسریعسریع
سودمندی برای بررسی عرض/عمقبسیار سودکم سود
سودمندی برای بررسی قدکم سودبسیار سود

درس عمیق:

  • یک مدل پیچیده‌تر (برای مثال، ساخت یک مدل سه‌بعدی دقیق و واقعی‌ای) بهتر نیست، بلکه اضافی و غیرضروری است.
  • دو مدل ساده که هر کدام برای یک مشکل خاص استفاده شود، بسیار بهتر از یک مدل پیچیده و جامع است.
  • این دقیقاً همان نحوه‌ی طراحی Bounded Context‌های مختلف است! برای مسائل مختلف، مدل‌های متفاوت.

نکته اضافی: فناوری LiDAR و Augmented Reality

یکی از خوانندگان در پاسخ به توئیت نویسنده نوشت:

“چرا خود را با کاغذ درگیر می‌کنی؟ می‌توانی از گوشی تلفن خود با اسکن‌نر LiDAR و برنامه‌ی Augmented Reality (AR) استفاده کنی!”

تجزیه‌ی این پیشنهاد درون چارچوب DDD:

  • این تکنولوژی بسیار پیچیده است. در DDD لغت: این یک Generic Subdomain است.
  • یعنی مشکل قبلاً توسط دیگران حل‌شده است و راه‌حل جاهز در دسترس است.
  • درس DDD: اگر نیاز داشتی، بهتر است راه‌حل جاهز و آماده (Ready-made Solution) را خریداری یا استفاده کنی، تا اینکه خودت از صفر پیادهسازی کنی.

اما در این مورد خاص، استفاده از LiDAR و AR بیش از حد تک‌کاری است. کاغذ ساده، متر نوار ساده بسیار سریع‌تر، ارزان‌تر، و عملی‌تر است.

خلاصه‌ی بخش سوم

نکات کلیدی و اساسی:

۱. مرزهای فیزیکی Bounded Context‌ها از اهمیت بالایی برخوردارند زیرا:

  • هر یک می‌تواند به‌طور مستقل استقرار داده شود
  • هر یک می‌تواند با تکنولوژی و ابزار متفاوت ساخته شود
  • هر یک می‌تواند نسخه‌برداری مستقل داشته باشد

۲. مرزهای مالکیت (Ownership) حیاتی و ضروری‌اند زیرا:

  • یک تیم = یک Bounded Context (هیچ اشتراک نیست)
  • تصمیم‌گیری‌های سریع و مؤثر
  • عدم وجود فرضیات ضمنی و متناقض بین تیم‌ها

۳. Bounded Context‌ها در دنیای واقعی و غیرنرم‌افزاری نیز یافت می‌شوند:

  • دامین‌های معنایی (گوجه‌فرنگی در متون مختلف)
  • علم و فیزیک (نیوتن در برابر اینشتین)
  • زندگی روزمره (یخچال و درب و سناریوهای عملی)

۴. مدل‌سازی درباره‌ی **سودمندی است، نه صحیح بودن مطلق و ایدئال.**

  • یک مدل ساده که مشکل خاصی را بخش می‌دهد، بسیار بهتر است از یک مدل پیچیده و وسیع.
  • چندین مدل کوچک و متخصص بسیار بهتر از یک مدل بزرگ و جامع است.

فصل ۳: مدیریت پیچیدگی دامین - بخش چهارم (نهایی)

نتیجه‌گیری

نویسنده با یک خلاصه‌ی جامع این فصل را پایان می‌دهد. در این بخش پایانی، تمام مفاهیم کلیدی درباره‌ی Bounded Context و مدیریت پیچیدگی دامین به‌طور خلاصه بیان می‌شود.

پیام اصلی فصل

هر زمانی که با یک تضاد یا ناسازگاری درون مدل‌های ذهنی متخصصان کسب‌وکار مواجه شویم، باید دامین را به چندین Bounded Context کوچک‌تر تقسیم کنیم.

در مثال تلفن‌فروشی که در ابتدای فصل دیدیم، «سرنخ» (Lead) در دپارتمان مارکتینگ و فروش معانی متفاوتی داشت. راه‌حل DDD این بود که هر دپارتمان را به عنوان یک Bounded Context جداگانه درک کنیم.

ویژگی‌های Bounded Context

۱. سازگاری زبان مشترک درون مرز: زبان مشترک (Ubiquitous Language) در داخل یک Bounded Context باید کاملاً سازگار و منطقی باشد. هیچ ابهام، هیچ تناقض، هیچ اصطلاح مترادف. اما میان Bounded Context‌ها، واژه‌های یکسانی می‌توانند معنای کاملاً متفاوت داشته باشند.

۲. مرز مدل (Model Boundary): Bounded Context تنها دامن‌ی نیست که یک مدل درون آن معتبر است. حدود کاربرد یک مدل مشخص است. هر مدل برای حل یک مشکل خاص طراحی شده است.

۳. مرز دوره‌ی زندگی (Lifecycle Boundary): هر Bounded Context می‌تواند بر اساس سرعت تغییر خود توسعه داده شود. اگر Bounded Context A نیاز به تغییرات مکرر دارد اما Bounded Context B ثابت است، این مشکلی نیست. هر کدام مستقل تکامل می‌یابد.

۴. مرز مالکیت (Ownership Boundary): یک Bounded Context باید تنها توسط یک تیم مدیریت شود. این وضوح مسئولیت را ایجاد می‌کند و از تضادهای میان‌تیمی جلوگیری می‌کند.

تفاوت بین Subdomain و Bounded Context

Subdomain‌ها کشف می‌شوند. Bounded Context‌ها طراحی می‌شوند.

Subdomain

  • منشأ: از تحلیل کسب‌وکار ناشی می‌شود
  • تعیین‌کننده: استراتژی و ساختار تجاری شرکت
  • نقش: نمایندگی واقعیت تجاری سازمان

Bounded Context

  • منشأ: از تصمیمات طراحی معماری نرم‌افزار ناشی می‌شود
  • تعیین‌کننده: نیازهای فنی، سازمانی، و فناوری
  • نقش: نمایندگی راه‌حل نرم‌افزاری برای مسائل تجاری

رابطه بین آن‌ها

آنها لزوماً یک‌به‌یک نیستند:

سناریو اول: یک Bounded Context برای کل سیستم اگر سیستم کوچک است، ممکن است یک Bounded Context برای کل دامین کافی باشد، حتی اگر چندین Subdomain وجود داشته باشد.

سناریو دوم: Bounded Context‌های متعدد براساس مدل‌های متناقض اگر متخصصان کسب‌وکار مدل‌های متناقضی درباره‌ی یک مفهوم دارند، ما Bounded Context‌های جداگانه می‌سازیم.

سناریو سوم: Bounded Context برای هر Subdomain در بسیاری از موارد، هر Subdomain در یک Bounded Context جداگانه پیاده‌سازی می‌شود. این یک انتخاب طراحی معمول است.

سناریو چهارم: چندین مدل از یک Subdomain می‌تواند چندین Bounded Context وجود داشته باشد که یک Subdomain واحد را مدل‌سازی می‌کنند، هر کدام برای حل‌کردن یک مشکل متفاوت.

نکات نهایی و عملی

۱. Bounded Context نه Subdomain است

یکی از اشتباهاتی که اغلب در پروژه‌ها دیده می‌شود این است که تیم‌ها تصور می‌کنند Bounded Context و Subdomain یکی هستند. اینطور نیست. برای درک بهتر:

  • Subdomain: «ما در کسب‌وکار درباره‌ی فروش کار می‌کنیم»
  • Bounded Context: «ما نرم‌افزار را برای مدیریت فروش به این ترتیب طراحی کرده‌ایم»

۲. Bounded Context نیاز به سرزمین‌شناسی فنی دارد

برای تعیین مرزهای Bounded Context، نیاز داریم تا:

  • درباره‌ی کسب‌وکار بسیار بدانیم (Subdomain‌ها را شناسایی کنیم)
  • درباره‌ی نیازهای فنی و سازمانی فکر کنیم (نیازهای توسعه‌ی مستقل، نیازهای مختلف فناوری و غیره)
  • محدودیت‌های سازمانی را درنظر بگیریم (تعداد تیم‌ها، مهارت‌های موجود و غیره)

۳. مالکیت واضح است

نویسنده یک بار دیگر تأکید می‌کند که مالکیت واضح یکی از مهم‌ترین عوامل موفقیت Bounded Context است. اگر مالکیت مشخص نیست، تمام مزایای Bounded Context از دست می‌رود.

خلاصه‌ی مختصر

Bounded Context یک الگوی استراتژیک در Domain-Driven Design است که:

۱. مسئله را حل می‌کند: زمانی که متخصصان کسب‌وکار مدل‌های متناقضی درباره‌ی یک مفهوم دارند، Bounded Context این ناسازگاری را تبدیل به فرصت می‌کند. هر مدل در کانتکست خود معتبر و منطقی است.

۲. توسعه‌ی مستقل را امکان‌پذیر می‌کند: هر Bounded Context می‌تواند مستقل:

  • توسعه داده شود
  • استقرار داده شود
  • نسخه‌برداری شود
  • فناوری خود را انتخاب کند

۳. تیم‌ها را منظم می‌کند: با تخصیص واضح مالکیت Bounded Context‌ها به تیم‌ها، ما:

  • مسئولیت را روشن می‌کنیم
  • تصمیم‌گیری را سریع می‌کنیم
  • فرضیات ضمنی را از بین می‌بریم

۴. معماری را بهبود می‌بخشد: Bounded Context‌ها مرزهای معماری واضح را تعریف می‌کنند که سیستم را ماژولار و قابل نگهداری می‌سازد.

تمرین‌ها (Exercises)

نویسنده در پایان فصل چند تمرین برای تقویت درک مطالب ارائه می‌دهد:

تمرین ۱

سؤال: تفاوت میان Subdomain و Bounded Context کدام است؟

  • (الف) Subdomain‌ها طراحی می‌شوند، Bounded Context‌ها کشف می‌شوند.
  • (ب) Bounded Context‌ها طراحی می‌شوند، Subdomain‌ها کشف می‌شوند.
  • (ج) Bounded Context و Subdomain اساساً یکی هستند.
  • (د) هیچ یک از موارد بالا صحیح نیست.

پاسخ صحیح: (ب)

تمرین ۲

سؤال: Bounded Context کدام یک از موارد زیر است؟

  • (الف) مرز یک مدل
  • (ب) مرز دورة‌ زندگی (Lifecycle)
  • (ج) مرز مالکیت
  • (د) تمام موارد بالا

پاسخ صحیح: (د)

تمرین ۳

سؤال: کدام یک از گزاره‌های زیر درباره‌ی اندازه‌ی Bounded Context درست است؟

  • (الف) هرچه Bounded Context کوچک‌تر باشد، سیستم انعطاف‌پذیرتر است.
  • (ب) Bounded Context‌ها باید همیشه با مرزهای Subdomain‌ها همسو باشند.
  • (ج) هرچه Bounded Context بزرگ‌تر باشد، بهتر است.
  • (د) این بستگی دارد (It depends).

پاسخ صحیح: (د)

توضیح: اندازه‌ی Bounded Context باید متناسب با نیازهای خاص پروژه باشد. نه هرچه کوچک‌تر بهتر است (چون سربار یکپارچه‌سازی زیاد می‌شود) و نه هرچه بزرگ‌تر بهتر است (چون مدل پیچیده می‌شود).

تمرین ۴

سؤال: کدام مورد درباره‌ی مالکیت Bounded Context درست است؟

  • (الف) چندین تیم می‌توانند روی یک Bounded Context کار کنند.
  • (ب) یک تیم می‌تواند چندین Bounded Context را مدیریت کند.
  • (ج) هر Bounded Context باید تنها توسط یک تیم مدیریت شود.
  • (د) (ب) و (ج) هردو درست هستند.

پاسخ صحیح: (د)

تمرین ۵

سؤال: درباره‌ی کمپانی WolfDesk (شرکتی که در مقدمه‌ی کتاب معرفی شده است)، کدام عملکردهای سیستم ممکن است نیاز به مدل‌های متفاوتی از یک موجودیت مثل «تیکت پشتیبانی» داشته باشند؟

پاسخ: شاگرد باید تفکر کند و مثال‌هایی برآورد کند. مثلاً:

  • مدل تیکت برای «تخصیص به کارمند پشتیبانی» (به تفصیل ترتیبات زمان‌بندی و مهارت‌های مورد نیاز)
  • مدل تیکت برای «پیگیری رضایت مشتری» (به تفصیل بازخوردها و نمرات)
  • مدل تیکت برای «تجزیه‌و‌تحلیل روند» (به تفصیل آمار و مدت‌زمان حل)

تمرین ۶

سؤال: درباره‌ی یک پروژه‌ی نرم‌افزاری که در حال حاضر کار می‌کنی (یا کار کرده‌ای)، کدام عملکردهایی می‌تواند به عنوان Bounded Context‌های جداگانه در نظر گرفته شود؟ چرا؟

پاسخ: این تمرین شاگرد را ترغیب می‌کند تا به صورت عملی درباره‌ی سیستمی واقعی فکر کند و Bounded Context‌های آن را شناسایی کند.

پیوند با فصل بعدی

نویسنده در پایان اشاره می‌کند که اگرچه Bounded Context‌ها فائدهمند و ضروری هستند، اما تنهایی نیستند. یک سیستم معمولاً شامل چندین Bounded Context است که باید یکدیگر را یکپارچه کنند.

در فصل ۴ (Integrating Bounded Contexts)، خواهیم دید که چگونی Bounded Context‌های مختلف می‌توانند به‌طور مؤثر با هم ارتباط برقرار کنند و داده را تبادل کنند. این یکی از مهم‌ترین چالش‌های معماری است.

پایان فصل ۳

خلاصه‌ی کلی فصل ۳:

موضوعنکات کلیدی
مسئلهمتخصصان کسب‌وکار مدل‌های متناقضی از یک مفهوم دارند
راه‌حلBounded Context: تقسیم دامین به مدل‌های کوچک‌تر و متسازگار
مزایاتوسعه‌ی مستقل، مدل‌های روشن، تیم‌های منظم
مالکیتیک Bounded Context = یک تیم (اما یک تیم می‌تواند چندین Bounded Context داشته باشد)
تفاوت از SubdomainSubdomain کشف می‌شود؛ Bounded Context طراحی می‌شود
در دنیای واقعیدامین‌های معنایی، فیزیک، زندگی روزمره

فصل ۴: یکپارچه‌سازی Bounded Context‌ها - بخش اول

مقدمه

نویسنده این فصل را با بیانی مهم آغاز می‌کند. اگرچه Bounded Context‌ها مستقل هستند و هر کدام مدل خود را دارند، اما نمی‌توانند تنهایی کار کنند. یک سیستم معمولاً شامل چندین Bounded Context است که باید برای دستیابی به هدفِ کلی سیستم با هم کار کنند.

این فصل درباره‌ی الگوهایی است که برای یکپارچه‌سازی Bounded Context‌ها استفاده می‌شود. اما این الگوها تنها تکنیکی نیستند—آن‌ها توسط نحوه‌ی ارتباط تیم‌ها تعیین می‌شوند.

کنتراکت‌ها (Contracts)

قبل از اینکه درباره‌ی الگوهای یکپارچه‌سازی صحبت کنیم، باید درباره‌ی کنتراکت‌ها (Contracts) بحث کنیم.

تعریف کنتراکت

هر Bounded Context با Bounded Context دیگر یک رابط ارتباطی دارد. این رابط، کنتراکت نام‌گذاری می‌شود. کنتراکت تعریف می‌کند:

  • کدام داده‌ها بین Bounded Context‌ها تبادل می‌شود
  • کدام عملیات یک Bounded Context از Bounded Context دیگر می‌خواهد
  • کدام قوانین برای این تبادل اعمال می‌شود

چرا کنتراکت‌ها مهم‌اند؟

هر کنتراکت بیش از یک طرف را تحت تأثیر قرار می‌دهد. اگر Bounded Context A یک API تغییر دهد، Bounded Context B که از این API استفاده می‌کند، متأثر می‌شود. بنابراین:

  • کنتراکت‌ها باید تعریف و هماهنگ شوند
  • تمام طرف‌های درگیر باید موافق باشند

زبان کنتراکت

یکی دیگر از مسائل مهم این است: کدام زبان برای کنتراکت استفاده شود؟

برای مثال:

  • Bounded Context A از زبان مشترک خود صحبت می‌کند: «Customer»
  • Bounded Context B از زبان مشترک خود صحبت می‌کند: «User»

آیا این دو یکی هستند؟ نه لزوماً! Bounded Context A ممکن است درباره‌ی مشتریان پرداخت‌کننده صحبت کند، اما Bounded Context B درباره‌ی تمام کاربران لاگ‌کننده.

بنابراین، کنتراکت باید بر حسب قرارداد متفق تعریف شود—نه به زبان یک Bounded Context، نه به زبان دیگری.

الگوهای یکپارچه‌سازی: سه دسته اصلی

نویسنده الگوهای یکپارچه‌سازی را به سه دسته تقسیم می‌کند، که هر کدام بر اساس نوع ارتباط تیم‌ها است:

۱. الگوهای تعاون (Cooperation Patterns)

۲. الگوهای مشتری-تامین‌کننده (Customer-Supplier Patterns)

۳. الگوهای مسیرهای جداگانه (Separate Ways Patterns)

در این بخش، ما درباره‌ی الگوهای تعاون صحبت می‌کنیم.

الگوهای تعاون (Cooperation Patterns)

الگوهای تعاون برای Bounded Context‌هایی مناسب هستند که توسط تیم‌های با ارتباط خوب و هماهنگی قوی پیاده‌سازی می‌شوند.

شرایط تعاون

تعاون موثر زمانی اتفاق می‌افتد:

۱. تیم‌ها هدفِ یکسانی دارند: موفقیت یکی برای دیگری مهم است. ۲. ارتباط منظم: تیم‌ها مکرراً و منظم با هم صحبت می‌کنند. ۳. اعتماد و تعهد: هر تیم برای رفع مسائل یکپارچه‌سازی تعهد دارد.

دو الگو اصلی تعاون

۱. الگوی شراکت (Partnership Pattern)

تعریف: در الگوی شراکت، دو Bounded Context هماهنگی دوطرفه دارند. اگر یک تیم API خود را تغییر دهد، تیم دیگر را مطلع می‌کند و تیم دیگر سازگار می‌شود—بدون نزاع یا تعارض.

خصوصیات:

  • هماهنگی دوطرفه: نه یکی طرف دیگری را فرمان می‌دهد. هردو برابر هستند.
  • حل‌کردن مشکلات درون قبیله: زمانی که مسائل یکپارچه‌سازی رخ می‌دهند، تیم‌ها آن‌ها را با هم حل می‌کنند.
  • نیاز به انضباط: Continuous Integration (CI) ضروری است. تغییرات باید سریع در هردو سیستم ادغام شوند.

مثال:

فرض کنید تیم A (Bounded Context Orders) و تیم B (Bounded Context Inventory) روی سیستم فروش کار می‌کنند.

  • تیم A نیاز دارد تا فوری بفهمد که محصول در انبار موجود است یا نه.
  • تیم B نیاز دارد تا فوری بفهمد که یک سفارش ثبت شد.
1
2
3
4
5
6
7
تیم A (Orders)          تیم B (Inventory)
    |                         |
    +-----> سرنخ سفارش ------>|
    |                         |
    |<--- وضعیت موجودی ------+
    |                         |
    +---> تأیید سفارش ------->|

تیم‌ها نزدیک به هم کار می‌کنند، مشکلات را سریع حل می‌کنند.

محدودیت‌ها:

  • نیاز به همکاری مستمر: اگر تیم‌ها دور از هم باشند (جغرافیایی یا ساعتی)، این الگو کار نمی‌کند.
  • Scaling: اگر سیستم بسیار پیچیده شود و تیم‌های زیادی باشند، این الگو مقیاس‌پذیر نیست.

۲. الگوی هسته‌ی مشترک (Shared Kernel Pattern)

تعریف: گاهی اوقات دو یا بیشتر Bounded Context نیاز دارند تا مدل یا کدِ واحدی را به‌طور همزمان استفاده کنند.

مثال عملی:

فرض کنید یک سیستم سازمانی سلسله‌مراتبی دارد. هر کارمند می‌تواند:

  • مستقیماً اجازه‌هایی داشته باشد
  • غیرمستقیماً اجازه‌هایی را از واحد تشکیلاتی خود ارث کند
1
2
3
4
کارمند John
├── اجازه‌های مستقیم: [Read, Edit]
└── اجازه‌های ارثی (از واحد Sales):
    └── [Approve Orders]

این مدل پیچیده است و چندین Bounded Context نیاز دارند تا آن را درک کنند:

  • Bounded Context AccessControl: برای بررسی اجازه‌های دسترسی
  • Bounded Context HumanResources: برای مدیریت کارمندان و واحدهای تشکیلاتی
  • Bounded Context AuditLog: برای ثبت تغییرات اجازه‌ها

مدل‌های مشترک (Shared Model)

در الگوی Shared Kernel، مدلِ مشترک (مثلاً کلاس Permission یا کلاس OrganizationalUnit) درون چندین Bounded Context دقیقاً یکسان استفاده می‌شود.

نکات مهم:

۱. محدوده‌ی کوچک: Shared Model باید تنها شامل بخش‌های ضروری باشد. تمام جزئیات دیگر به‌صورت خصوصی درون هر Bounded Context باقی می‌ماند.

1
2
3
4
5
6
7
8
9
10
11
   Shared Kernel:
   ├── Permission (نام، توضیح)
   └── OrganizationalUnit (ID، نام)
   
   Bounded Context AccessControl (خصوصی):
   ├── Role
   └── RoleAssignment
   
   Bounded Context HumanResources (خصوصی):
   ├── Employee
   └── EmployeeCompensation

۲. سازگاری: اگر هر Bounded Context مدل مشترک را تغییر دهد، باید تغییر برای تمام انجام شود. این باعث می‌شود که تغییرات هماهنگ باشند (یا اصلاً نباشند).

۳. تعارض‌ها: اگر دو Bounded Context درخواست‌های متناقضی از مدل مشترک داشته باشند، نمی‌توانیم هردو را رضایت دهیم. بنابراین، مدل مشترک باید برای نیازهای تمام طرف‌ها طراحی شود.

پیاده‌سازی Shared Kernel

روش ۱: مخزن مشترک (Shared Repository)

اگر سازمان از mono-repository استفاده می‌کند (یک مخزن بزرگ برای تمام Bounded Context‌ها)، می‌توان مدل مشترک را در یک پوشه‌ی مشترک قرار داد:

1
2
3
4
5
6
7
8
9
10
11
12
shared-kernel/
├── Permission.cs
├── OrganizationalUnit.cs
└── ...

bounded-contexts/
├── AccessControl/
│   └── (استفاده از shared-kernel)
├── HumanResources/
│   └── (استفاده از shared-kernel)
└── AuditLog/
    └── (استفاده از shared-kernel)

روش ۲: مخزن جداگانه (Separate Repository)

اگر هر Bounded Context مخزن جداگانه دارد، می‌توان Shared Model را در یک پکیج یا Library جداگانه استخراج کرد:

1
2
3
4
5
6
7
8
9
10
11
12
shared-kernel-library/
├── Permission
├── OrganizationalUnit
└── pom.xml (یا package.json یا .csproj)

access-control-service/
├── pom.xml
└── (dependency: shared-kernel-library)

human-resources-service/
├── pom.xml
└── (dependency: shared-kernel-library)

مزایا و نقاط ضعف الگوهای تعاون

الگوی شراکت

مزایا:

  • ساده: دو تیم مستقیماً با هم کار می‌کنند
  • کاهش پیچیدگی: نیازی به میانجی یا کنتراکت پیچیده نیست
  • سرعت: تصمیمات سریع گرفته می‌شود

نقاط ضعف:

  • قابلیت مقیاس‌پذیری: وقتی تیم‌ها زیاد شوند، کار سخت می‌شود
  • وابستگی: تیم‌ها بسیار وابسته به یکدیگر هستند
  • توزیع‌شدگی: اگر تیم‌ها جغرافیایی‌اً دور باشند، غیرممکن است

الگوی Shared Kernel

مزایا:

  • هماهنگی: اگر مدل مشترک خوب طراحی شود، تمام Bounded Context‌ها آن را درک می‌کنند
  • تکرار‌نشدگی: کد یا مدل تکرار نمی‌شود

نقاط ضعف:

  • تنگی و ارتباط: تغییر در مدل مشترک تمام Bounded Context‌ها را متأثر می‌کند
  • دشواری: اگر مدل مشترک نادرست طراحی شود، خرابی در تمام جا‌ها منتشر می‌شود
  • تعارض‌ها: نیازهای مختلف Bounded Context‌ها ممکن است متناقض باشند

خلاصه‌ی الگوهای تعاون

الگوشرایطمزایانقاط ضعف
شراکت (Partnership)تیم‌های با ارتباط خوب و قریبساده، سریعنامقیاس‌پذیر، وابسته
Shared Kernelنیاز به مدل مشترکنکرار‌نشدگی کدتنگی‌ی اتصال، خطر منتشرشدگی خرابی

نکات مهم

۱. الگوهای تعاون برای سیستم‌های کوچک و تیم‌های قریب مناسب‌اند.

۲. Shared Kernel باید **کوچک و متمرکز باشد. اگر بیش‌ازحد بزرگ شود، مشکلاتی ایجاد می‌شود.**

۳. یک Bounded Context می‌تواند در دو الگوی مختلف قرار داشته باشد. برای مثال، Bounded Context A با B در الگوی Shared Kernel است، اما با C در الگوی Partnership است.


فصل ۴: یکپارچه‌سازی Bounded Context‌ها - بخش دوم

الگوهای مشتری-تامین‌کننده (Customer-Supplier Patterns)

تا کنون درباره‌ی الگوهای تعاون صحبت کردیم. این الگوها برای تیم‌هایی مناسب بود که برابر و متوازن بودند.

اما دنیای واقعی همیشه برابر نیست. بسیاری از پروژه‌ها رابطه‌های نامتوازن دارند. در این موارع:

  • یک تیم تامین‌کننده (Supplier) است و سرویس یا API ارائه می‌دهد.
  • یک تیم مشتری (Customer) است و آن سرویس را استفاده می‌کند.

تفاوت با الگوهای تعاون

در الگوهای تعاون:

  • تیم A تغییر می‌دهد → تیم B را خبر می‌دهد → تیم B سازگار می‌شود
  • هردو تیم برابر در تصمیم‌گیری هستند

در الگوهای مشتری-تامین‌کننده:

  • تامین‌کننده سرویس خود را نگهداری و توسعه می‌دهد
  • مشتری باید سازگار شود (یا فشار وارد کند)
  • نیازهای مشتری اهمیت دارد اما تامین‌کننده مسئول نهایی است

شرایط مناسب برای الگوهای مشتری-تامین‌کننده

این الگوها زمانی مناسب‌اند که:

۱. وابستگی واضح: مشتری به سرویس تامین‌کننده وابسته است ۲. نقش‌های متفاوت: تامین‌کننده و مشتری نقش‌های متفاوتی دارند ۳. قدرت نابرابر: تامین‌کننده اختیار بیشتری در تعریف API دارد ۴. تیم‌های مختلف: معمولاً این تیم‌ها نزدیک نیستند و در سرعت‌های متفاوت کار می‌کنند

دو الگوی اصلی مشتری-تامین‌کننده

۱. الگوی میزبان باز (Open Host Service Pattern)

تعریف: تامین‌کننده یک API عمومی و خوب‌تعریف‌شده ارائه می‌دهد که چندین مشتری می‌تواند از آن استفاده کند. این API استاندارد است و برای راحتی کلیه‌ی مشتریان طراحی شده است.

خصوصیات:

  • API عمومی و ثابت: تامین‌کننده API خود را واضح و مستند می‌کند
  • حمایت از چندین مشتری: یک API می‌تواند برای بسیاری از Bounded Context‌های مختلف استفاده شود
  • تعهد به سازگاری: تامین‌کننده تعهد می‌دهد که API را برای عقب‌سو سازگار نگه دارد (Backward Compatible)
  • تجزیه‌و‌تحلیل تقاضا: تامین‌کننده باید نیازهای تمام مشتریان را درک کند و API را بر این اساس طراحی کند

مثال عملی:

فرض کنید Bounded Context Payment (پرداخت) یک API عمومی ارائه می‌دهد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
API Open Host Service: Payment Service
├── POST /payments
│   ├── amount: number
│   ├── currency: string
│   ├── customer_id: string
│   └── metadata: object
│
├── GET /payments/{id}
│   └── [retrieve payment status]
│
├── POST /payments/{id}/refund
│   ├── amount: number (optional)
│   └── reason: string
│
└── WebHook: payment.completed
    └── [notify all customers when payment succeeds]

مشتریان مختلف می‌توانند از این API استفاده کنند:

  • Bounded Context Orders برای تأیید پرداخت سفارش
  • Bounded Context Subscription برای پرداخت اشتراک ماهانه
  • Bounded Context Refunds برای بازپرداخت

مزایا و چالش‌های Open Host Service

مزایا:

  • استقلال بیشتر: مشتریان می‌توانند به‌طور مستقل توسعه دهند
  • تغییرات کنترل‌شده: تامین‌کننده تغییرات را کنترل می‌کند و نسخه‌های قدیم را دعم می‌کند
  • بیشتر مشتریان: یک API عمومی می‌تواند هزاران یا میلیون‌ها مشتری داشته باشد

چالش‌ها:

  • طراحی پیچیده: API باید برای تمام مشتریان مفید باشد
  • تعارض‌های نیازمندی: مشتریان مختلف نیازهای متناقض ممکن است داشته باشند
  • سرعت توسعه کاهش: تامین‌کننده نمی‌تواند سریع تغییر کند

۲. الگوی زبان منتشرشده (Published Language Pattern)

تعریف: تامین‌کننده یک زبان (Data Format) استاندارد برای تبادل‌ی اطلاعات تعریف می‌کند. این نه API است، بلکه قرارداد داده‌ای است.

مثال:

به‌جای اینکه تامین‌کننده یک API با اندپوینت‌های مختلف بدهد، یک فرمت داده واحد ارائه می‌دهد (مثلاً JSON Schema یا Protocol Buffers):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Published Language: Customer Data Format
{
  "customer_id": "string",
  "full_name": "string",
  "email": "string",
  "phone": "string (optional)",
  "address": {
    "street": "string",
    "city": "string",
    "postal_code": "string",
    "country": "string"
  },
  "metadata": {
    "created_at": "ISO8601 datetime",
    "updated_at": "ISO8601 datetime"
  }
}

مشتریان می‌توانند این Published Language را استفاده کنند:

  • برای خواندن داده‌های Customer
  • برای نوشتن داده‌های Customer
  • برای تبادل اطلاعات Customer با سایر سیستم‌ها

تفاوت Open Host Service و Published Language

الگواستفادهمثال
Open Host ServiceAPI با عملیات مختلف (GET، POST، DELETE)POST /customers
Published Languageقرارداد داده‌ای (Data Format){customer_id, name, email, ...}

Open Host Service = How (چگونه عملیات انجام شود) Published Language = What (چه داده‌هایی تبادل شود)

مثال عملی: ترکیب دو الگو

فرض کنید Bounded Context Customer Management می‌خواهد برای چندین مشتری سرویس بدهد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Bounded Context: Customer Management

1. Open Host Service API:
   ├── GET /customers
   ├── POST /customers
   ├── PUT /customers/{id}
   └── DELETE /customers/{id}

2. Published Language (قرارداد داده):
   {
     "id": "uuid",
     "name": "string",
     "email": "email",
     "created_at": "datetime"
   }

مشتریان:
├── Bounded Context Orders
│   └── استفاده از GET /customers + Published Language
├── Bounded Context Billing
│   └── استفاده از GET /customers + Published Language
└── Bounded Context Support
    └── استفاده از GET/PUT /customers + Published Language

استراتژی‌های مشتری برای سازگاری

گاهی اوقات مشتری نمی‌تواند مستقیماً از API تامین‌کننده استفاده کند. شاید:

  • API ناسازگار با مدل مشتری است
  • تامین‌کننده زیاد تغییر می‌دهد
  • نیازهای مشتری خاص هستند

در این موارع، مشتری می‌تواند یک لایه‌ی درمیانی بسازد.

الگوی لایه‌ی ضد فساد (Anticorruption Layer Pattern)

تعریف: مشتری یک لایه‌ی ترجمه می‌سازد که:

  • داده‌های تامین‌کننده را می‌گیرد
  • آن‌ها را به مدل مشتری ترجمه می‌کند
  • مدل مشتری از تغییرات تامین‌کننده محفوظ می‌ماند

مثال:

فرض کنید Bounded Context Orders نیاز دارد اطلاعات مشتری را از Bounded Context Customer Management بگیرد.

اما مدل Customer Management به این شکل است:

1
2
3
4
5
6
7
{
  "customer_id": "ABC123",
  "full_name": "John Doe",
  "email_address": "john@example.com",
  "phone_number": "+1-555-0000",
  "residential_address": {...}
}

مدل Orders بهتر می‌خواهد:

1
2
3
4
5
{
  "id": "ABC123",
  "name": "John Doe",
  "contact": "john@example.com"
}

حل: Anticorruption Layer

Orders یک ترجمه‌کننده می‌سازد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Anticorruption Layer
public class CustomerAdapter
{
    private readonly ICustomerManagementClient client;
    
    public OrderCustomer GetCustomer(string id)
    {
        // داده‌های خام از تامین‌کننده
        var rawCustomer = client.GetCustomer(id);
        
        // ترجمه به مدل Orders
        return new OrderCustomer
        {
            Id = rawCustomer.customer_id,
            Name = rawCustomer.full_name,
            Contact = rawCustomer.email_address
        };
    }
}

مزایا:

  • استقلال: Orders مستقل از تغییرات Customer Management
  • ترجمه: داده‌های خام تبدیل به مدل مناسب
  • محافظت: اگر Customer Management عمدی عوض شود، Orders متأثر نمی‌شود

نقص:

  • کد اضافی: ترجمه‌کننده کد اضافی است
  • نگهداری: اگر Customer Management زیاد تغییر کند، ترجمه‌کننده باید مکرر بروزرسانی شود

خلاصه‌ی الگوهای مشتری-تامین‌کننده

الگواستفادهمناسب برای
Open Host Serviceتامین‌کننده یک API عمومی می‌دهدچندین مشتری، نیازهای متشابه
Published Languageقرارداد داده‌ای واضحتبادل اطلاعات ساختارمند
Anticorruption Layerمشتری ترجمه می‌کندمشتری نیازهای خاص دارد

نکات مهم

۱. Open Host Service برای تامین‌کننده خوب است: اگر API خوب طراحی شود، چندین مشتری می‌تواند از آن استفاده کند.

۲. Published Language برای استاندارد‌سازی خوب است: اگر بسیاری از Bounded Context‌ها همان قالب داده را نیاز داشته باشند.

۳. Anticorruption Layer برای مشتری دفاع است: مشتری می‌تواند خودش را از تغییرات تامین‌کننده محفوظ کند.

۴. تاثیر بر سرعت توسعه: Open Host Service موجب می‌شود تامین‌کننده کندتر تغییر کند (برای سازگاری با تمام مشتریان)، اما Anticorruption Layer موجب می‌شود مشتری خودش را نیاز‌های خاص کند.


فصل ۴: یکپارچه‌سازی Bounded Context‌ها - بخش سوم

الگوی مسیرهای جداگانه (Separate Ways Pattern)

تا کنون درباره‌ی الگوهایی صحبت کردیم که Bounded Context‌ها باید یکدیگر را یکپارچه کنند:

  • الگوهای تعاون (Partnership، Shared Kernel) برای تیم‌های قریب
  • الگوهای مشتری-تامین‌کننده (Open Host Service، Anticorruption Layer) برای تیم‌های نامتوازن

اما گاهی اوقات بهترین راه‌حل این است که Bounded Context‌ها اصلاً یکپارچه نشوند!

تعریف Separate Ways

الگوی Separate Ways یعنی:

  • دو یا بیشتر Bounded Context کاملاً مستقل هستند
  • داده‌ای تبادل نمی‌کنند
  • API مشترک ندارند
  • هر کدام خود را مدیریت می‌کند
  • اگر یکی تغییر کند، دیگری متأثر نمی‌شود

موارد استفاده

الگوی Separate Ways زمانی مناسب است که:

۱. وابستگی کم: Bounded Context‌ها پیش‌زمینه‌ای وابستگی ندارند ۲. نیازهای متفاوت: هر Bounded Context مدل و منطق کاملاً متفاوت دارد ۳. تکرار قابل تحمل: تکرار داده یا کد بین Bounded Context‌ها مقبول است ۴. سرعت توسعه مهم: نرفتن به تعقیب یکدیگر برای هماهنگی اولویت است

مثال عملی

فرض کنید یک سیستم حسابداری داریم با دو Bounded Context:

Bounded Context ۱: Accounting (حسابداری)

1
2
3
4
5
6
Account
├── id
├── name
├── balance
├── type (Asset, Liability, Equity)
└── currency

Bounded Context ۲: Reporting (گزارش‌دهی)

1
2
3
4
5
6
Account
├── id
├── name
├── balance
├── last_updated
└── region

اگر نگاه کنیم، هردو موجودیت Account را دارند. اما مدل‌های آن‌ها متفاوت است:

  • Accounting نیاز دارد به نوع حساب و ارز
  • Reporting نیاز دارد به تاریخ بروزرسانی و منطقه

راه‌حل Separate Ways

به‌جای تلاش برای یکپارچه کردن، Bounded Context‌ها مستقل عمل می‌کنند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────┐
│  Accounting     │
│  ├── Accounts   │
│  ├── Journals   │
│  └── Ledgers    │
└────────┬────────┘
         │ (no integration)
         │
┌────────┴────────┐
│  Reporting      │
│  ├── Reports    │
│  ├── Summaries  │
│  └── Analytics  │
└─────────────────┘

نتیجه:

  • هر Bounded Context مدل Account خود را دارد
  • اگر Accounting تغییر کند، Reporting نیازی ندارد تغییر کند
  • اگر Reporting ستون جدید اضافه کند، Accounting متأثر نمی‌شود

اما داده‌ها را چگونه بروزرسانی کنیم؟

اگر Reporting نیاز دارد اطلاعات جدید از Accounting بگیرد، چند راه وجود دارد:

راه ۱: تکرار داده‌ها (Data Duplication)

Reporting نسخه‌ی خود از داده‌های Account را نگهداری می‌کند:

1
2
3
4
5
6
7
Accounting Database          Reporting Database
├── accounts                  ├── accounts (copy)
├── journals                  ├── reports
└── ledgers                   └── analytics

[Periodic Sync Job]
Accounting → Reporting (هر شب یا هر ساعت)

مزایا:

  • استقلال کامل: Reporting می‌تواند هر زمان اطلاعات را پردازش کند
  • عملکرد: Reporting نیازی ندارد به Accounting درخواست کند (کندتر است)

نقص:

  • تاخیر: Reporting داده‌های قدیمی ممکن است داشته باشد
  • نگهداری: اگر ساختار داده در Accounting تغییر کند، sync job شکست می‌خورد

راه ۲: هر Bounded Context داده خود را بسازد

هر Bounded Context صفر از صفر داده‌های خود را تولید می‌کند:

1
2
3
4
5
6
7
8
9
10
Accounting:
├── UserA SubmitTransaction $100
├── System: Create Account + Ledger Entry
└── Accounting Database: ✓ saved

Reporting:
├── [هر ۱۵ دقیقه یک بار]
├── Analyze transactions from logs
├── Generate Report
└── Reporting Database: ✓ updated

مزایا:

  • استقلال کامل: هیچ وابستگی مستقیم نیست
  • نیازی به sync نیست: هر یک خود را مدیریت می‌کند

نقص:

  • کار تکراری: منطق تولید داده تکرار می‌شود
  • ناسازگاری: اگر منطق تولید متفاوت باشد، داده‌ها متناقض می‌شود

کی استفاده کنیم و کی نه؟

استفاده از Separate Ways ✓

1
2
3
4
5
✓ Bounded Context‌ها نیازهای **بسیار متفاوت** دارند
✓ تکرار داده **قابل تحمل** است
✓ **تاخیر در اطلاعات** مشکل نیست
✓ تیم‌ها می‌خواهند **سریع** توسعه کنند
✓ هزینه‌ی یکپارچه‌سازی **زیاد** است

استفاده نکنیم ✗

1
2
3
4
✗ Bounded Context‌ها **بسیار وابسته** هستند
✗ داده‌ها **باید همیشه سازگار** باشند
✗ **تاخیر در اطلاعات ناپذیرفتنی** است
✗ تکرار داده **خطرناک** است

مثال عملی واقعی

فرض کنید یک سیستم فروش آنلاین داریم:

سناریو ۱: استفاده از Separate Ways

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Bounded Context: Order Management
├── Create Order
├── Update Order Status
└── Cancel Order

Bounded Context: Analytics
├── Report Sales
├── Track Metrics
└── User Behavior

[No direct integration]

Order Management عملیات سفارش را انجام می‌دهد
Analytics هر شب داده‌های دیروز را تجزیه‌و‌تحلیل می‌کند

معادلاً: تاخیر ۲۴ ساعت در گزارش‌ها قابل تحمل است.

سناریو ۲: استفاده نکنیم (Open Host Service بهتر است)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Bounded Context: Payment
├── Charge Card
├── Handle Refund
└── Check Balance

Bounded Context: Inventory
├── Update Stock
└── Check Availability

[Need immediate sync!]

اگر سفارش تأیید شد:
1. Payment: برداشت پول ✓
2. Inventory: کاهش موجودی ✓
(هر دو باید **فوری** اتفاق بیفتد)

اگر Inventory تاخیری داشته باشد، می‌تواند موجودی منفی شود!

چهار الگوی اصلی: یک جدول خلاصه

الگورابطهمتناسب بامثال
Partnership↔ دوطرفه، برابرتیم‌های قریب، تعاون خوبOrders ↔ Inventory
Shared Kernel⊕ مدل مشترکنیازهای متشابهPermissions در چندین Bounded Context
Open Host Service→ یک‌طرفه، عمومیتامین‌کننده قویPayment API برای چندین مشتری
Separate Ways⊘ هیچ ارتباطنیازهای متفاوت، تاخیر قابل تحملOrders ⊘ Analytics

نکات مهم

۱. Separate Ways نه شکست است: بسیاری از توسعه‌دهندگان فکر می‌کنند که الگوی بدون یکپارچه‌سازی شکست یا طراحی بد است. اما درست است! گاهی بدون تماس بهتر از تماس ضعیف است.

۲. قانون YAGNI (You Aren’t Gonna Need It): اگر هنوز نیاز یکپارچه‌سازی نیست، آن را نسازید. منتظر بمانید تا بفهمید چه نیاز است.

۳. ارزیابی هزینه:

  • یکپارچه‌سازی = بسیاری کد، تست، نگهداری
  • تکرار = کد ساده‌تر اما منطق مکرر
  • کدام کارآمد است؟ این بستگی دارد.

۴. تغییر الگو: اگر بعداً معلوم شود که Separate Ways نمی‌تواند کار کند، می‌تواند به Open Host Service تبدیل شود.

پایان بخش سوم از فصل ۴

تا اینجا سه گروه الگو را کاور کردیم:

  • ✓ الگوهای تعاون (Partnership، Shared Kernel)
  • ✓ الگوهای مشتری-تامین‌کننده (Open Host Service، Anticorruption Layer)
  • ✓ الگوی مسیرهای جداگانه (Separate Ways)

فصل ۴: یکپارچه‌سازی Bounded Context‌ها - بخش چهارم (نهایی)

Context Map - نقشه‌ی کانتکست

تا اینجا درباره‌ی الگوهای یکپارچه‌سازی مختلف صحبت کردیم. اما یک سیستم بزرگ ممکن است دستها Bounded Context و صدها رابطه داشته باشد. چگونه می‌توانیم کل تصویر را ببینیم؟

پاسخ: Context Map

تعریف Context Map

Context Map یک نمودار بصری است که نشان می‌دهد:

  • کدام Bounded Context‌ها در سیستم وجود دارند
  • چگونه Bounded Context‌ها با یکدیگر ارتباط دارند
  • کدام الگو برای هر ارتباط استفاده می‌شود
  • جریان داده بین Bounded Context‌ها

چرا Context Map مهم است؟

۱. درک کلی: اگر نقشه ندارید، سیستم بسیار پیچیده است ۲. ارتباط: توسط Context Map می‌توانید با تیم‌های دیگر صحبت کنید ۳. تصمیم‌گیری: برای تغییرات بزرگ، Context Map راهنما است ۴. سند: Context Map مستند می‌کند که سیستم چگونه کار می‌کند

عناصر Context Map

۱. Bounded Context (جعبه)

هر Bounded Context به‌عنوان یک جعبه یا مستطیل نشان داده می‌شود:

1
2
3
4
┌─────────────────────┐
│   Order Management  │
│   (Bounded Context) │
└─────────────────────┘

۲. ارتباطات (خطوط و فلش‌ها)

ارتباطات بین Bounded Context‌ها به‌عنوان خطوط نشان داده می‌شود:

1
2
3
4
┌──────────────┐         ┌──────────────┐
│   Orders     │────────▶│   Inventory  │
│  (Customer)  │         │  (Supplier)  │
└──────────────┘         └──────────────┘

فلش جهت وابستگی یا داده‌ای جریان را نشان می‌دهد.

۳. نوع ارتباط (برچسب)

هر ارتباط با نوع الگو برچسب زده می‌شود:

1
2
3
4
5
6
7
┌──────────────┐           ┌──────────────┐
│   Orders     │───OHS────▶│   Payment    │
│              │           │              │
│ (Customer)   │           │ (Supplier)   │
└──────────────┘           └──────────────┘

OHS = Open Host Service

۴. جهت‌گیری

  • → جهت دار: Bounded Context سمت چپ وابسته به سمت راست است
  • ↔ دوطرفه: هردو وابسته هستند (Partnership)
  • ⊘ بدون خط: کاملاً جداگانه (Separate Ways)

نمودار Context Map: مثال واقعی

فرض کنید یک سیستم فروش آنلاین داریم. نقشه‌ی Context آن می‌تواند به این‌گونه باشد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
                    ┌─────────────────┐
                    │    Billing      │
                    │  (Supporting)   │
                    └────────┬────────┘
                             │
                             │ OHS
                             ▼
    ┌────────────────┐  ┌─────────────┐      ┌──────────────┐
    │   Customers    │──│   Orders    │──SK──│  Inventory   │
    │    (Core)      │  │   (Core)    │      │ (Supporting) │
    └────────────────┘  └─────────────┘      └──────────────┘
             △               │
             │               │ OHS
           ACL               ▼
             │         ┌──────────────┐
             │         │   Payment    │
             └─────────│  (Generic)   │
                       └──────────────┘

توضیح:

  • Customers و Orders از SK (Shared Kernel) استفاده می‌کنند
  • Orders یک OHS (Open Host Service) را برای Inventory و Billing فراهم می‌کند
  • Customers از Payment یک ACL (Anticorruption Layer) استفاده می‌کند
  • Billing و Inventory مستقل هستند (Separate Ways)

شماره‌گذاری الگوها در Context Map

معمولاً Context Map از اختصارات استفاده می‌کند:

اختصارالگومثال
PPartnershipدو طرفه ↔
SKShared Kernelمدل مشترک
OHSOpen Host ServiceAPI عمومی
PLPublished Languageقرارداد داده
ACLAnticorruption Layerلایه ترجمه
SWSeparate Waysبدون ارتباط

ساخت Context Map: مراحل عملی

مرحله ۱: شناسایی Bounded Context‌ها

اول تمام Bounded Context‌ها را شناسایی کنید:

  • کدام‌ها Core هستند؟
  • کدام‌ها Generic هستند؟
  • کدام‌ها Supporting هستند؟
1
2
3
4
5
6
7
8
9
10
11
12
13
Core:
├── Order Management
├── Customer Management
└── Pricing Engine

Generic:
├── Payment Processing
├── User Authentication
└── Notification Service

Supporting:
├── Reporting
└── Audit Logging

مرحله ۲: شناسایی ارتباطات

برای هر جفت Bounded Context، بپرسید:

  • آیا باید ارتباط داشته باشند؟
  • اگر بله، کدام الگو مناسب است؟
  • کدام جهت دارد؟
1
2
3
4
5
6
7
8
9
Order Management → Payment Processing
├── نوع: Customer-Supplier
├── الگو: OHS (Order Management فروشنده است)
└── جریان: Order Details → Payment API

Order Management ↔ Inventory Management
├── نوع: Cooperation
├── الگو: Partnership (تعاون شده)
└── جریان: Stock Updates ↔ Order Updates

مرحله ۳: رسم نمودار

ابزارهای استفاده‌شده:

  • Miro یا Lucidchart (برای نمودارات تعاونی)
  • Figma (برای طراحی و ارتباط)
  • PlantUML (برای نمودارات کدی)
  • draw.io (رایگان و ساده)

مرحله ۴: اعتبارسنجی

Context Map را با تمام تیم‌ها بررسی کنید:

  • آیا جاهای تاریک وجود دارد؟
  • آیا ارتباطات درست است؟
  • آیا الگوها منطقی‌اند؟

Context Map و تقسیم تیم

Context Map یک ابزار مدیریتی نیز است!

مثال: تقسیم تیم‌ها

فرض کنید ۶ تا تیم داریم:

1
2
3
4
5
6
7
8
9
10
11
12
Context Map:
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│   Orders     │──│  Inventory   │──│  Shipping    │
│  (Team A)    │  │  (Team B)    │  │  (Team C)    │
└──────────────┘  └──────────────┘  └──────────────┘
       │                  │                  │
       │ OHS              │ OHS              │ OHS
       ▼                  ▼                  ▼
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│   Payment    │  │   Reports    │  │  Customers   │
│  (Team D)    │  │  (Team E)    │  │  (Team F)    │
└──────────────┘  └──────────────┘  └──────────────┘

مثال تقسیم:

  • Team A مالک Orders
  • Team B مالک Inventory
  • Team D مالک Payment
  • و غیره…

هر تیم مالک یک Bounded Context است و نمی‌تواند روی Bounded Context دیگری کار کند.

Context Map و تصمیم‌های معماری

مثال: آیا Separate Ways خوب است؟

فرض کنید Reporting و Orders بدون ارتباط هستند:

1
2
3
4
5
6
7
┌──────────────┐     ┌──────────────┐
│   Orders     │     │  Reporting   │
│              │     │              │
│  (generates  │     │  (reads from │
│   sales)     │     │   database)  │
└──────────────┘     └──────────────┘
         ⊘ (Separate Ways)

سؤال: آیا Reporting هم‌زمان با Orders است؟

اگر بله (تاخیر ۶ ساعت مقبول):

  • ✓ Separate Ways خوب است

اگر نه (تاخیر ناپذیر):

  • ✗ Separate Ways نمی‌تواند کار کند
  • ✓ به Open Host Service تغییر بدهید

Context Map برای انتقال تلفنی

یکی از بهترین استفاده‌های Context Map این است که برای توضیح سیستم در جلسات تیم یا مراجعات معماری استفاده شود.

جلسه: تصمیم‌گیری درباره‌ی نوع الگو

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
سناریو: "آیا Payment باید Separate Ways باشد؟"

Context Map:
┌──────────────┐
│   Orders     │
└────────┬─────┘
         │
         ▼ (Separate Ways?)
┌──────────────┐
│   Payment    │
└──────────────┘

بحث:
Engineer A: "اگر Payment fail کند، Order ناقص بماند!"
Engineer B: "می‌تواند async job که دوباره تلاش کند."
Architect: "بیایید Open Host Service استفاده کنیم."

نتیجه: ارتباط تغییر می‌کند:
┌──────────────┐
│   Orders     │
└────────┬─────┘
         │
         ▼ OHS
┌──────────────┐
│   Payment    │
└──────────────┘

نکات مهم درباره‌ی Context Map

۱. نه استاتیک، پویا است: Context Map تغییر می‌کند وقتی سیستم تکامل می‌یابد. هر ماه یا هر سه‌ماه آن را بروزرسانی کنید.

۲. برای پروژه‌های بزرگ ضروری است: برای سیستم‌های ۲-۳ Bounded Context، Context Map ساده است. برای ۱۰+ Bounded Context، ضروری است.

۳. ابزار برای ارتباط: Context Map زبان مشترک برای تیم‌های مختلف است. مهندسان، معماران، و مدیران می‌تواند آن را درک کنند.

۴. نگهداری Context Map: Context Map باید در مخزن پروژه ذخیره شود (مثلاً architecture/context-map.md یا context-map.drawio).

۵. تکامل مسیری: در حالی‌که پروژه بڑتر می‌شود، Context Map ممکن است Separate Ways → Open Host Service یا Partnership → Open Host Service شود.

خلاصه‌ی فصل ۴

الگوهای یکپارچه‌سازی

گروهالگوهاکاربرد
تعاونPartnership، Shared Kernelتیم‌های قریب، ارتباط خوب
مشتری-تامینOHS، PL، ACLتامین‌کننده قوی، مشتری ضعیف
جداگانهSeparate Waysنیازهای متفاوت، استقلال

Context Map

  • نمودار بصری تمام Bounded Context‌ها و ارتباطات
  • ابزار ارتباط میان تیم‌ها
  • سند معماری برای سیستم

نکات کلیدی

۱. نوع ارتباط بر اساس نوع تیم است:

  • تیم‌های تعاون‌گر → Partnership یا Shared Kernel
  • تیم‌های نامتوازن → Open Host Service
  • تیم‌های مستقل → Separate Ways

۲. Anticorruption Layer مهم است: مشتری می‌تواند خودش را از تغییرات تامین‌کننده محفوظ کند.

۳. هیچ الگوی “بهترین” نیست: هر الگو برای شرایط خاص بهتر است.

۴. Context Map یک سند زندهٔ است: سیستم تکامل می‌یابد؛ Context Map نیز باید تکامل یابد.

پایان فصل ۴

نقاط مهم برای یادگیری

فصل ۳ و ۴ در یک نگاه:

  • فصل ۳: چگونه دامین را به Bounded Context‌ها تقسیم کنیم
  • فصل ۴: چگونه Bounded Context‌ها را یکپارچه کنیم و آن‌ها را مدیریت کنیم

بخش اول کتاب (Strategic Design) اکنون کامل است:

  • ✓ فصل ۱: تحلیل دامین کسب‌وکار
  • ✓ فصل ۲: زبان مشترک (Ubiquitous Language)
  • ✓ فصل ۳: Bounded Context
  • ✓ فصل ۴: یکپارچه‌سازی Bounded Context‌ها

بخش دوم (Tactical Design) درباره‌ی پیاده‌سازی Bounded Context‌ها خواهد بود:

  • الگوهای طراحی چگونگی
  • Aggregates
  • Domain Events
  • و غیره

فصل ۵: Aggregates - بخش اول

مقدمه: از Strategic Design به Tactical Design

تا اینجا بخش استراتژیک Domain-Driven Design را کامل کردیم:

  • چگونه دامین تجاری را تحلیل کنیم (فصل ۱)
  • چگونه زبان مشترک بسازیم (فصل ۲)
  • چگونه Bounded Context‌ها را طراحی کنیم (فصل ۳)
  • چگونه Bounded Context‌ها را یکپارچه کنیم (فصل ۴)

حالا وارد بخش تاکتیکی می‌شویم. این بخش درباره‌ی پیاده‌سازی است. یعنی:

  • چگونه کد بنویسیم
  • چگونه اشیاء را سازمان‌دهی کنیم
  • چگونه منطق تجاری را محدود کنیم

تغییر دیدگاه

Strategic Design:

  • دید کل: سیستم کدام Bounded Context‌ها دارد؟
  • سطح تصمیم‌گیری: معمار یا تیم سرپرست

Tactical Design:

  • دید جزئی: درون یک Bounded Context چگونه کد بنویسیم؟
  • سطح تصمیم‌گیری: مهندس نرم‌افزار، معمار کد

مشکل: اشیاء بدون مرز

قبل از اینکه درباره‌ی Aggregates صحبت کنیم، اجازه دهید مسئله را بفهمیم.

مثال: سفارش (Order)

فرض کنید یک Bounded Context Order Management داریم. یک سفارش شامل:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Order
{
    public string Id { get; set; }
    public string CustomerId { get; set; }
    public List<OrderLine> Lines { get; set; }
    public Customer Customer { get; set; }  // مرجع به Customer
    public List<Payment> Payments { get; set; }
    public Shipment Shipment { get; set; }
    public Invoice Invoice { get; set; }
    public List<Activity> Activities { get; set; }
    public List<Comment> Comments { get; set; }
    // ... ۵۰ ویژگی دیگر!
}

public class OrderLine
{
    public string ProductId { get; set; }
    public Product Product { get; set; }  // مرجع به Product
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

public class Customer
{
    public string Id { get; set; }
    public string Name { get; set; }
    public List<Order> Orders { get; set; }
    public CreditCard CreditCard { get; set; }
    // ...
}

مشکلات

۱. مرزی نیست: کوئی نمی‌دانند Order کجا شروع می‌شود و کجا تمام می‌شود. آیا Shipment جزء Order است؟ آیا Customer جزء Order است؟

۲. بارگذاری بیش‌ازحد: اگر Order را بارگذاری کنیم، تمام مرتبط‌ها (Customer، Payments، Comments، و غیره) هم بارگذاری می‌شود. کند است.

۳. ناسازگاری: فرض کنید Order و Invoice به‌طور ناسازگار بروزرسانی شوند:

  • Order Amount = $100
  • Invoice Amount = $150

کدام درست است؟ کسی نمی‌دانند!

۴. نگهداری سخت: تغییر کردن یک مرجع ممکن است ۲۰ جای دیگر را شکست دهد.

حل: Aggregate Pattern

Aggregate یک الگوی طراحی است که:

  • مرزی واضح را تعریف می‌کند
  • داده‌ای منسجم را محدود می‌کند
  • قوانین تجاری را درون Aggregate حفاظت می‌کند

تعریف Aggregate

Aggregate = گروهی از اشیاء که:

  • با هم کار می‌کنند
  • یک مدل تجاری واحد را نمایندگی می‌کنند
  • یک مرز واضح دارند

مثال: Order Aggregate

به‌جای اینکه Order تمام چیز را شامل شود، Aggregate را به این‌گونه طراحی می‌کنیم:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Order Aggregate
├── Order (Root)
│   ├── id
│   ├── customer_id  (فقط ID، نه کل Customer!)
│   ├── status
│   └── created_at
│
├── OrderLine (درون Aggregate)
│   ├── product_id
│   ├── quantity
│   └── price
│
└── OrderTotal (درون Aggregate)
    ├── subtotal
    ├── tax
    └── total

خارج از Aggregate:
├── Customer (مرجع جداگانه)
├── Payment (Aggregate جداگانه)
├── Shipment (Aggregate جداگانه)
└── Invoice (Aggregate جداگانه)

نکات مهم

۱. Aggregate Root: Order Root است. یعنی تنها نقطه‌ی دسترسی به داخل Aggregate.

۲. فقط ID‌ها: بدلاً از اینکه Customer کامل را درون Order ذخیره کنیم، فقط customer_id ذخیره می‌کنیم.

۳. مرز مشخص: OrderLine‌ها درون Order هستند. اما Payment، Shipment، Invoice خارج هستند.

Aggregate Root

Aggregate Root نقطه‌ی ورود تنها‌ی Aggregate است.

نمونه کد

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Order  // ← Aggregate Root
{
    private string _id;
    private string _customerId;
    private List<OrderLine> _lines;  // private!
    
    // Aggregate Root فقط درخواست‌های معتبر را می‌پذیرد
    public void AddLineItem(string productId, int quantity, decimal price)
    {
        // قانون تجاری: کمیت باید مثبت باشد
        if (quantity <= 0)
            throw new InvalidOperationException("Quantity must be positive");
        
        // قانون تجاری: Order فقط ۱۰۰ خط می‌تواند داشته باشد
        if (_lines.Count >= 100)
            throw new InvalidOperationException("Order cannot have more than 100 lines");
        
        _lines.Add(new OrderLine(productId, quantity, price));
    }
    
    public void RemoveLineItem(int index)
    {
        if (index < 0 || index >= _lines.Count)
            throw new ArgumentOutOfRangeException(nameof(index));
        
        _lines.RemoveAt(index);
    }
    
    public decimal GetTotal()
    {
        return _lines.Sum(l => l.GetAmount());
    }
}

public class OrderLine  // ← عضو Aggregate (نه Root)
{
    private string _productId;
    private int _quantity;
    private decimal _price;
    
    // OrderLine نمی‌تواند **مستقل** تغییر کند
    // تنها درون Order می‌تواند تغییر کند
    
    public decimal GetAmount() => _quantity * _price;
}

کوئری‌کردن Aggregate

1
2
3
4
5
6
7
8
9
10
11
// درست: Order Root را بارگذاری می‌کنیم
var order = repository.GetOrder(orderId);

// درست: از Root، LineItems را می‌خواهیم
var lines = order.GetLineItems();

// نادرست: OrderLine را **مستقل** بارگذاری نکنیم
var line = repository.GetOrderLine(lineId);  // ✗ نادرست!

// نادرست: Order را **کامل** بارگذاری نکنیم
var order = repository.GetOrderWithCustomer(orderId);  // ✗ نادرست!

حدود Aggregate

Aggregate باید کوچک و متمرکز باشد.

قانون: Aggregate باید تنها یک Subdomain را مدل‌سازی کند

اگر Aggregate چندین Subdomain را شامل کند:

1
2
3
4
5
✗ بد: Order Aggregate شامل Inventory است
  Order ← Inventory (ارتباط سخت)
  
✓ خوب: Order و Inventory Aggregate‌های جداگانه هستند
  Order ←→ Inventory (ارتباط ضعیف، فقط ID)

قانون: Aggregate باید تراکنش واحد باشد

اگر نیاز دارید دو Aggregate را یک‌زمان و یک‌جوری ذخیره کنید:

1
2
3
4
5
6
7
✗ بد: Customer و Order را **همزمان** ذخیره کنیم
  update customer
  update order  ← اگر fail کند، customer بروز‌شده اما order نه!
  
✓ خوب: Order Aggregate را **تنها** ذخیره کنیم
  update order
  [جداگانه: Customer Aggregate را بروزرسانی کنیم]

ارتباط بین Aggregate‌ها

۱. مرجع ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Order  // Order Aggregate
{
    public string CustomerId { get; set; }  // فقط ID!
    
    // اگر نیاز داریم Customer را بدانیم:
    public void SetCustomer(Customer customer)
    {
        if (customer == null)
            throw new ArgumentNullException(nameof(customer));
        
        this.CustomerId = customer.Id;
        // Customer خود را ذخیره **نمی‌کنیم**
    }
}

۲. Service برای ارتباط بین Aggregate‌ها

اگر نیاز دارید دو Aggregate را یک‌جوری ترکیب کنیم:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class CreateOrderService
{
    private readonly IOrderRepository orderRepository;
    private readonly ICustomerRepository customerRepository;
    
    public CreateOrderService(
        IOrderRepository orderRepository,
        ICustomerRepository customerRepository)
    {
        this.orderRepository = orderRepository;
        this.customerRepository = customerRepository;
    }
    
    public void CreateOrder(string customerId, List<OrderLineDto> lines)
    {
        // Aggregate ۱: Customer را بارگذاری کنیم
        var customer = customerRepository.GetById(customerId);
        
        if (customer == null)
            throw new InvalidOperationException("Customer not found");
        
        // Aggregate ۲: Order جدید را بسازیم
        var order = new Order(customer.Id);
        
        foreach (var line in lines)
        {
            order.AddLineItem(line.ProductId, line.Quantity, line.Price);
        }
        
        // Order را ذخیره کنیم (فقط Order، نه Customer)
        orderRepository.Save(order);
    }
}

نکات:

  • هر Aggregate مستقل ذخیره می‌شود
  • Service نقطه‌ی هماهنگی است
  • اگر یکی fail شود، دیگری تأثیر نمی‌پذیرد

Identity و Aggregate Root

۱. Aggregate Root باید شناسه منحصر به فرد داشته باشد

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Order
{
    public OrderId Id { get; }  // شناسه منحصر به فرد
    
    public Order(OrderId id, string customerId)
    {
        if (id == null)
            throw new ArgumentNullException(nameof(id));
        
        this.Id = id;
        this.CustomerId = customerId;
    }
}

// استفاده:
var orderId = new OrderId(Guid.NewGuid());
var order = new Order(orderId, "CUSTOMER_123");

۲. اعضای Aggregate شناسه ندارند (یا محلی)

1
2
3
4
5
6
7
8
9
public class OrderLine
{
    // OrderLine شناسه **جهانی** ندارد
    // تنها درون Order شناخته می‌شود
    
    public int LineNumber { get; set; }  // محلی
    public string ProductId { get; set; }
    public int Quantity { get; set; }
}

حالت Aggregate و Invariants

Invariant = قانونی که همیشه صحیح باید باشد.

مثال: Invariant در Order

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Order
{
    private List<OrderLine> _lines;
    private decimal _totalAmount;
    
    // Invariant: OrderTotal صحیح است
    private void UpdateTotal()
    {
        _totalAmount = _lines.Sum(l => l.GetAmount());
    }
    
    public void AddLineItem(string productId, int quantity, decimal price)
    {
        var line = new OrderLine(productId, quantity, price);
        _lines.Add(line);
        UpdateTotal();  // Invariant را حفاظت کنیم
    }
    
    public void RemoveLineItem(int index)
    {
        _lines.RemoveAt(index);
        UpdateTotal();  // Invariant را حفاظت کنیم
    }
    
    // Invariant: Order نمی‌تواند منفی باشد
    public void ApplyDiscount(decimal discountAmount)
    {
        if (discountAmount > _totalAmount)
            throw new InvalidOperationException("Discount cannot exceed total");
        
        _totalAmount -= discountAmount;
    }
}

نکات:

  • هر عملیات Invariant‌ها را حفاظت می‌کند
  • اگر Invariant شکسته شود، Exception پرتاب می‌شود
  • پایگاه داده نمی‌تواند Aggregate را به حالت نامعتبر ذخیره کند

بارگذاری و ذخیره‌ی Aggregate

بارگذاری (Load)

1
2
3
4
5
6
7
8
9
10
11
12
public interface IOrderRepository
{
    // Aggregate Root را بارگذاری کنیم
    Order GetById(OrderId id);
    
    // نادرست: OrderLine را مستقل بارگذاری نکنیم
    // OrderLine GetLineById(int lineId);  ✗
}

// استفاده:
var order = repository.GetById(orderId);
var firstLine = order.GetLineItems().First();  // ✓ صحیح

ذخیره‌سازی (Save)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface IOrderRepository
{
    // Aggregate Root را ذخیره کنیم
    void Save(Order order);
    
    // نادرست: OrderLine را مستقل ذخیره نکنیم
    // void SaveOrderLine(OrderLine line);  ✗
}

// استفاده:
var order = new Order(orderId, customerId);
order.AddLineItem("PROD_001", 2, 50m);
order.AddLineItem("PROD_002", 1, 100m);

repository.Save(order);  // ✓ صحیح: Order کامل ذخیره می‌شود

خلاصه‌ی بخش اول

Aggregate چیست؟

  • گروهی از اشیاء با مرز واضح
  • یک مدل تجاری واحد را نمایندگی می‌کند
  • Invariant‌ها را حفاظت می‌کند

Aggregate Root:

  • نقطه‌ی ورود تنها‌ی Aggregate
  • شناسه منحصر به فرد دارد
  • تمام قوانین تجاری را کنترل می‌کند

اصول:

  • ✓ Aggregate ها باید کوچک باشند
  • ✓ Aggregate ها باید متمرکز باشند
  • فقط ID برای ارتباط بین Aggregate‌ها
  • هر Aggregate مستقل ذخیره می‌شود
  • Invariant‌ها درون Aggregate حفاظت می‌شوند

فصل ۵: Aggregates - بخش دوم

طراحی Aggregate: قواعد و اصول

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

۱. قانون “تراکنش واحد” (One Transaction Rule)

این مهم‌ترین قانون Aggregate است: در هر تراکنش دیتابیس، تنها یک نمونه از Aggregate باید تغییر کند.

چرا؟

  • مقیاس‌پذیری (Scalability): اگر تراکنش شما ۵ Aggregate مختلف را قفل کند، دیتابیس زیر بار می‌رود.
  • سادگی: مدیریت شکست تراکنش برای یک Aggregate ساده است.
  • مرزهای درست: اگر مجبورید دو Aggregate را همزمان تغییر دهید، شاید مرزهای آن‌ها غلط است (شاید باید یکی شوند).

مثال غلط:

1
2
3
4
5
6
// تراکنش بزرگ و خطرناک
transaction.Start();
order.AddItem(item);
customer.UpdateLastOrderDate(date); // تغییر Aggregate دوم!
inventory.DecreaseStock(item);      // تغییر Aggregate سوم!
transaction.Commit();

مثال درست (استفاده از Eventual Consistency):

1
2
3
4
5
6
7
8
9
10
// ۱. تغییر Order (تراکنش اول)
transaction.Start();
order.AddItem(item);
// Order یک رویداد "OrderUpdated" منتشر می‌کند
transaction.Commit();

// ۲. پردازش رویداد (تراکنش جداگانه)
// وقتی رویداد دریافت شد:
customer.UpdateLastOrderDate(event.Date);
inventory.DecreaseStock(event.Item);

۲. ارجاع فقط با شناسه (Reference by ID)

Aggregate‌ها نباید به شیء یکدیگر ارجاع دهند، بلکه باید فقط شناسه (ID) یکدیگر را نگه دارند.

مثال غلط (Object Reference):

1
2
3
4
5
public class Order {
    // ارجاع مستقیم به شیء Customer
    // این باعث می‌شود با لود کردن Order، کل Customer هم لود شود!
    public Customer Customer { get; set; } 
}

مثال درست (ID Reference):

1
2
3
4
public class Order {
    // فقط شناسه نگه داشته می‌شود
    public Guid CustomerId { get; set; } 
}

مزایا:

  • Lazy Loading خودکار: نیازی نیست نگران لود شدن اشیای اضافی باشید.
  • استقلال: Aggregate‌ها می‌توانند در دیتابیس‌های مختلف باشند.

۳. Aggregate‌های کوچک (Small Aggregates)

Aggregate باید تا حد امکان کوچک باشد.

  • اشتباه رایج: ساختن “مگا-Aggregate” که همه چیز را دارد.
    • مثال: Customer که لیستی از تمام Orderهای تاریخچه خود را دارد.
    • نتیجه: هر بار که آدرس مشتری عوض می‌شود، دیتابیس باید هزاران سفارش را هم مدیریت کند (یا حداقل در حافظه لود کند).
  • طراحی درست:
    • Customer فقط اطلاعات مشتری را دارد.
    • Order مستقل است و به Customer اشاره می‌کند.

خطاهای عمومی در طراحی Aggregate

۱. اشتباه گرفتن Aggregate با Data Model

Aggregate یک مدل داده (Data Model) برای گزارش‌گیری نیست. Aggregate برای تغییر دادن داده (Command) و اجرای قوانین تجاری است.

  • اگر می‌خواهید یک لیست از “سفارش‌ها با نام مشتری” نمایش دهید، نباید Aggregate‌ها را به هم وصل کنید.
  • باید از Read Model (در فصل‌های بعد مثل CQRS خواهیم دید) استفاده کنید که با یک کوئری SQL ساده (Join) داده‌ها را می‌خواند.

۲. تزریق Repository به Aggregate

هیچ‌وقت Repository را به درون کلاس Aggregate تزریق نکنید.

1
2
3
4
5
6
7
// ❌ غلط: Aggregate نباید به بیرون دسترسی داشته باشد
public class Order {
    public void AddItem(Item item, IInventoryRepository repo) {
        repo.CheckStock(item); // وابستگی به زیرساخت!
        ...
    }
}

راه‌حل: داده‌های مورد نیاز را قبل از صدا زدن متد آماده کنید و به آن پاس دهید.

1
2
3
4
5
// ✅ درست
public void AddItem(Item item, bool isStockAvailable) {
    if (!isStockAvailable) throw new Exception("Out of stock");
    ...
}

الگوهای کار با Aggregate‌های پیچیده

گاهی اوقات منطق تجاری آنقدر پیچیده است که در یک Aggregate جا نمی‌شود یا نیاز به هماهنگی بین چند Aggregate دارد.

Domain Service (سرویس دامنه)

وقتی منطقی متعلق به یک موجودیت خاص نیست یا به چند Aggregate مربوط می‌شود، از Domain Service استفاده می‌کنیم.

مثال: انتقال پول از یک حساب به حساب دیگر.

  • این منطق نه متعلق به “حساب مبدا” است و نه “حساب مقصد”.
  • این یک عملیات است که بین دو حساب رخ می‌دهد.
1
2
3
4
5
6
public class MoneyTransferService {
    public void Transfer(Account from, Account to, Money amount) {
        from.Debit(amount);
        to.Credit(amount);
    }
}

نکته: Domain Service نباید حالت (State) نگه دارد. فقط منطق را اجرا می‌کند.

نتیجه‌گیری فصل ۵

Aggregate‌ها بلوک‌های اصلی ساختمانِ Logic سیستم شما هستند. آن‌ها:

  1. سازگاری (Consistency) داده‌ها را تضمین می‌کنند.
  2. مرزهای تراکنش را مشخص می‌کنند.
  3. پیچیدگی را با پنهان‌سازی جزئیات مدیریت می‌کنند.

چک‌لیست طراحی Aggregate:

  • آیا Aggregate Root یک شناسه جهانی دارد؟
  • آیا تمام تغییرات فقط از طریق Root انجام می‌شود؟
  • آیا ارجاع به سایر Aggregate‌ها فقط با ID است؟
  • آیا Aggregate به اندازه کافی کوچک است؟
  • آیا قوانین “یک تراکنش” رعایت شده است؟

در مدل سنتی (Monolith با دیتابیس رابطه‌ای)، ما عادت داشتیم همه چیز را در یک تراکنش بزرگ (ACID) انجام دهیم. اما وقتی قانون “تراکنش واحد به ازای هر Aggregate” را رعایت می‌کنیم، دیگر “همزمانی مطلق” (به معنی اتمیک بودن در یک لحظه واحد برای هر دو عملیات) را از دست می‌دهیم.

پس چگونه مطمئن شویم که اگر سفارش ثبت شد، موجودی حتماً کم می‌شود؟ (یا اگر موجودی کم نشد، سفارش لغو شود؟)

برای این کار در DDD از الگوی Saga (ساگا) یا Process Manager استفاده می‌کنیم. بیایید سناریو را دقیق بررسی کنیم:

سناریو: خرید و کاهش موجودی

ما دو Aggregate داریم:

  1. Order (سفارش)
  2. Inventory (انبار/موجودی)

این‌ها دو موجودیت جدا هستند و طبق قانون DDD نباید در یک تراکنش دیتابیس با هم تغییر کنند.

راه‌حل: فرآیند چند مرحله‌ای (Saga)

به جای اینکه سعی کنیم همه کار را در یک لحظه انجام دهیم، آن را به یک فرآیند چند مرحله‌ای تبدیل می‌کنیم که “نهایتاً” سازگار می‌شود.

مراحل:

  1. گام اول (Order): کاربر درخواست خرید می‌دهد.
    • Aggregate Order ساخته می‌شود.
    • وضعیت اولیه سفارش: PENDING (در انتظار).
    • سیستم سفارش را ذخیره می‌کند و رویداد OrderCreated را منتشر می‌کند.
    • (تراکنش اول تمام شد)
  2. گام دوم (Inventory): سیستم (یا یک کامپوننت گوش‌به‌زنگ) رویداد OrderCreated را دریافت می‌کند.
    • فرمانی به Aggregate Inventory ارسال می‌شود: “موجودی این کالا را رزرو کن”.
    • Inventory چک می‌کند:
      • حالت الف (موجودی هست): موجودی را کم می‌کند و رویداد StockReserved منتشر می‌کند.
      • حالت ب (موجودی نیست): رویداد StockReservationFailed منتشر می‌کند.
    • (تراکنش دوم تمام شد)
  3. گام سوم (Order - بازخورد): سیستم گوش‌به‌زنگِ رویدادهای انبار است.
    • اگر StockReserved آمد: وضعیت سفارش را به CONFIRMED (تایید شده) تغییر می‌دهد.
    • اگر StockReservationFailed آمد: وضعیت سفارش را به CANCELLED (لغو شده) تغییر می‌دهد (و شاید ایمیلی به کاربر بزند که “شرمنده، موجودی تمام شد”).
    • (تراکنش سوم تمام شد)

چطور “تضمین” می‌کنیم این اتفاق بیفتد؟

شاید بپرسی: “اگر بعد از گام ۱، سیستم قطع شد و گام ۲ اجرا نشد چه؟”

اینجاست که الگوهایی مثل Outbox Pattern وارد می‌شوند:

  1. تضمین حداقل یک‌بار اجرا (At-least-once delivery): وقتی Order ذخیره می‌شود، رویداد OrderCreated هم در همان تراکنش دیتابیس (مثلاً در یک جدول Outbox) ذخیره می‌شود. یک پردازشگر جداگانه دائماً این جدول را چک می‌کند و پیام‌ها را به صف (Message Broker) می‌فرستد. تا زمانی که تایید نگیرد که پیام پردازش شده، آن را دوباره و دوباره می‌فرستد.

  2. Idempotency (تکرارپذیری امن): چون ممکن است پیام‌ها چند بار ارسال شوند (مثلاً شبکه قطع و وصل شود)، گیرنده (Inventory) باید هوشمند باشد. باید چک کند “آیا من قبلاً موجودی را برای OrderID: 123 کم کرده‌ام؟”. اگر بله، بار دوم کاری نمی‌کند.

خلاصه

ما “همزمانی لحظه‌ای” (Strong Consistency) را فدای “سازگاری نهایی” (Eventual Consistency) می‌کنیم، اما با مکانیزم‌هایی مثل Saga و Outbox Pattern تضمین می‌کنیم که سیستم در نهایت به یک حالت معتبر و پایدار برسد (یا سفارش تایید شود و موجودی کم شود، یا سفارش لغو شود).

این روش شاید پیچیده‌تر به نظر برسد، اما تنها راهی است که سیستم‌های بزرگ و مقیاس‌پذیر (مثل آمازون) می‌توانند بدون قفل کردن کل دیتابیس کار کنند.


Domain Service - توضیح عمیق‌تر

Domain Service چیست؟

تعریف ساده: Domain Service یک کلاس بدون حالت (Stateless) است که منطق تجاری‌ای را پیاده‌سازی می‌کند که:

  1. متعلق به یک Aggregate خاص نیست
  2. به چندین Aggregate یا مفهوم مربوط می‌شود
  3. نیاز به هماهنگی یا مختصی‌کاری دارد

مثال ۱: انتقال پول بین حساب‌ها

چرا نمی‌تواند در Aggregate باشد؟

1
2
3
4
5
6
7
// ❌ اشتباه: این منطق کجا باید باشد؟
public class Account {
    public void TransferTo(Account destination, Money amount) {
        this.Debit(amount);           // حساب مبدا
        destination.Credit(amount);   // حساب مقصد
    }
}

مشکلات:

  1. کدام Aggregate تغییر می‌کند؟ هردو! این نقض قانون “یک تراکنش، یک Aggregate” است.

  2. وابستگی دوطرفه: Account A باید درخواست دریافت کند از Account B. این وابستگی نامعقول است.

  3. کجا ذخیره شود؟ حتی اگر بتوانیم این را انجام دهیم، آن را در کدام جدول دیتابیسی ذخیره کنیم؟ accounts جدول؟

✅ راه‌حل: Domain Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Domain Service (بدون حالت)
public class MoneyTransferService 
{
    private readonly IAccountRepository accountRepository;
    
    public MoneyTransferService(IAccountRepository accountRepository)
    {
        this.accountRepository = accountRepository;
    }
    
    // این متد منطقِ انتقال پول را هماهنگ می‌کند
    public void Transfer(
        AccountId fromId, 
        AccountId toId, 
        Money amount)
    {
        // گام ۱: بارگذاری حساب‌های مختلف
        var fromAccount = accountRepository.GetById(fromId);
        var toAccount = accountRepository.GetById(toId);
        
        // گام ۲: بررسی شروط
        if (!fromAccount.HasSufficientBalance(amount))
            throw new InsufficientFundsException();
        
        // گام ۳: تغییر Aggregate اول
        fromAccount.Debit(amount);
        accountRepository.Save(fromAccount); // تراکنش ۱
        
        // گام ۴: تغییر Aggregate دوم
        toAccount.Credit(amount);
        accountRepository.Save(toAccount);   // تراکنش ۲
    }
}

مزایا:

  • ✓ منطق انتقال در یک جای واضح است
  • ✓ هر Aggregate مستقل تغییر می‌کند
  • ✓ قانون “یک تراکنش، یک Aggregate” رعایت شده است
  • ✓ اگر تراکنش دوم ناکام شود، Aggregate اول قابل بازگشت است (Compensating Action)

مثال ۲: محاسبه سقف زمانی پاسخ (SLA - Service Level Agreement)

مسئله

فرض کنید یک سیستم پشتیبانی مشتری داریم.

هر تیکت (Ticket) نیاز دارد:

  • تاریخ انتظار پاسخ: بستگی به اولویت تیکت و سیاست بخش (Department Policy) و برنامه کاری نماینده (Agent Shift)

مثال:

  • تیکت عادی: پاسخ در ۴ ساعت
  • تیکت دارای اولویت: پاسخ در ۱ ساعت
  • اما اگر نماینده در تعطیل است: سقف زمانی به روز بعد موکول می‌شود

چرا نمی‌تواند در Aggregate باشد؟

1
2
3
4
5
6
7
8
9
// ❌ مشکل: منطق برای یک Ticket نیست، برای سیاست بخش و برنامه است
public class Ticket {
    public void CalculateResponseDeadline() {
        // باید Department را بخواند
        // باید WorkSchedule را بخواند
        // باید Policy را بخواند
        // این‌ها جاهای مختلفی هستند!
    }
}

✅ راه‌حل: Domain Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// Domain Service
public class ResponseTimeFrameCalculationService 
{
    private readonly IDepartmentRepository departmentRepository;
    private readonly IWorkScheduleRepository workScheduleRepository;
    
    public ResponseTimeFrameCalculationService(
        IDepartmentRepository departmentRepository,
        IWorkScheduleRepository workScheduleRepository)
    {
        this.departmentRepository = departmentRepository;
        this.workScheduleRepository = workScheduleRepository;
    }
    
    // این متد سقف زمانی را محاسبه می‌کند
    public DateTime CalculateResponseDeadline(
        UserId agentId,
        Priority priority,
        bool isEscalated,
        DateTime startTime)
    {
        // گام ۱: دریافت سیاست بخش
        var department = departmentRepository.GetDepartmentOf(agentId);
        var policy = department.GetPolicy();
        
        // گام ۲: محاسبه زمان برای اولویت
        var maxResponseTime = policy.GetMaxResponseTime(priority);
        
        // گام ۳: اگر تشدید شده است، زمان کاهش می‌یابد
        if (isEscalated)
        {
            maxResponseTime = maxResponseTime * policy.EscalationFactor;
        }
        
        // گام ۴: برنامه کاری نماینده را بخواند
        var shifts = workScheduleRepository.GetUpcomingShifts(
            agentId, 
            startTime, 
            startTime.Add(maxResponseTime));
        
        // گام ۵: محاسبه دقیق با در نظر گرفتن تعطیلات
        var deadline = CalculateDeadlineConsideringShifts(
            startTime, 
            maxResponseTime, 
            shifts);
        
        return deadline;
    }
    
    private DateTime CalculateDeadlineConsideringShifts(
        DateTime startTime,
        TimeSpan workingTime,
        IEnumerable<WorkShift> shifts)
    {
        var currentTime = startTime;
        var remainingTime = workingTime;
        
        foreach (var shift in shifts)
        {
            if (remainingTime <= TimeSpan.Zero)
                break;
            
            var availableTime = shift.GetDuration();
            var timeToUse = TimeSpan.FromMinutes(
                Math.Min(availableTime.TotalMinutes, remainingTime.TotalMinutes));
            
            currentTime = shift.StartTime.Add(timeToUse);
            remainingTime -= timeToUse;
        }
        
        return currentTime;
    }
}

استفاده:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// در Application Service
public class AssignTicketToAgentService 
{
    private readonly ITicketRepository ticketRepository;
    private readonly ResponseTimeFrameCalculationService timeService;
    
    public void AssignTicket(TicketId ticketId, UserId agentId)
    {
        // بارگذاری Ticket
        var ticket = ticketRepository.GetById(ticketId);
        
        // استفاده از Domain Service برای محاسبه سقف زمانی
        var deadline = timeService.CalculateResponseDeadline(
            agentId,
            ticket.Priority,
            ticket.IsEscalated,
            DateTime.UtcNow);
        
        // تخصیص تیکت
        ticket.AssignTo(agentId, deadline);
        
        ticketRepository.Save(ticket);
    }
}

مزایا:

  • ✓ منطق پیچیده محاسبه در یک جای واضح است
  • ✓ می‌تواند با چندین منبع داده کار کند
  • ✓ قابلِ تست است (Testable)
  • ✓ قابلِ استفاده مجدد است (Reusable)

Domain Service در مقابل Application Service

سؤال: آیا Domain Service مثل Application Service است؟

نه! این دو متفاوت هستند:

ویژگیDomain ServiceApplication Service
نامبخشی از Domain Modelبخشی از Infrastructure/Application
منطقمنطق تجاریمنطق پیاده‌سازی/ترتیب‌دهی
وابستگیفقط Domain Objects و RepositoriesDatabase، External APIs، UI
مثالMoneyTransferServiceTransferMoneyCommandHandler
کجا قرار می‌گیردDomain پوشهApplication پوشه

مثال: متوازی‌سازی

1
2
3
4
5
6
7
8
9
10
Command Handler (Application Service)
├── ✓ اعتبارسنجی ورودی
├── ✓ مجوز‌سنجی (Authorization)
├── Domain Service ← منطق تجاری
│   ├── ✓ محاسبات پیچیده
│   ├── ✓ قوانین تجاری
│   └── ✓ هماهنگی بین Aggregate‌ها
├── Repository
│   └── ✓ ذخیره‌سازی
└── ✓ جواب دادن

نکات مهم درباره‌ی Domain Service

۱. Stateless (بدون حالت)

1
2
3
4
5
6
7
8
9
10
11
// ❌ غلط: Domain Service نباید حالت داشته باشد
public class BadTransferService {
    private Money _cachedAmount;  // حالت
    
    public void Transfer(...) { ... }
}

// ✅ درست: هیچ حالتی نگه نمی‌دارد
public class GoodTransferService {
    public void Transfer(...) { ... }
}

۲. تنها درخواست‌های معتبر می‌پذیرد

1
2
3
4
5
6
7
8
9
10
11
public void Transfer(AccountId from, AccountId to, Money amount)
{
    // ✓ بررسی شروط
    if (from == to)
        throw new InvalidOperationException("Cannot transfer to same account");
    
    if (amount.IsNegative)
        throw new InvalidOperationException("Amount must be positive");
    
    // ...
}

۳. Domain Service برای منطق عام است

1
2
3
4
5
6
7
8
9
// اگر منطقی فقط برای یک Aggregate است، درون Aggregate قرار دارد
public class Customer {
    public void UpdateEmail(string email) { /* منطق ویژه Customer */ }
}

// اگر منطقی بین چند Aggregate است، Domain Service
public class CustomerNotificationService {
    public void NotifyAllRelatedAggregates(CustomerId id, Message msg) { /* ... */ }
}

خلاصه

Domain Service زمانی استفاده می‌شود که:

✓ منطق به یک Aggregate خاص متعلق نیست
✓ به چند Aggregate یا مفهوم مربوط می‌شود
✓ نیاز به هماهنگی بین موجودیت‌ها دارد
✓ محاسبات پیچیده که نیاز به منابع مختلفی دارند

Domain Service نیست:

  • فقط یک “helper” یا “utility” کلاس
  • جای ذخیره منطق Application
  • جایی برای Business Logic اصلی (آن‌ها برای Aggregate است)

این سوال یکی از اساسی‌ترین مسائل در سیستم‌های تراکنش‌محور (مثل بانکی) است. در مثال قبلی (MoneyTransferService)، چون دو Aggregate (fromAccount و toAccount) در دو تراکنش جدا ذخیره می‌شوند، خطر Race Condition وجود دارد.

بیایید سناریو را دقیق بررسی کنیم:

سناریو خطرناک:

  1. کاربر ۱ درخواست انتقال ۱۰۰۰ تومان می‌دهد (موجودی: ۱۰۰۰).
  2. کاربر ۲ (یا همان کاربر در تب دیگر) درخواست برداشت ۱۰۰۰ تومان می‌دهد.
  3. هر دو درخواست همزمان به HasSufficientBalance(1000) می‌رسند.
  4. چون هنوز هیچ‌کدام Debit نکرده‌اند، هر دو True می‌گیرند.
  5. هر دو ۱۰۰۰ تومان کم می‌کنند.
  6. موجودی نهایی: ۱۰۰۰- (منفی هزار!)

راه‌حل‌ها

برای جلوگیری از این مشکل، چند راهکار وجود دارد که از ساده به پیچیده مرتب شده‌اند:

۱. قفل خوش‌بینانه (Optimistic Concurrency Control - Versioning)

این روش رایج‌ترین و استانداردترین روش در DDD است.

چگونه کار می‌کند؟ هر Aggregate یک فیلد Version (مثلاً عدد صحیح) دارد.

  • وقتی Aggregate را می‌خوانیم، Version فعلی را هم می‌خوانیم (مثلاً ۵).
  • وقتی می‌خواهیم ذخیره کنیم، به دیتابیس می‌گوییم: “این را آپدیت کن به شرطی که Version هنوز ۵ باشد”.
  • اگر در این فاصله کسی دیگر آن را آپدیت کرده باشد (و Version شده باشد ۶)، دیتابیس خطا می‌دهد.
1
2
3
UPDATE Accounts
SET Balance = 0, Version = 6
WHERE Id = 'AccountA' AND Version = 5; -- شرط حیاتی

در کد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Account {
    public Guid Id { get; private set; }
    public decimal Balance { get; private set; }
    public int Version { get; private set; } // فیلد نسخه

    public void Debit(Money amount) {
        if (Balance < amount) throw new Exception("Not enough money");
        Balance -= amount;
        // Version به طور خودکار در Repository یا ORM افزایش می‌یابد
    }
}

// در سرویس:
try {
    fromAccount.Debit(amount);
    accountRepository.Save(fromAccount); // اگر نسخه تغییر کرده باشد، خطا می‌دهد
} catch (ConcurrencyException) {
    // خطا: "کسی دیگر موجودی را تغییر داد. لطفاً دوباره تلاش کنید."
    // اینجا می‌توانیم Retry کنیم
}

مزایا: پرفورمنس بالا (چون دیتابیس قفل نمی‌شود)، ساده. معایب: اگر رقابت خیلی زیاد باشد، تعداد خطاها زیاد می‌شود.

۲. قفل بدبینانه (Pessimistic Concurrency Control)

در این روش، وقتی Aggregate را از دیتابیس می‌خوانیم، آن را “قفل” می‌کنیم تا کس دیگری نتواند آن را بخواند یا بنویسد تا کار ما تمام شود.

1
SELECT * FROM Accounts WHERE Id = 'AccountA' FOR UPDATE;

در کد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// در Repository
public Account GetByIdWithLock(Guid id) {
    // اجرای کوئری با FOR UPDATE
}

// در سرویس
using (var transaction = db.BeginTransaction()) {
    var fromAccount = repo.GetByIdWithLock(fromId); // اینجا قفل می‌شود
    
    if (fromAccount.Balance < amount) ...
    
    fromAccount.Debit(amount);
    repo.Save(fromAccount);
    
    transaction.Commit(); // اینجا قفل آزاد می‌شود
}

مزایا: تضمین ۱۰۰٪ عدم تداخل. معایب: پرفورمنس پایین (چون بقیه باید منتظر بمانند)، خطر Deadlock (بن‌بست).

۳. استفاده از تراکنش دیتابیس (ACID Transaction)

اگر هر دو Aggregate در یک دیتابیس رابطه‌ای باشند، می‌توانیم کل عملیات را در یک تراکنش انجام دهیم.

1
2
3
4
5
6
7
8
9
10
11
12
using (var scope = new TransactionScope()) {
    var fromAccount = repo.GetById(fromId);
    var toAccount = repo.GetById(toId);
    
    fromAccount.Debit(amount);
    toAccount.Credit(amount);
    
    repo.Save(fromAccount);
    repo.Save(toAccount);
    
    scope.Complete();
}

نکته: این روش قانون “یک تراکنش، یک Aggregate” را نقض می‌کند، اما برای عملیات حساس مالی که نیاز به Strong Consistency دارند، گاهی به عنوان یک استثنای آگاهانه پذیرفته می‌شود.

۴. مدل مبتنی بر رویداد (Event Sourcing)

در Event Sourcing، ما “موجودی فعلی” را ذخیره نمی‌کنیم، بلکه “تراکنش‌ها” را ذخیره می‌کنیم.

  • وضعیت حساب = جمع تمام رویدادهای Deposited و Withdrawn.

برای حل مشکل همزمانی، باز هم از ExpectedVersion استفاده می‌شود (مشابه روش ۱). وقتی می‌خواهیم رویداد Withdrawn جدید را ذخیره کنیم، می‌گوییم “من انتظار دارم آخرین رویداد شماره ۵ باشد”. اگر رویداد شماره ۶ ثبت شده باشد، خطا می‌دهد.

بهترین روش کدام است؟

برای ۹۹٪ موارد در سیستم‌های مدرن و DDD، روش ۱. قفل خوش‌بینانه (Optimistic Locking) توصیه می‌شود.

چرا؟

  • چون استاندارد است.
  • چون دیتابیس را قفل نمی‌کند.
  • چون با معماری‌های توزیع‌شده سازگار است.
  • چون در سناریوی بانکی واقعی، احتمال اینکه دو نفر دقیقاً در یک میلی‌ثانیه از یک حساب برداشت کنند کم است، پس هزینه مدیریت خطا (Retry) کمتر از هزینه کند کردن کل سیستم (Lock) است.

پاسخ کوتاه: TransactionScope باید در Application Service باشد.

بیایید دلیلش را باز کنیم:

۱. چرا در Domain Service نباید باشد؟

همان‌طور که خودت اشاره کردی، لایه Domain باید “خالص” (Pure) باشد.

  • نباید به دیتابیس (SQL, EF Core) وابسته باشد.
  • نباید به کتابخانه‌های زیرساختی (System.Transactions) وابسته باشد.
  • نباید نگران “چگونگی” ذخیره‌سازی باشد؛ فقط نگران “قوانین تجاری” است.

اگر TransactionScope را در Domain Service بنویسیم، کدمان به تکنولوژی خاصی گره می‌خورد و تست کردن آن (Unit Test) سخت می‌شود.

۲. جایگاه درست: Application Service

لایه Application وظیفه‌اش Orchestration (هماهنگی) است. این لایه می‌گوید: “من می‌خواهم یک کار کامل (Use Case) انجام دهم”. این کار کامل شامل مدیریت تراکنش، امنیت، لاگ‌کردن و فراخوانی منطق دامنه است.

بنابراین ساختار صحیح به این شکل می‌شود:

الف) Domain Service (فقط منطق تجاری)

این سرویس فقط می‌داند که “اگر پول از یکی کم شد، باید به دیگری اضافه شود”. هیچ خبری از تراکنش دیتابیس ندارد.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// لایه Domain (بدون وابستگی به زیرساخت)
public class MoneyTransferDomainService 
{
    // این سرویس فقط منطق را اجرا می‌کند
    public void Transfer(Account from, Account to, Money amount)
    {
        if (!from.HasSufficientBalance(amount))
            throw new InsufficientFundsException();

        from.Debit(amount);
        to.Credit(amount);
        
        // نکته: اینجا Save یا Commit نمی‌کنیم!
        // فقط وضعیت اشیاء (In-Memory) تغییر کرده است.
    }
}

ب) Application Service (مدیریت تراکنش)

اینجاست که ما تراکنش را باز می‌کنیم، داده‌ها را لود می‌کنیم، سرویس دامنه را صدا می‌زنیم و در نهایت همه‌چیز را ذخیره می‌کنیم.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// لایه Application (مدیریت Use Case و تراکنش)
public class TransferMoneyUseCase 
{
    private readonly IAccountRepository _repo;
    private readonly MoneyTransferDomainService _domainService;

    public TransferMoneyUseCase(IAccountRepository repo, MoneyTransferDomainService domainService)
    {
        _repo = repo;
        _domainService = domainService;
    }

    public void Execute(Guid fromId, Guid toId, decimal amountValue)
    {
        // 1. شروع تراکنش (وابستگی به زیرساخت اینجا مجاز است)
        using (var scope = new TransactionScope()) 
        {
            // 2. لود کردن داده‌ها
            var fromAccount = _repo.GetById(fromId);
            var toAccount = _repo.GetById(toId);
            var money = new Money(amountValue);

            // 3. فراخوانی منطق خالص دامنه
            _domainService.Transfer(fromAccount, toAccount, money);

            // 4. ذخیره‌سازی تغییرات
            _repo.Save(fromAccount);
            _repo.Save(toAccount);

            // 5. پایان تراکنش (Commit واقعی اینجا رخ می‌دهد)
            scope.Complete();
        }
    }
}

یک نکته ظریف (Unit of Work)

در پروژه‌های واقعی، معمولاً به جای استفاده مستقیم از TransactionScope در کد، از الگوی Unit of Work استفاده می‌شود که پشت صحنه تراکنش را مدیریت می‌کند. در آن صورت کد Application Service حتی تمیزتر می‌شود:

1
2
3
4
5
6
7
8
9
10
public void Execute(...) {
    // باز شدن ضمنی تراکنش توسط UnitOfWork
    var from = repo.Get(id1);
    var to = repo.Get(id2);
    
    domainService.Transfer(from, to, amount);
    
    // این خط همه‌چیز را در یک تراکنش ذخیره می‌کند
    unitOfWork.Commit(); 
}

جمع‌بندی

  • Domain Service: فقط منطق تغییر موجودی‌ها (Debit/Credit) را دارد. نمی‌داند تراکنش چیست.
  • Application Service: مسئولیت باز کردن و بستن تراکنش (TransactionScope) را دارد تا مطمئن شود تمام تغییرات یا با هم ذخیره می‌شوند یا هیچ‌کدام.

پاسخ در یک کلمه نهفته است: DIP یا Dependency Inversion Principle (اصل وارونگی وابستگی).

بیایید بازش کنیم:

۱. مشکل وابستگی مستقیم (بدون اینترفیس در دامین)

فرض کنید اینترفیس را در لایه Infrastructure (جایی که کد دیتابیس هست) یا لایه Application بگذاریم.

در این حالت، لایه Domain (که قلب نرم‌افزار است) اگر بخواهد چیزی را ذخیره یا بازیابی کند، باید به لایه پایین‌تر (Infrastructure) وابسته شود.

1
Domain Layer  ---->  Infrastructure Layer (SQL/EF Core)

چرا این بد است؟

  • چون دامین شما به تکنولوژی آلوده می‌شود.
  • اگر بخواهید دیتابیس را از SQL به Mongo تغییر دهید، باید کدهای دامین را دستکاری کنید!
  • تست کردن دامین سخت می‌شود چون نمی‌توانید دیتابیس را به راحتی ماک (Mock) کنید.

۲. راه‌حل: وارونگی وابستگی (با اینترفیس در دامین)

در DDD، قانون این است: لایه دامین نباید به هیچ لایه خارجی وابسته باشد.

اما دامین نیاز دارد بداند “یک جایی هست که می‌توانم اطلاعات را بگیرم”. پس ما “قرارداد” (Interface) را در دامین تعریف می‌کنیم، اما “اجرا” (Implementation) را به لایه زیرساخت می‌سپاریم.

ساختار این‌طور می‌شود:

لایه Domain:

  • تعریف می‌کند: interface IAccountRepository
  • می‌گوید: “من نیاز دارم بتوانم GetById و Save کنم. برایم مهم نیست چطور.”

لایه Infrastructure:

  • اجرا می‌کند: class SqlAccountRepository : IAccountRepository
  • می‌گوید: “من طبق قرارداد دامین، کد SQL را می‌نویسم.”

نتیجه (جهت وابستگی‌ها):

1
Domain Layer (IAccountRepository)  <----  Infrastructure Layer (SqlAccountRepository)

دقت کن! جهت فلش برعکس شد. حالا زیرساخت به دامین وابسته است (چون دارد اینترفیس دامین را پیاده‌سازی می‌کند). دامین آزاد و مستقل باقی مانده است.

تمثیل دنیای واقعی: پریز برق

  • لایه دامین (شما): شما نیاز به برق دارید. یک “قرارداد” دارید به نام پریز برق. شما فقط می‌دانید دو سوراخ دارد و برق می‌دهد. برایتان مهم نیست پشت دیوار سیم‌کشی مسی است یا آلومینیومی، برق از سد می‌آید یا نیروگاه هسته‌ای.
    • پریز (Interface) در اتاق شما (Domain) است.
  • لایه زیرساخت (اداره برق): آن‌ها وظیفه دارند سیم‌کشی کنند و برق را به پریز برسانند تا با قرارداد شما جور در بیاید.

اگر پریز (Interface) متعلق به اداره برق بود، هر بار که آن‌ها سیستمشان را عوض می‌کردند، ممکن بود مجبور شوید دیوارهای خانه‌تان را خراب کنید!

خلاصه

ما اینترفیس Repository را در Domain می‌گذاریم تا:

  1. دامین مستقل بماند: دامین به دیتابیس وابسته نشود.
  2. زبان مشترک باشد: متدها به زبان دامین هستند (FindActiveUsers) نه زبان دیتابیس (Select *).
  3. تست‌پذیری: بتوانیم دامین را بدون دیتابیس واقعی تست کنیم.

سؤال خیلی خوبی است! اینجاست که اکثر مبتدیان گیج می‌شوند. بیایید دو گزینه را مقایسه کنیم:

گزینه ۱: Repository Interface در Application Layer

1
2
3
4
Architecture (غلط):
Domain Layer → Application Layer → Infrastructure Layer
                    ↓
            (IAccountRepository تعریف شده اینجا)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Application/Contracts/IAccountRepository.cs
public interface IAccountRepository 
{
    Account GetById(Guid id);
    void Save(Account account);
}

// Domain/Services/MoneyTransferService.cs
public class MoneyTransferDomainService 
{
    private readonly IAccountRepository _repo;  // ❌ وابسته به Application!
    
    public void Transfer(Account from, Account to, Money amount)
    {
        // ...
    }
}

مشکل:

  • Domain الآن وابسته به Application است!
  • اگر Application تغییر کند (مثلاً متد جدید اضافه کنیم)، Domain تأثیر می‌پذیرد.
  • جهت وابستگی “اشتباه” است:
1
2
Domain ───────────> Application  ❌
(بالا)              (پایین)

(این عکسِ آنچه باید اتفاق بیفتد!)

گزینه ۲: Repository Interface در Domain Layer

1
2
3
4
Architecture (درست):
Domain Layer ←── Application Layer ←── Infrastructure Layer
    ↓                                         ↓
(IAccountRepository)            (SqlAccountRepository)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Domain/Contracts/IAccountRepository.cs
public interface IAccountRepository 
{
    Account GetById(Guid id);
    void Save(Account account);
}

// Domain/Services/MoneyTransferService.cs
public class MoneyTransferDomainService 
{
    private readonly IAccountRepository _repo;  // ✓ وابسته به Domain خود!
    
    public void Transfer(Account from, Account to, Money amount)
    {
        // ...
    }
}

// Infrastructure/Repositories/SqlAccountRepository.cs
public class SqlAccountRepository : IAccountRepository  // ✓ Application/Infrastructure اجرا می‌کند
{
    public Account GetById(Guid id) { /* SQL code */ }
    public void Save(Account account) { /* SQL code */ }
}

جهت وابستگی درست است:

1
2
Domain (اینترفیس)    ←    Infrastructure (اجرا)  ✓
(بالا)                      (پایین)

مثال عملی: چرا تفاوت می‌کند

سناریو: تغییر Repository

فرض کنید می‌خواهیم GetById متد جدیدی بگذاریم: GetByIdWithLock.

اگر Repository در Application باشد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Application/Contracts/IAccountRepository.cs (تغییر کردیم!)
public interface IAccountRepository 
{
    Account GetById(Guid id);
    Account GetByIdWithLock(Guid id);  // ✨ متد جدید
    void Save(Account account);
}

// Domain/Services/MoneyTransferService.cs
public class MoneyTransferDomainService 
{
    // ❌ وابسته به Application است!
    // حالا باید دامین را ری‌کمپایل کنیم
    // حتی اگر فقط Application تغییر کرده باشد!
}

نتیجه: دامین (که باید پایدار و تغییرناپذیر باشد) تحت تأثیر قرار گرفت.

اگر Repository در Domain باشد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Domain/Contracts/IAccountRepository.cs (تغییر کردیم!)
public interface IAccountRepository 
{
    Account GetById(Guid id);
    Account GetByIdWithLock(Guid id);  // ✨ متد جدید
    void Save(Account account);
}

// Domain/Services/MoneyTransferService.cs
public class MoneyTransferDomainService 
{
    // ✓ وابسته به Domain خود است
    // اگر Domain تغییر کند، طبیعی است که Domain را ری‌کمپایل کنیم
    // Application نیازی به تغییر ندارد اگر به متد جدید نیاز نداشته باشد
}

// Infrastructure/Repositories/SqlAccountRepository.cs
public class SqlAccountRepository : IAccountRepository  // ✓ فقط اجرا را بروز می‌کنیم
{
    public Account GetById(Guid id) { /* SQL code */ }
    public Account GetByIdWithLock(Guid id) { /* SQL code */ }
    public void Save(Account account) { /* SQL code */ }
}

نتیجه: تغییر محتاط و محدود شد. فقط Infrastructure اجرا را تغییر داد.

اصل معماری: سطح “پایداری”

در معماری Hexagonal یا Clean Architecture، سطح پایدارِ (Stable Layer) دامنه است.

  • Domain باید پایدار باشد: زیرا قوانین تجاری تغییر نمی‌کند (یا بسیار کم).
  • Application نسبتاً پایدار است: اما تغییرات بیشتری نسبت به Domain دارد.
  • Infrastructure ناپایدار است: ممکن است هر روز تغییر کند (SQL → Mongo، FTP → S3).
1
2
3
4
5
پایداری پایین:   Infrastructure   (DB، API‌های خارجی)
                  ↓
                Application      (Use Cases، Service Orchestration)
                  ↓
پایداری بالا:    Domain           (Business Rules)

اگر Interface در Application باشد، دامین وابسته به لایه‌ای می‌شود که ناپایدارتر است. این معکوس اصول معماری خوب است.

بیان ریاضی: Dependency Inversion Principle (DIP)

1
2
3
4
5
6
"اشیاء سطح بالا نباید به اشیاء سطح پایین وابسته باشند.
هر دو باید به اینترفیس‌ها وابسته باشند."

Domain (HIGH LEVEL)
   ↓ (وابسته به interface)
IRepository (interface) ← (پیاده‌سازی) ← Infrastructure (LOW LEVEL)

اگر Interface در Application باشد:

1
2
3
4
5
Domain (HIGH LEVEL)
   ↓ (وابسته به interface)
IRepository (در Application - MIDDLE)
   ↓ (اجرا)
Infrastructure (LOW LEVEL)

اینطور ترتیب سطح‌ها به هم می‌ریزد!

خلاصه

Repository Interface در Domain باید باشد تا:

  1. جهت وابستگی درست: Infrastructure → Domain (نه برعکس)
  2. Domain پایدار بماند: تغییرات Infrastructure و Application Domain را تحت تأثیر نگذارند
  3. صحت معماری: این‌طور است که “معمارِ نرم‌افزار” می‌خواهد
  4. مقیاس‌پذیری: اگر ۱۰ جاهای مختلف از Repository استفاده کنند، تغییرات محتاط و ایمن‌تر خواهند بود

اگر بخواهم کوتاه بگویم: در حالت ایده‌آل، حق با توست! دامین (به خصوص Aggregateها) نباید دیتابیس را صدا بزنند.

اما بیایید ببینیم واقعاً کجاها در لایه دامین به Repository نیاز پیدا می‌کنیم و کجاها نباید استفاده کنیم.

۱. کجا نباید استفاده کنیم؟ (اکثر موارد)

در ۹۰٪ مواقع، Aggregate نباید Repository داشته باشد.

غلط:

1
2
3
4
5
6
7
public class Order {
    public void AddItem(ProductId id, IProductRepository repo) {
        var product = repo.GetById(id); // ❌ وابستگی دامین به ریپازیتوری
        if(product.Stock < 1) throw new Exception();
        ...
    }
}

درست: داده را در لایه Application بخوان و به دامین پاس بده.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Application Layer
public void AddItemUseCase(OrderId orderId, ProductId productId) {
    var order = orderRepo.GetById(orderId);
    var product = productRepo.GetById(productId); // ✅ خواندن در اپلیکیشن
    
    order.AddItem(product); // ✅ پاس دادن داده به دامین
    
    orderRepo.Save(order);
}

// Domain Layer
public class Order {
    public void AddItem(Product product) { // ✅ بدون وابستگی به ریپازیتوری
        if(product.Stock < 1) throw new Exception();
        ...
    }
}

تا اینجا حرفت کاملاً درست است. دامین فقط روی داده‌های ورودی منطق اجرا می‌کند.

۲. پس کجا نیاز داریم؟ (موارد خاص)

تنها جایی که در لایه دامین (معمولاً در Domain Service) به Repository نیاز پیدا می‌کنیم، زمانی است که قانون تجاری به مجموعه‌ای از داده‌ها نیاز دارد که نمی‌توانیم همه را در مموری لود کنیم.

بیایید چند مثال واقعی بزنیم:

مثال الف: چک کردن یکتا بودن (Uniqueness Check)

قانون: “هیچ کاربری نباید ایمیل تکراری داشته باشد.”

  • اگر بخواهیم این را در User Aggregate چک کنیم، باید لیست همه کاربران دنیا را به آن پاس بدهیم! (غیرممکن است).
  • پس نیاز به یک Domain Service داریم که به دیتابیس دسترسی داشته باشد (از طریق اینترفیس).
1
2
3
4
5
6
7
8
9
10
11
12
// Domain Service
public class UserRegistrationService {
    private readonly IUserRepository _userRepo; // ✅ نیاز داریم

    public User Register(string email, ...) {
        // قانون بیزینس: ایمیل نباید تکراری باشد
        if (_userRepo.Exists(email)) 
            throw new DuplicateEmailException();

        return new User(email, ...);
    }
}

مثال ب: انتقال پول (همان مثال قبلی)

قانون: “برای انتقال پول، باید حساب مبدأ و مقصد وجود داشته باشند.”

  • باز هم نمی‌توانیم کل دیتابیس حساب‌ها را در مموری لود کنیم.
  • سرویس دامین (MoneyTransferService) نیاز دارد GetById بزند تا دو Aggregate خاص را پیدا کند و روی آن‌ها عملیات انجام دهد.

مثال ج: قوانین محاسباتی سنگین

قانون: “اگر مجموع خریدهای کاربر در سال گذشته بیشتر از ۱۰ میلیون بود، ۱۰٪ تخفیف بگیرد.”

  • نمی‌توانیم تمام سفارش‌های سال قبل را در متد ApplyDiscount پاس بدهیم (شاید هزاران سفارش باشد).
  • یک متد در ریپازیتوری (GetTotalSales(userId, year)) خیلی بهینه‌تر است.

جمع‌بندی: قانون سرانگشتی

۱. درون Aggregate (Entity/Value Object):

  • هرگز از Repository استفاده نکن.
  • داده‌های لازم را به عنوان پارامتر متد بگیر.

۲. درون Domain Service:

  • فقط زمانی از Repository استفاده کن که منطق بیزینس نیاز به چک کردن وجود چیزی، یکتا بودن، یا محاسبات روی حجم زیاد داده دارد که نمی‌توان آن را در لایه Application انجام داد یا به صرفه نیست.

پس حرفت درست است: هدف ما حذف وابستگی دامین به داده‌های خارجی است، اما گاهی “واقعیت فنی” (حجم داده‌ها) و “نیاز بیزینسی” (یکتایی) ما را مجبور می‌کند یک پل (Interface) بزنیم.


پاسخ کوتاه: می‌شود این کار را کرد! و خیلی‌ها هم همین کار را می‌کنند. این روش را Anemic Domain Model (مدل دامین کم‌خون) یا انتقال لاجیک به لایه Application می‌گویند.

اما بیایید ببینیم اگر این چک‌ها را به لایه Application ببریم، چه چیزی را از دست می‌دهیم (و چه خطراتی دارد).

سناریوی ۱: چک کردن یکتایی ایمیل در لایه Application

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Application Layer (Use Case)
public void RegisterUser(string email) {
    // 1. چک کردن در اپلیکیشن
    if (repo.Exists(email)) 
        throw new Exception("Duplicate!");

    // 2. ساخت یوزر (دامین)
    var user = new User(email); 
    
    repo.Save(user);
}

// Domain Layer
public class User {
    public User(string email) {
        // هیچ چکی اینجا نیست! دامین کور است.
        Email = email;
    }
}

مشکل کجاست؟

۱. نشت دانش بیزینس (Business Logic Leakage): قانون “ایمیل باید یکتا باشد” یک قانون بیزینسی است، نه یک قانون اپلیکیشنی (مثل لاگین کردن یا فرمت جیسون). جای قوانین بیزینس در دامین است. اگر این چک را در اپلیکیشن بگذاری، دامین دیگر “صاحب اختیار” قوانین خودش نیست.

۲. عدم تضمین یکپارچگی (Inconsistency Risk): فرض کن فردا یک راه دیگر برای ثبت نام اضافه شود (مثلاً ایمپورت از اکسل، یا ثبت نام ادمین).

  • اگر برنامه‌نویس جدید یادش برود در متد ImportExcelUseCase هم آن if (repo.Exists) را بنویسد چه؟
  • دامین (new User) چون خودش چک نمی‌کند، یوزر تکراری را با خوشحالی می‌سازد!
  • دیتابیس خراب می‌شود.

اما اگر در Domain Service باشد: هر کسی بخواهد یوزر بسازد مجبور است از سرویس دامین رد شود و قانون همیشه اجرا می‌شود.

سناریوی ۲: تخفیف ۱۰ میلیونی در لایه Application

1
2
3
4
5
6
7
8
9
10
11
12
// Application Layer
public void Checkout(OrderId orderId) {
    var order = repo.Get(orderId);
    
    // لاجیک بیزینس در اپلیکیشن:
    var totalSales = repo.GetTotalSales(order.UserId);
    if (totalSales > 10_000_000) {
        order.ApplyDiscount(10);
    }
    
    repo.Save(order);
}

مشکل کجاست؟ دوباره همان مشکل. اگر فردا یک قابلیت “خرید سریع” (Quick Buy) اضافه کنیم و برنامه‌نویس یادش برود این ۴ خط کد محاسبه تخفیف را کپی-پیست کند، بیزینس ضرر می‌کند (یا مشتری شاکی می‌شود).

اصل “Encapsulation” (کپسوله‌سازی)

هدف DDD این است که دامین محافظِ تمامِ قوانینِ خودش باشد.

  • دامین باید بگوید: “من اجازه نمی‌دهم یوزر تکراری ساخته شود.”
  • دامین باید بگوید: “من اجازه نمی‌دهم سفارشی بدون محاسبه تخفیف نهایی شود.”

اگر این مسئولیت‌ها را به لایه Application بدهیم، دامین تبدیل می‌شود به یک مشت کلاس get/set (کم‌خون) و لاجیک در سرویس‌های اپلیکیشن پخش و پلا می‌شود (که نگهداری‌اش سخت است).

پس چه کنیم؟ راه حل میانه

اگر خیلی حساسی که دامین به ریپازیتوری وصل نشود (که حساسیت خوبی است)، می‌توانی از الگوی Double Dispatch یا Passing Policy استفاده کنی:

لایه Application:

1
2
3
4
5
// داده را آماده می‌کند
bool isEmailUnique = !repo.Exists(email);

// به دامین می‌گوید: "بیا، این وضعیت ایمیل است، خودت تصمیم بگیر"
var user = User.Create(email, isEmailUnique);

لایه Domain:

1
2
3
4
5
6
7
8
public class User {
    public static User Create(string email, bool isUnique) {
        // لاجیک بیزینس سر جای خودش است:
        if (!isUnique) throw new DuplicateEmailException();
        
        return new User(email);
    }
}

مزیت:

  1. دامین به ریپازیتوری وابسته نیست (پاکِ پاک).
  2. لاجیک بیزینس (“اگر یونیک نبود خطا بده”) هنوز در دامین است.

عیب: برنامه‌نویس لایه Application هنوز می‌تواند دروغ بگوید! (می‌تواند true بفرستد در حالی که ایمیل تکراری است). ولی حداقل لاجیک در دامین متمرکز است.

جمع‌بندی

  1. ایده‌آل: لاجیک در دامین، داده‌ها از بیرون پاس داده شوند (مثل راه حل آخر).
  2. عملیاتی (Pragmatic): استفاده از Domain Service که Repository دارد (برای راحتی و تضمین اجرا).
  3. اشتباه (Anti-Pattern): نوشتن if/elseهای بیزینسی در لایه Application و رها کردن دامین.

انتخاب بین ۱ و ۲ سلیقه‌ای و وابسته به پیچیدگی پروژه است، اما گزینه ۳ در DDD توصیه نمی‌شود.


فصل ۶: مقابله با منطق تجاری پیچیده (Tackling Complex Business Logic) - بخش اول

مقدمه

تا اینجای کار با ابزارهای اصلی DDD برای پیاده‌سازی منطق تجاری آشنا شدیم:

  • Value Objects: برای مفاهیم ساده و تغییرناپذیر.
  • Aggregates: برای مدیریت تغییرات داده‌ها و تضمین سازگاری (Consistency).
  • Domain Services: برای منطقی که متعلق به یک موجودیت خاص نیست.

اما گاهی اوقات، منطق تجاری آنقدر پیچیده است که حتی این الگوها هم کافی نیستند. در این فصل با دو الگوی پیشرفته‌تر آشنا می‌شویم که برای Core Subdomainهای بسیار پیچیده طراحی شده‌اند:

  1. Domain Events (رویدادهای دامنه)
  2. Event Sourcing (منبع‌رویداد) - که در بخش‌های بعدی عمیق‌تر می‌شود.

Domain Events (رویدادهای دامنه)

تعریف Domain Event

یک Domain Event پیامی است که توصیف می‌کند “اتفاق مهمی در دامین رخ داده است”. بر خلاف Command (که دستوری برای انجام کار در آینده است: “این کار را بکن”)، Event همیشه به زمان گذشته اشاره دارد (“این کار انجام شد”).

مثال:

  • OrderCreated (سفارش ایجاد شد)
  • PaymentApproved (پرداخت تایید شد)
  • CustomerAddressChanged (آدرس مشتری تغییر کرد)

چرا Domain Event مهم است؟

Domain Eventها ابزار اصلی برای ارتباط بین Aggregateها هستند بدون اینکه آن‌ها را به هم وابسته کنند (Decoupling).

یادت هست قانون “یک تراکنش، یک Aggregate”؟ وقتی در Order تغییری می‌دهیم و می‌خواهیم Inventory هم باخبر شود، نمی‌توانیم مستقیماً متد inventory.Decrease() را صدا بزنیم. به جای آن:

  1. Order رویداد OrderCreated را منتشر می‌کند.
  2. تراکنش Order تمام می‌شود.
  3. یک هندلر (Handler) جداگانه رویداد را می‌گیرد و Inventory را آپدیت می‌کند.

ساختار یک Event

یک Event باید شامل تمام اطلاعات لازم برای توصیف آن رخداد باشد. معمولاً یک Value Object است (تغییرناپذیر).

1
2
3
4
5
6
7
8
9
10
public class OrderCreated : IDomainEvent
{
    public Guid EventId { get; }
    public Guid OrderId { get; }
    public Guid CustomerId { get; }
    public DateTime OccurredOn { get; }
    // شاید اقلام سفارش هم باشند
    
    public OrderCreated(...) { ... }
}

پیاده‌سازی Domain Events در Aggregate

Aggregate مسئول تولید و انتشار رویدادهاست. اما یک نکته مهم وجود دارد: کی رویداد منتشر شود؟

اگر رویداد را قبل از ذخیره شدن تغییرات در دیتابیس منتشر کنیم، و بعد ذخیره‌سازی شکست بخورد چه؟ سیستم‌های دیگر فکر می‌کنند کار انجام شده، در حالی که نشده!

الگوی رایج: جمع‌آوری و انتشار (Collect & Dispatch)

۱. درون Aggregate: رویدادها را در یک لیست موقت ذخیره می‌کنیم. ۲. در Application Service: بعد از اینکه repo.Save() موفق شد، رویدادها را منتشر می‌کنیم.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 1. Aggregate Base Class
public abstract class AggregateRoot
{
    private readonly List<IDomainEvent> _domainEvents = new();
    
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void AddDomainEvent(IDomainEvent eventItem)
    {
        _domainEvents.Add(eventItem);
    }

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

// 2. Concrete Aggregate
public class Order : AggregateRoot
{
    public void Confirm()
    {
        this.Status = OrderStatus.Confirmed;
        // افزودن رویداد به لیست (هنوز منتشر نشده)
        AddDomainEvent(new OrderConfirmed(this.Id));
    }
}

انتشار (Dispatcher) در Application Layer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ConfirmOrderUseCase
{
    public void Execute(Guid orderId)
    {
        var order = repo.GetById(orderId);
        order.Confirm();
        
        repo.Save(order); // تغییرات دیتابیس ذخیره شد
        
        // حالا رویدادها را منتشر می‌کنیم
        foreach(var evt in order.DomainEvents)
        {
            dispatcher.Dispatch(evt);
        }
        order.ClearDomainEvents();
    }
}

مدیریت پیچیدگی (Managing Complexity)

نویسنده در اینجا به کتاب “The Choice” اثر Eliyahu Goldratt ارجاع می‌دهد تا مفهوم پیچیدگی را باز کند.

پیچیدگی چیست؟ پیچیدگی یعنی دشواری در کنترل و پیش‌بینی رفتار سیستم. این دشواری با مفهوم درجات آزادی (Degrees of Freedom) سنجیده می‌شود.

مثال ساده و عالی کتاب:

کلاس A را در نظر بگیرید:

1
2
3
4
5
6
7
public class ClassA {
    public int A { get; set; }
    public int B { get; set; }
    public int C { get; set; }
    public int D { get; set; }
    public int E { get; set; }
}

درجات آزادی: ۵ (چون هر ۵ متغیر می‌توانند هر عددی باشند).

کلاس B را در نظر بگیرید:

1
2
3
4
5
6
7
8
9
public class ClassB {
    public int A { get; set; }
    public int D { get; set; }
    
    // بقیه متغیرها محاسبه می‌شوند (Invariant)
    public int B => A + 10;
    public int C => D * 2;
    public int E => A + D;
}

درجات آزادی: ۲ (فقط A و D).

نتیجه: ClassB با اینکه منطق بیشتری دارد، ساده‌تر است چون رفتارش قابل پیش‌بینی‌تر است. متغیرهایش نمی‌توانند حالت‌های نامعتبر بگیرند.

نقش Invariantها در کاهش پیچیدگی

Aggregateها و Value Objectها دقیقاً همین کار را می‌کنند: کاهش درجات آزادی.

  • یک Email (Value Object) نمی‌تواند @ نداشته باشد. (کاهش آزادی string)
  • یک Order (Aggregate) نمی‌تواند TotalAmount منفی داشته باشد.

با کپسوله‌سازی منطق و جلوگیری از تغییر مستقیم فیلدها (private set)، ما پیچیدگی سیستم را مهار می‌کنیم. Domain Eventها هم کمک می‌کنند تا اثرات جانبی (Side Effects) را مدیریت شده و شفاف کنیم.


فصل ۶: مقابله با منطق تجاری پیچیده - بخش دوم

Value Objects: ابزار کاهش پیچیدگی

اگر Aggregateها برای مدیریت ترتیب تغییرات هستند، Value Objectها برای تعریف مفاهیم دقیق تجاری هستند.

Value Object چیست؟

یک Value Object یک شیء است که:

  • با مقدارش شناخته می‌شود (نه با شناسه ID).
  • تغییرناپذیر است (Immutable).
  • تنها قوانین تجاری خود را کپسوله می‌کند.

مثال: Email

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ رویکرد سنتی (بدون Value Object)
public class Customer
{
    public string Email { get; set; }
    
    public void SetEmail(string email)
    {
        Email = email; // هیچ اعتبارسنجی نیست!
    }
}

// استفاده:
var customer = new Customer();
customer.Email = "invalid-email"; // داخل شده!
customer.Email = "";              // خالی هم می‌تواند باشد!

مشکل: قانون “Email باید فرمت معتبر داشته باشد” آرام و آرام در سرتاسر کد پخش می‌شود.

✅ رویکرد Value Object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// Email Value Object
public class Email : IEquatable<Email>
{
    public string Value { get; }

    // سازنده: تنها راه ایجاد Email
    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentNullException(nameof(value));
        
        if (!value.Contains("@") || !value.Contains("."))
            throw new InvalidEmailException($"'{value}' is not a valid email");

        Value = value;
    }

    // تغییرناپذیری: Email را تغییر نمی‌دهیم، Email جدید می‌سازیم
    public Email ChangeEmail(string newValue)
    {
        return new Email(newValue);
    }

    // برابری: دو Email برابرند اگر مقدارشان برابر باشد
    public bool Equals(Email other)
    {
        return Value == other?.Value;
    }

    public override bool Equals(object obj)
    {
        return Equals(obj as Email);
    }

    public override int GetHashCode()
    {
        return Value.GetHashCode();
    }

    public override string ToString()
    {
        return Value;
    }
}

// استفاده:
public class Customer
{
    public Email Email { get; private set; }

    public Customer(Email email)
    {
        Email = email ?? throw new ArgumentNullException(nameof(email));
    }

    public void ChangeEmail(Email newEmail)
    {
        Email = newEmail; // نیاز نیست اعتبار سنجی کنیم، Email خودش مراقب است
    }
}

// در کد:
var email = new Email("john@example.com"); // ✓ معتبر
var customer = new Customer(email);

var invalidEmail = new Email("invalid"); // ✗ Exception بلافاصله!

مزایا:

  1. قانون یک جای است: اعتبار Email در Email کلاس تعریف شده است، نه ۵ جای مختلف.
  2. ایمن: نمی‌تواند Email نامعتبر ایجاد شود.
  3. معنادار: ChangeEmail(Email) خیلی بهتر از SetEmail(string) است.

Invariants در Value Objects

یک Value Object نمی‌تواند به حالت نامعتبر برسد.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Value Object: Money
public class Money : IEquatable<Money>
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative");
        
        if (string.IsNullOrEmpty(currency))
            throw new ArgumentNullException(nameof(currency));

        Amount = amount;
        Currency = currency;
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies");
        
        return new Money(Amount + other.Amount, Currency);
    }

    // ...
}

// استفاده:
var price = new Money(100, "USD");
var negative = new Money(-50, "USD"); // ✗ Exception

var price2 = new Money(200, "EUR");
var sum = price.Add(price2); // ✗ Exception: currencies مختلف

Aggregates و Value Objects: همکاری

Aggregateها معمولاً از Value Objectها استفاده می‌کنند تا پیچیدگی را کاهش دهند.

مثال: Order Aggregate با Money و Email

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
public class Order : AggregateRoot
{
    public OrderId Id { get; private set; }
    public Email CustomerEmail { get; private set; }
    public List<OrderLineItem> LineItems { get; private set; }
    public Money Total { get; private set; }
    public OrderStatus Status { get; private set; }

    public Order(OrderId id, Email customerEmail)
    {
        Id = id;
        CustomerEmail = customerEmail;
        LineItems = new List<OrderLineItem>();
        Total = new Money(0, "USD");
        Status = OrderStatus.Pending;
    }

    public void AddLineItem(ProductId productId, Quantity quantity, Money unitPrice)
    {
        // مراقبت: هیچ لاجیک اضافی نیست!
        // Email خودش معتبر است.
        // Money نمی‌تواند منفی باشد.
        // Quantity خودش اعتبار دارد.
        
        var lineItem = new OrderLineItem(productId, quantity, unitPrice);
        LineItems.Add(lineItem);
        
        // Total خودکار بروز می‌شود
        RecalculateTotal();
    }

    private void RecalculateTotal()
    {
        var newTotal = new Money(0, "USD");
        foreach (var item in LineItems)
        {
            newTotal = newTotal.Add(item.GetAmount());
        }
        Total = newTotal;
    }
}

public class OrderLineItem // یک Value Object (یا Entity ساده)
{
    public ProductId ProductId { get; }
    public Quantity Quantity { get; }
    public Money UnitPrice { get; }

    public OrderLineItem(ProductId productId, Quantity quantity, Money unitPrice)
    {
        ProductId = productId;
        Quantity = quantity;
        UnitPrice = unitPrice;
    }

    public Money GetAmount() => UnitPrice.Multiply(Quantity.Value);
}

public class Quantity : IEquatable<Quantity>
{
    public int Value { get; }

    public Quantity(int value)
    {
        if (value <= 0)
            throw new ArgumentException("Quantity must be positive");
        Value = value;
    }

    public Equals(Quantity other) => Value == other?.Value;
    // ...
}

نتیجه:

  • Order دقیقاً نمی‌تواند به حالت نامعتبر برود.
  • AddLineItem فقط یک خط است چون تمام اعتبارات توسط Value Objects انجام می‌شود.

Domain Services مجدد نگاهی

حالا می‌فهمیم Domain Services کجا واقعاً مفید هستند:

زمانی که یک قانون بین چند Aggregate است

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// قانون: نمی‌توانیم سفارشی ایجاد کنیم اگر کاربر suspend شده باشد.
public class OrderCreationService
{
    private readonly ICustomerRepository _customerRepo;
    private readonly IOrderRepository _orderRepo;

    public Order CreateOrder(CustomerId customerId, List<OrderLineItem> items)
    {
        var customer = _customerRepo.GetById(customerId);
        
        if (customer.IsSuspended)
            throw new SuspendedCustomerException();

        var order = new Order(OrderId.NewId(), customer.Email);
        foreach(var item in items)
            order.AddLineItem(item.ProductId, item.Quantity, item.Price);

        _orderRepo.Save(order);
        return order;
    }
}

اینجا Domain Service ضروری است چون قانون “بین” Customer و Order است:

  • نه فقط Order را مربوط می‌کند.
  • نه فقط Customer را مربوط می‌کند.
  • هر دو را در نظر می‌گیرد.

زمانی که یک محاسبه از چند منبع اطلاعات نیاز دارد

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class ShippingCalculationService
{
    private readonly IShippingPolicyRepository _policyRepo;
    private readonly IWarehouseRepository _warehouseRepo;

    public Money CalculateShippingCost(
        Order order, 
        Address shippingAddress)
    {
        // قانون پیچیده: هزینه ارسال بستگی دارد به:
        // 1. وزن سفارش
        // 2. مسافت از انبار تا آدرس
        // 3. سیاست کمپانی (الگو)
        // 4. موجودی انبار (برای تعیین منطقه)

        var policy = _policyRepo.GetDefaultPolicy();
        var nearestWarehouse = _warehouseRepo.FindNearest(shippingAddress);
        
        var distance = CalculateDistance(nearestWarehouse.Location, shippingAddress);
        var weight = order.CalculateTotalWeight();
        
        return policy.CalculateCost(weight, distance);
    }
}

اینجا هم Domain Service ضروری است چون:

  • نمی‌توانیم این منطق را درون Order بگذاریم (Order درباره shipping نمی‌داند).
  • نمی‌توانیم این منطق را درون Warehouse بگذاریم (Warehouse درباره Order نمی‌داند).
  • محاسبه نیاز به اطلاعات از چند منبع دارد.

نکات مهم درباره Value Objects

۱. تغییرناپذیری (Immutability)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ غلط: Value Object تغییرپذیر
public class Email
{
    public string Value { get; set; } // set!
}

// ✓ درست: Value Object تغییرناپذیر
public class Email
{
    public string Value { get; } // فقط get
    
    public Email ChangeEmail(string newValue) 
    {
        return new Email(newValue);
    }
}

چرا؟ اگر Email تغییرپذیر باشد، تمام جاهایی که Email را نگه‌داشته‌اند تحت تأثیر قرار می‌گیرند:

1
2
3
4
var email1 = new Email("john@example.com");
var email2 = email1;
email2.Value = "jane@example.com";
// حالا email1 هم تغییر کرده! (اگر mutable باشد)

۲. برابری (Equality) بر اساس مقدار

1
2
3
4
var email1 = new Email("john@example.com");
var email2 = new Email("john@example.com");

email1 == email2; // باید true باشد!

برای این کار باید Equals و GetHashCode را صحیح پیاده‌سازی کنیم.

۳. بدون هویت (No Identity)

1
2
3
4
5
6
7
8
9
// دو Money برابرند اگر مقدار و ارزشان برابر باشد
var money1 = new Money(100, "USD");
var money2 = new Money(100, "USD");
money1 == money2; // true

// اما دو سفارش نه! حتی اگر داده‌هایشان یکسان باشد
var order1 = new Order(OrderId.NewId(), ...);
var order2 = new Order(OrderId.NewId(), ...);
order1 == order2; // false (چون آیدی‌های مختلفی دارند)

خلاصه بخش دوم

الگوهدفمثال
Value Objectتعریف مفهوم دقیق و ایمنEmail, Money, Quantity
Aggregateمدیریت تغییرات و سازگاریOrder, Customer
Domain Serviceمنطقی که بین Aggregateها استOrderCreationService, ShippingCalculationService

نقش ابزارها در کاهش پیچیدگی:

  • Value Objects: درجات آزادی را کم می‌کنند (نمی‌توانند حالت نامعتبر داشته باشند).
  • Aggregates: تضمین می‌کنند قوانین سازگاری همیشه رعایت شود.
  • Domain Services: منطق پیچیده را متمرکز و مدیریت‌شده می‌کنند.

فصل ۶: مقابله با منطق تجاری پیچیده - بخش سوم

Event Sourcing: منبع‌رویداد

تا اینجا درباره Domain Events صحبت کردیم (رویدادهایی که Aggregate منتشر می‌کند). حالا یک گام پیش‌تر می‌رویم: Event Sourcing - الگویی که رویدادها را به عنوان منبع اصلی حقیقت (Source of Truth) استفاده می‌کند.

مشکل: مدل State-Based سنتی

در روش سنتی، ما فقط “وضعیت فعلی” را ذخیره می‌کنیم:

1
2
3
4
5
-- جدول Leads (مثال از کتاب)
| id  | first_name | last_name | status    | created_on | updated_on |
| --- | ---------- | --------- | --------- | ---------- | ---------- |
| 1   | Sean       | Callahan  | CONVERTED | 2019-01-31 | 2019-01-31 |
| 2   | Sarah      | Estrada   | CLOSED    | 2019-03-29 | 2019-03-29 |

مسائل:

  1. تاریخچه از بین می‌رود: نمی‌دانیم Sean چه زمانی CONVERTED شد؟ چند بار تماس گرفته شد؟
  2. تحلیل محدود: نمی‌توانیم بگوییم “چند روز طول کشید تا یک lead تبدیل شود؟”
  3. عدم قابل پیگیری: اگر اشتباه رخ دهد، نمی‌توانیم ببینیم دقیقاً چه اتفاق افتاد.

راه‌حل: Event Sourcing

به جای ذخیره‌سازی “وضعیت فعلی”، ما تمام رویدادات را ذخیره می‌کنیم:

1
2
3
4
5
6
7
8
9
10
-- جدول Events (Event Store)
| event_id | lead_id | event_type            | data                             | timestamp           |
| -------- | ------- | --------------------- | -------------------------------- | ------------------- |
| 0        | 1       | LeadInitialized       | {name: "Sean Callahan", ...}     | 2019-01-31 10:02:40 |
| 1        | 1       | Contacted             | {}                               | 2019-01-31 12:32:08 |
| 2        | 1       | FollowupSet           | {followup_on: "2019-05-27"}      | 2019-01-31 12:32:08 |
| 3        | 1       | ContactDetailsChanged | {first_name: "Sean", ...}        | 2019-05-20 12:32:08 |
| 4        | 1       | Contacted             | {}                               | 2019-05-27 12:02:12 |
| 5        | 1       | OrderSubmitted        | {payment_deadline: "2019-05-30"} | 2019-05-27 12:02:12 |
| 6        | 1       | PaymentConfirmed      | {status: "CONVERTED"}            | 2019-05-27 12:38:44 |

مزایا:

  1. ✓ تاریخچه کامل: می‌دانیم دقیقاً چه اتفاق‌های افتاد.
  2. ✓ تحلیل عمیق: می‌توانیم ببینیم Sean از ساعت ۱۰ صبح تا ۱۲:۳۸ بعدازظهر (۲۸ روز!) طول کشید.
  3. ✓ Audit Log: تمام تغییرات ثبت شده است.

Event Sourcing چگونه کار می‌کند؟

۱. بارگذاری (Rehydrating) Aggregate

وقتی می‌خواهیم Lead را بارگذاری کنیم:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class TicketAPI
{
    private readonly IEventStore eventStore;

    public void RequestEscalation(TicketId id, EscalationReason reason)
    {
        // ۱. بارگذاری تمام رویدادات
        var events = eventStore.FetchAll(id);
        
        // ۲. بازسازی Aggregate (Rehydration)
        var ticket = new Ticket(events);
        
        // ۳. اجرای دستور
        ticket.Execute(new RequestEscalation(reason));
        
        // ۴. ذخیره‌سازی رویدادهای جدید
        eventStore.Append(id, ticket.DomainEvents, expectedVersion);
    }
}

public class Ticket : AggregateRoot
{
    private TicketState _state;
    
    // سازنده: از رویدادات بازسازی می‌کند
    public Ticket(IEnumerable<DomainEvent> history)
    {
        _state = new TicketState();
        
        foreach (var evt in history)
        {
            ApplyEvent(evt);
        }
    }
    
    private void ApplyEvent(DomainEvent evt)
    {
        // رویداد را به حالت اعمال می‌کنیم
        ((dynamic)_state).Apply((dynamic)evt);
    }
    
    public void Execute(RequestEscalation cmd)
    {
        if (_state.IsEscalated)
            throw new InvalidOperationException("Already escalated");
        
        // رویداد نیا را ایجاد می‌کنیم
        var escalatedEvent = new TicketEscalated(this.Id, cmd.Reason);
        ApplyEvent(escalatedEvent);
        AddDomainEvent(escalatedEvent);
    }
}

// State Projector: رویدادها را به حالت تبدیل می‌کند
public class TicketState
{
    public TicketId Id { get; private set; }
    public int Version { get; private set; }
    public bool IsEscalated { get; private set; }
    public Priority Priority { get; private set; }
    
    public void Apply(TicketInitialized evt)
    {
        Id = evt.Id;
        Version = 0;
        IsEscalated = false;
        Priority = evt.Priority;
    }
    
    public void Apply(TicketEscalated evt)
    {
        IsEscalated = true;
        Version++;
    }
}

۲. Event Store

Event Store یک دیتابیس Append-Only است (فقط می‌توانیم داده اضافه کنیم، نه تغییر یا حذف):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public interface IEventStore
{
    IEnumerable<DomainEvent> FetchAll(Guid aggregateId);
    void Append(Guid aggregateId, IEnumerable<DomainEvent> events, int expectedVersion);
}

public class EventStore : IEventStore
{
    private readonly Dictionary<Guid, List<DomainEvent>> _store = new();

    public IEnumerable<DomainEvent> FetchAll(Guid aggregateId)
    {
        if (_store.TryGetValue(aggregateId, out var events))
            return events;
        return Enumerable.Empty<DomainEvent>();
    }

    public void Append(Guid aggregateId, IEnumerable<DomainEvent> events, int expectedVersion)
    {
        if (!_store.ContainsKey(aggregateId))
            _store[aggregateId] = new List<DomainEvent>();

        var currentEvents = _store[aggregateId];
        
        // بررسی Optimistic Concurrency
        if (currentEvents.Count != expectedVersion)
            throw new ConcurrencyException("Events have been added by another process");

        currentEvents.AddRange(events);
    }
}

مزایا و معایب Event Sourcing

مزایا

۱. سفر زمانی (Time Travel)

1
2
3
4
// ما می‌توانیم Lead را در هر نقطه‌ی زمانی بازسازی کنیم
var allEvents = eventStore.FetchAll(leadId);
var eventsUntilDay5 = allEvents.Where(e => e.Timestamp <= day5);
var leadStateOnDay5 = new Lead(eventsUntilDay5);

این خیلی مفید است برای:

  • Debugging: اگر بگ پیدا شود، می‌تواند Aggregate را به حالت زمان بگ بازگردانید.
  • تحلیل: کسب‌وکار می‌تواند ببیند فروش در هر مرحله چگونه پیش رفت.

۲. Audit Log خودکار

تمام رویدادات ثبت شده است، بنابراین Audit Log خودکار است:

1
2
3
4
5
۱۰:۰۲ - Lead created
۱۲:۳۲ - Agent called
۱۲:۳۲ - Follow-up scheduled
...
۱۲:۳۸ - Payment confirmed

۳. تحلیل عمیق (Deep Insights)

1
2
3
4
5
6
7
8
// چند بار یک lead تماس گرفته شد؟
var contactEvents = events.OfType<Contacted>().Count();

// چقدر طول کشید تا CONVERTED شود؟
var duration = convertedEvent.Timestamp - initializedEvent.Timestamp;

// چه نسبتی از leads تبدیل می‌شود؟
var conversionRate = convertedCount / (double)initializedCount;

۴. Projections (نمایش‌های متعدد)

می‌توانیم از یک event stream چند نمایش مختلف بسازیم:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Projection ۱: برای جستجو
public class LeadSearchProjection
{
    public long LeadId { get; set; }
    public HashSet<string> Names { get; set; }
    public HashSet<string> PhoneNumbers { get; set; }
    
    public void ApplyLeadInitialized(LeadInitialized evt)
    {
        LeadId = evt.LeadId;
        Names.Add(evt.FirstName);
        PhoneNumbers.Add(evt.PhoneNumber);
    }
    
    public void ApplyContactDetailsChanged(ContactDetailsChanged evt)
    {
        Names.Add(evt.FirstName);
        PhoneNumbers.Add(evt.PhoneNumber);
    }
}

// Projection ۲: برای تحلیل
public class LeadAnalyticsProjection
{
    public long LeadId { get; set; }
    public int Followups { get; set; }
    public LeadStatus Status { get; set; }
    
    public void ApplyFollowupSet(FollowupSet evt)
    {
        Followups++;
    }
    
    public void ApplyPaymentConfirmed(PaymentConfirmed evt)
    {
        Status = LeadStatus.CONVERTED;
    }
}

// همان رویدادات → نمایش‌های مختلف
var searchModel = LeadSearchProjection.From(events);
var analyticsModel = LeadAnalyticsProjection.From(events);

معایب

۱. منحنی یادگیری تند (Steep Learning Curve)

Event Sourcing به طریقه فکر کردن متفاوتی نیاز دارد. درک رویدادها و Rehydration برای برنامه‌نویسانی که با State-Based مدل کار کرده‌اند سخت است.

۲. تکامل مدل پیچیده

اگر Event schema تغییر کند چه؟

1
2
3
4
5
6
7
8
9
10
11
12
مثال: ابتدا Event PaymentConfirmed این ساختار را داشت:
{
    "leadId": 12,
    "status": "CONVERTED"
}

بعداً می‌خواهیم amount هم اضافه کنیم:
{
    "leadId": 12,
    "status": "CONVERTED",
    "amount": 5000
}

رویدادهای قدیمی چطور مدیریت شوند؟ این نیاز به Event Migration دارد که پیچیده است. (کتابی کامل با نام “Versioning in an Event Sourced System” درباره این مسئله نوشته شده است!)

۳. پیچیدگی معماری

Event Sourcing نیاز دارد:

  • Event Store (دیتابیس خاص)
  • Projection Handlers (برای ساختن مدل‌های مختلف)
  • Snapshots (برای بهینه‌سازی Rehydration)
  • Event Migration Tools

این سؤ (Accidental Complexity) اضافی است اگر مدل ساده باشد.

۴. عدم تطابق (Impedance Mismatch)

اگر Query ساده برای یک نمایش شامل join پیچیده است:

1
2
3
4
5
6
7
8
-- State-Based مدل (ساده):
SELECT name, email, total_spent FROM customers WHERE city = 'Tehran'

-- Event-Sourced (پیچیده):
-- ابتدا تمام events را بخواند
-- بعد تمام customers را Rehydrate کند
-- بعد فیلتر کند
-- (بسیار ناکارآمد!)

برای این مشکل، Projections را باید دقیق‌تر طراحی کرد.

Snapshots: بهینه‌سازی

اگر یک Aggregate ۱۰,۰۰۰ رویداد داشته باشد، بارگذاری همه‌ی آن‌ها کند است!

Snapshot یعنی: هر ۱۰۰۰ رویداد، وضعیت فعلی را ذخیره می‌کنیم.

1
2
3
4
5
6
7
8
// بدون Snapshot: ۱۰,۰۰۰ رویداد را بخواند و Apply کند
var events = eventStore.FetchAll(leadId); // ۱۰,۰۰۰ event
var lead = new Lead(events); // ۱۰,۰۰۰ بار Apply

// با Snapshot: تنها ۱۰۰ رویداد اخیر را بخواند
var snapshot = snapshotStore.FetchLatest(leadId); // وضعیت در رویداد ۹۹۰۰
var recentEvents = eventStore.FetchAfter(leadId, 9900); // تنها ۱۰۰ event
var lead = new Lead(snapshot, recentEvents); // ۱۰۰ بار Apply

خلاصه Event Sourcing

جنبهState-BasedEvent-Sourced
منبع حقیقتجدول (current state)Event Log
تاریخچهنیستکامل
سفر زمانی❌ غیرممکن✅ ممکن
Projectionsیک جدولچندین
کمپلکسیتیپایینبالا
Performanceبهتر (معمولاً)بدتر (بدون Snapshot)

این یکی از چالش‌برانگیزترین بخش‌های Event Sourcing است. چون رویدادها تغییرناپذیر (Immutable) هستند و متعلق به گذشته‌اند، نمی‌توانیم آن‌ها را مثل رکوردهای دیتابیس “آپدیت” کنیم. اگر تغییری در ساختار (Schema) رویداد لازم باشد، باید با استراتژی‌های خاصی آن را مدیریت کنیم.

در اینجا ۴ استراتژی اصلی برای مدیریت تغییر Schema در Event Sourcing (که به آن Event Versioning می‌گویند) را توضیح می‌دهم:

۱. استراتژی نگاشت ضعیف (Weak Schema / Lax Mapping)

ساده‌ترین روش این است که سریالایزر (مثلاً JSON Serializer) را طوری تنظیم کنیم که نسبت به فیلدهای جدید یا حذف شده “سخت‌گیر” نباشد.

سناریو: فیلد جدید IpAddress به رویداد UserLoggedIn اضافه شده است.

  • رویدادهای قدیمی: این فیلد را ندارند.
  • رویدادهای جدید: این فیلد را دارند.

راه حل: در کلاس رویداد، فیلد را Nullable تعریف می‌کنیم.

1
2
3
4
5
6
7
8
public class UserLoggedIn 
{
    public Guid UserId { get; set; }
    public DateTime Timestamp { get; set; }
    
    // فیلد جدید (اختیاری)
    public string? IpAddress { get; set; } 
}
  • وقتی رویداد قدیمی لود می‌شود، IpAddress نال است (که منطقی است، چون آن زمان ثبت نشده بود).
  • وقتی رویداد جدید لود می‌شود، IpAddress پر است.

مزایا: ساده، بدون نیاز به مایگریشن. معایب: فقط برای تغییرات ساده (افزودن فیلد غیرضروری) کار می‌کند. برای تغییر نام یا تغییر نوع داده کارساز نیست.

۲. استراتژی Upcasting (ارتقاء در لحظه)

این روش قدرتمندترین و رایج‌ترین روش است. ما رویدادهای قدیمی را در دیتابیس دست نمی‌زنیم، بلکه هنگام لود شدن (در حافظه)، آن‌ها را به نسخه جدید تبدیل می‌کنیم.

سناریو: نام فیلد Address به ShippingAddress تغییر کرده است.

راه حل: یک کلاس Upcaster می‌نویسیم که JSON قدیمی را می‌گیرد و به JSON جدید تبدیل می‌کند.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class AddressChangedUpcaster : IUpcaster 
{
    public JObject Upcast(JObject oldEvent)
    {
        // اگر رویداد نسخه ۱ است
        if (oldEvent["Version"].Value<int>() == 1) 
        {
            // تغییر نام فیلد
            oldEvent["ShippingAddress"] = oldEvent["Address"];
            oldEvent.Remove("Address");
            
            // ارتقاء نسخه
            oldEvent["Version"] = 2;
        }
        
        return oldEvent;
    }
}

مزایا:

  • دیتابیس دست‌نخورده و تمیز می‌ماند (تاریخچه واقعی).
  • کد دامین همیشه با آخرین نسخه رویداد کار می‌کند (تمیز).
  • تمام منطق تغییرات در کلاس‌های Upcaster کپسوله می‌شود.

معایب: کمی سربار پردازشی هنگام لود کردن دارد (چون باید تبدیل انجام شود).

۳. استراتژی چند نسخه همزمان (Multiple Versions)

در این روش، ما هر دو کلاس EventV1 و EventV2 را در کد نگه می‌داریم و Aggregate ما بلد است با هر دو کار کند.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Order : AggregateRoot 
{
    // هندلر برای نسخه ۱
    public void Apply(OrderCreatedV1 evt) 
    {
        this.Id = evt.Id;
        this.CustomerName = evt.Name; // در نسخه ۱ نام یک تکه بود
    }

    // هندلر برای نسخه ۲
    public void Apply(OrderCreatedV2 evt) 
    {
        this.Id = evt.Id;
        this.CustomerName = $"{evt.FirstName} {evt.LastName}"; // در نسخه ۲ جدا شد
    }
}

مزایا: صریح و واضح است. معایب: کد Aggregate کثیف می‌شود (پر از متدهای قدیمی). بعد از ۱۰ نسخه تغییر، کلاس غیرقابل نگهداری می‌شود. توصیه نمی‌شود.

۴. کپی و تبدیل (Copy & Transform / In-Place Migration)

این روش شبیه Migration در دیتابیس‌های معمولی است. یک اسکریپت می‌نویسیم که تمام رویدادهای قدیمی را می‌خواند، تغییر می‌دهد و در یک Event Store جدید (یا همان قبلی) ذخیره می‌کند.

سناریو: تغییرات آنقدر بنیادی است که Upcasting خیلی کند می‌شود یا اصلاً ممکن نیست.

راه حل:

  1. استریم رویدادها را متوقف کنید (Downtime).
  2. اسکریپت مایگریشن را اجرا کنید (همه رویدادهای نوع X را بخوان، تغییر بده، بازنویسی کن).
  3. سیستم را با کد جدید بالا بیاورید.

مزایا: پرفورمنس بالا (چون تبدیل در زمان اجرا نداریم). معایب:

  • بسیار خطرناک: چون “تاریخچه” را دستکاری می‌کنید (اصول Event Sourcing را نقض می‌کند).
  • اگر اشتباه کنید، داده‌های اصلی از دست می‌روند (حتماً بک‌آپ نیاز دارد).
  • نیاز به Downtime دارد.

خلاصه: کدام را انتخاب کنیم؟

  1. برای تغییرات ساده (افزودن فیلد): از روش ۱ (Weak Schema) استفاده کنید.
  2. برای تغییرات ساختاری (تغییر نام، تغییر نوع): از روش ۲ (Upcasting) استفاده کنید. این استاندارد طلایی است.
  3. هرگز از روش ۳ (چند نسخه در Aggregate) استفاده نکنید مگر برای مدت خیلی کوتاه.
  4. روش ۴ (بازنویسی دیتابیس) آخرین راه چاره است.

اسنپ‌شات (Snapshot) یک مکانیزم بهینه‌سازی (Optimization) حیاتی برای سیستم‌های Event Sourced است که تعداد رویدادهایشان زیاد می‌شود.

بیایید دقیق ببینیم چرا، چه زمانی و چگونه باید اسنپ‌شات بگیریم.

۱. مشکل: Rehydration کند

فرض کنید یک Aggregate به نام BankAccount داریم که ۱۰ سال عمر دارد و ۱۰۰,۰۰۰ تراکنش (Event) روی آن انجام شده است.

هر بار که می‌خواهیم موجودی را چک کنیم یا پولی برداشت کنیم:

  1. باید ۱۰۰,۰۰۰ رویداد را از دیتابیس بخوانیم. (زمان‌بر I/O)
  2. باید یک شیء new BankAccount() بسازیم.
  3. باید ۱۰۰,۰۰۰ بار متد Apply() را اجرا کنیم تا موجودی از صفر به عدد فعلی برسد. (زمان‌بر CPU)

این فرآیند ممکن است چند ثانیه طول بکشد که برای یک سیستم بانکی فاجعه است.

۲. راه حل: Snapshot

اسنپ‌شات یعنی “ذخیره کردن وضعیت محاسبه‌شده‌ی Aggregate در یک لحظه خاص”.

به جای اینکه از رویداد شماره ۱ شروع کنیم، از آخرین اسنپ‌شات (مثلاً رویداد شماره ۹۹,۰۰۰) شروع می‌کنیم و فقط ۱۰۰۰ رویداد بعدی را اعمال می‌کنیم.

۳. استراتژی پیاده‌سازی

الف) ساختار Snapshot

اسنپ‌شات دقیقاً شبیه همان State داخلی Aggregate است که سریالایز شده.

1
2
3
4
5
6
public class BankAccountSnapshot 
{
    public Guid Id { get; set; }
    public decimal Balance { get; set; } // وضعیت محاسبه شده
    public int LastEventVersion { get; set; } // تا این نسخه اعمال شده
}

ب) فرآیند گرفتن اسنپ‌شات (کی بگیریم؟)

معمولاً به دو روش انجام می‌شود:

  1. همزمان (Synchronous) - در هنگام ذخیره: هر بار که Aggregate ذخیره می‌شود، چک می‌کنیم “آیا تعداد رویدادها از آخرین اسنپ‌شات مثلاً ۱۰۰ تا بیشتر شده؟”. اگر بله، اسنپ‌شات جدید می‌گیریم و ذخیره می‌کنیم.
    • عیب: کمی زمان ذخیره کردن را کند می‌کند.
  2. غیرهمزمان (Asynchronous) - توسط Worker: (روش پیشنهادی) یک Process جداگانه داریم که رویدادها را گوش می‌دهد. هر وقت دید رویداد شماره ۱۰۰، ۲۰۰، ۳۰۰ و… برای یک Aggregate آمد، یک اسنپ‌شات از آن می‌سازد و در دیتابیسِ اسنپ‌شات ذخیره می‌کند.
    • مزیت: هیچ سرباری روی عملیات اصلی کاربر (Command) نمی‌اندازد.

ج) فرآیند بارگذاری (چطور استفاده کنیم؟)

منطق Repository تغییر می‌کند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class EventSourcedRepository<T> 
{
    public T GetById(Guid id) 
    {
        // ۱. تلاش برای یافتن آخرین اسنپ‌شات
        var snapshot = _snapshotStore.GetLatest(id);
        
        T aggregate;
        long version = 0;

        if (snapshot != null) 
        {
            // ۲. اگر بود، Aggregate را از روی آن می‌سازیم
            aggregate = RestoreFromSnapshot(snapshot);
            version = snapshot.LastEventVersion;
        }
        else 
        {
            // اگر نبود، Aggregate خالی می‌سازیم
            aggregate = new T();
        }

        // ۳. فقط رویدادهای "بعد از" آن نسخه را می‌خوانیم
        var newEvents = _eventStore.GetEvents(id, fromVersion: version + 1);

        // ۴. رویدادهای جدید را اعمال می‌کنیم
        foreach (var e in newEvents) 
        {
            aggregate.Apply(e);
        }

        return aggregate;
    }
}

۴. چالش مهم: تغییر Schema در Snapshot

دقت کنید! اسنپ‌شات هم مثل رویدادها ممکن است دچار تغییر ساختار شود (مثلاً فیلدی به Aggregate اضافه شود).

اما برخلاف رویدادها، اسنپ‌شات‌ها ارزش تاریخی ندارند. آن‌ها فقط برای سرعت هستند. پس اگر ساختار Aggregate عوض شد، ساده‌ترین استراتژی این است که اسنپ‌شات‌های قدیمی را نامعتبر (Invalidate) کنیم.

  • اگر اسنپ‌شات با کد جدید همخوانی نداشت، آن را دور می‌ریزیم.
  • سیستم مجبور می‌شود یک بار از اول تمام رویدادها را بخواند (کند می‌شود).
  • بلافاصله یک اسنپ‌شات جدید با ساختار جدید می‌گیرد.
  • دفعات بعدی دوباره سریع می‌شود.

۵. قانون سرانگشتی (Rule of Thumb)

  • کی استفاده کنیم؟ فقط زمانی که تعداد رویدادها زیاد است (مثلاً بالای ۱۰۰ یا ۵۰۰). برای اکثر Aggregateها (مثل سفارش خرید که شاید کلاً ۱۰ تا رویداد داشته باشد) نیازی به اسنپ‌شات نیست و فقط پیچیدگی اضافه می‌کند.

فصل ۶: مقابله با منطق تجاری پیچیده - بخش چهارم (پایانی)

انتخاب الگوی صحیح برای پیاده‌سازی منطق تجاری

در این فصل، سه الگوی اصلی برای پیاده‌سازی Core Subdomainهای پیچیده را بررسی کردیم. اما سوال اصلی این است: کدام را انتخاب کنیم؟

هیچ “بهترین” الگویی وجود ندارد. هر کدام مزایا و معایب خود را دارند. انتخاب اشتباه می‌تواند منجر به پیچیدگی تصادفی (Accidental Complexity) شود - یعنی کد را سخت کرده‌ایم بدون اینکه سودی ببریم.

بیایید آن‌ها را در یک چارچوب تصمیم‌گیری مقایسه کنیم.

۱. Domain Model (مدل دامنه سنتی)

این همان استفاده از Aggregateها و Value Objectهاست که وضعیت فعلی (State) را ذخیره می‌کنند.

  • کاربرد: اکثر پروژه‌های DDD (حدود ۸۰٪ موارد).
  • پیچیدگی: متوسط.
  • مزایا:
    • درک آسان برای تیم.
    • ابزارها و فریم‌ورک‌های زیاد (ORMها مثل Entity Framework).
    • کوئری گرفتن آسان (SQL ساده).
  • معایب:
    • تاریخچه دقیق تغییرات را از دست می‌دهید.
    • برای فرآیندهای بسیار پیچیده ممکن است Aggregateها خیلی بزرگ شوند.

کی استفاده کنیم؟

  • وقتی منطق تجاری پیچیده است اما تاریخچه تغییرات اهمیت حیاتی ندارد.
  • وقتی تیم تجربه کافی با Event Sourcing ندارد.

۲. Domain Events (فقط برای ارتباط)

استفاده از Aggregateهای سنتی که Domain Event منتشر می‌کنند (اما Event Sourcing نیستند).

  • کاربرد: وقتی نیاز به هماهنگی بین Aggregateها یا سیستم‌های دیگر دارید.
  • مزایا:
    • کاهش وابستگی (Decoupling).
    • بهبود پرفورمنس با انجام کارهای جانبی در پس‌زمینه (Async).
  • معایب: کمی پیچیدگی اضافه می‌کند (نیاز به Message Bus یا Dispatcher).

کی استفاده کنیم؟

  • تقریباً همیشه در کنار الگوی ۱ توصیه می‌شود. (Aggregate + Domain Events).

۳. Event-Sourced Domain Model (مدل دامنه مبتنی بر رویداد)

استفاده از Event Sourcing کامل (ذخیره رویدادها به جای وضعیت).

  • کاربرد: موارد خاص و بسیار پیچیده (حدود ۱۰-۲۰٪ موارد).
  • پیچیدگی: بسیار بالا (نیاز به Event Store، Snapshot، Versioning).
  • مزایا:
    • تاریخچه کامل و غیرقابل تغییر (Audit Log).
    • امکان تحلیل‌های زمانی (Time Travel).
    • طراحی طبیعی برای سیستم‌های “سری زمانی” (مثل حسابداری).
  • معایب:
    • منحنی یادگیری سخت.
    • چالش‌های تغییر Schema.
    • نیاز به CQRS برای کوئری گرفتن (چون کوئری روی Event Store سخت است).

کی استفاده کنیم؟

  • سیستم‌های مالی و حسابداری (Ledger).
  • سیستم‌های قانونی که نیاز به Audit Log دقیق دارند.
  • جایی که تحلیل رفتار کاربر و فرآیند بیزینس ارزش تجاری بالایی دارد.
  • جایی که “وضعیت فعلی” کمتر از “چگونگی رسیدن به وضعیت” اهمیت دارد.

نمودار تصمیم‌گیری (Decision Tree)

وقتی با یک Subdomain جدید روبرو می‌شوید، از این منطق استفاده کنید:

  1. آیا این Subdomain “قلب” سیستم (Core) است؟
    • خیر (Generic/Supporting): از الگوهای ساده مثل Transaction Script یا Active Record استفاده کنید. (اصلاً نیازی به DDD سنگین نیست).
    • بله (Core): ادامه بدهید.
  2. آیا نیاز به تحلیل تاریخچه تغییرات یا Audit Log دقیق دارید؟
    • بله: Event Sourcing را بررسی کنید.
    • خیر: ادامه بدهید.
  3. آیا منطق تجاری پیچیده است و قوانین زیادی دارد؟
    • بله: از Domain Model (Aggregate + Value Object) استفاده کنید.
    • خیر: شاید CRUD ساده کافی باشد.

جمع‌بندی نهایی فصل ۶

ما در این فصل یاد گرفتیم که چگونه با غولِ “پیچیدگی” مبارزه کنیم:

  1. Value Objects: با محدود کردن مقادیر ممکن و کپسوله کردن قوانین ریز، پایه‌های محکمی می‌سازند.
  2. Aggregates: با مدیریت تراکنش‌ها و Invariantها، سازگاری داده‌ها را تضمین می‌کنند.
  3. Domain Events: با جدا کردن Aggregateها از هم، سیستم را منعطف و مقیاس‌پذیر می‌کنند.
  4. Event Sourcing: با ضبط زمان، بُعد چهارم را به مدل ما اضافه می‌کند و قدرتمندترین (و پیچیده‌ترین) ابزار ماست.

نکته پایانی: به عنوان یک معمار یا توسعه‌دهنده ارشد، هنر شما استفاده از پیچیده‌ترین ابزار نیست؛ بلکه انتخاب ساده‌ترین ابزاری است که کار را به درستی انجام می‌دهد. اگر با یک جدول ساده کار راه می‌افتد، Event Sourcing پیاده نکنید!


مشکل: کوئری روی Event Store

سناریو: جستجو کردن

فرض کنید می‌خواهیم تمام Lead‌های شهر تهران را که در ۳۰ روز آخیر CONVERTED شده‌اند پیدا کنیم.

روش سنتی (State-Based):

1
2
3
4
SELECT * FROM leads 
WHERE city = 'Tehran' 
AND status = 'CONVERTED'
AND updated_on >= DATE_SUB(NOW(), INTERVAL 30 DAY)

این خیلی سریع است.

روش Event Sourcing (بدون CQRS):

دیتابیس ما شبیه این است:

1
2
3
4
5
6
7
8
9
-- Event Store
| event_id | lead_id | event_type       | data                   | timestamp           |
| -------- | ------- | ---------------- | ---------------------- | ------------------- |
| 0        | 1       | LeadInitialized  | {city: "Tehran", ...}  | 2024-11-01 10:02:40 |
| 1        | 1       | Contacted        | {}                     | 2024-11-10 12:32:08 |
| 2        | 1       | PaymentConfirmed | {status: "CONVERTED"}  | 2024-12-10 12:38:44 |
| 3        | 2       | LeadInitialized  | {city: "Isfahan", ...} | 2024-11-15 10:02:40 |
| 4        | 2       | PaymentConfirmed | {status: "CONVERTED"}  | 2024-12-05 12:38:44 |
| ...      | ...     | ...              | ...                    | ...                 |

مشکل:

1
2
3
4
5
-- این کوئری غیرممکن است!
SELECT DISTINCT lead_id FROM events 
WHERE data->>'city' = 'Tehran' 
AND event_type = 'PaymentConfirmed'
AND timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)

چرا؟

  1. JSON Parsing: فیلد data JSON است. بسیاری دیتابیس‌ها JSON Query کردن را سخت پیاده‌سازی می‌کنند یا اصلاً پشتیبانی نمی‌کنند.

  2. موقعیت‌یابی شهر: اطلاعات شهر در رویداد LeadInitialized است. ما باید تمام رویدادات را خوانده، هر Lead را بازسازی کنیم، ببینیم شهر چه بود، و بعد فیلتر کنیم.

  3. Joins پیچیده: فرض کنید می‌خواهیم “شهر” و “تاریخ تحویل” را ببینیم:
    • شهر در LeadInitialized است.
    • تاریخ تحویل در OrderShipped است.
    • ما باید هر Lead را از ابتدا تا انتها بخوانیم تا هر دو اطلاعات را داشته باشیم.
  4. کارایی: اگر ۱۰ میلیون Lead باشد، ما مجبور خواهیم شد:
    • ۱۰۰ میلیون رویداد را بخوانیم (اگر متوسط ۱۰ رویداد per Lead).
    • هر کدام را Parse کنیم.
    • هر کدام را Reconstruct کنیم.
    • این می‌تواند ۵-۱۰ دقیقه طول بکشد!

راه‌حل: CQRS (Command Query Responsibility Segregation)

ایده اساسی:

نوشتن (Write) و خواندن (Read) را جدا کنیم.

  • Write Model: Event Store (برای ثبت رویدادها).
  • Read Model: یک جدول آپتیمایز‌شده برای جستجو (مثل جدول LeadsSearchIndex).

معماری:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
┌─────────────────┐
│   Command       │
│ (Create Lead)   │
└────────┬────────┘
         │
         ▼
    ┌────────────────┐
    │  Event Store   │ (Source of Truth)
    │                │
    │ LeadCreated    │
    │ Contacted      │
    │ PaymentConfirmed
    └────────┬───────┘
             │
             │ Events
             │
             ▼
    ┌──────────────────────┐
    │  Projection Handler  │
    │  (Event Listener)    │
    └────────┬─────────────┘
             │
             │ Updates
             │
             ▼
┌─────────────────────────────────┐
│   Read Model (جدول جستجو)       │
│  ┌──────────────────────────┐   │
│  │ LeadsSearchIndex         │   │
│  ├──────────────────────────┤   │
│  │ lead_id                  │   │
│  │ name                     │   │
│  │ city                     │   │
│  │ status                   │   │
│  │ converted_on             │   │
│  │ created_on               │   │
│  └──────────────────────────┘   │
└─────────────────────────────────┘
         │
         │ Query
         │
    ┌────▼──────────┐
    │  Query Result │
    └───────────────┘

پیاده‌سازی:

۱. Event Store (نوشتن)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Command Handler
public class CreateLeadCommandHandler
{
    private readonly IEventStore _eventStore;

    public void Handle(CreateLeadCommand cmd)
    {
        var lead = new Lead(cmd.Name, cmd.City);
        lead.Execute(cmd);
        
        // رویدادها در Event Store ذخیره می‌شود
        _eventStore.Append(lead.Id, lead.DomainEvents);
    }
}

۲. Projection Handler (تبدیل رویدادها به Read Model)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class LeadSearchIndexProjectionHandler
{
    private readonly ILeadSearchRepository _readRepository;

    // این Handler گوش‌به‌زنگ رویدادهای Event Store است
    public void Handle(LeadCreated evt)
    {
        var searchModel = new LeadSearchModel 
        {
            LeadId = evt.LeadId,
            Name = evt.Name,
            City = evt.City,
            Status = "NEW_LEAD",
            CreatedOn = evt.Timestamp
        };
        
        // در Read Model (جدول آپتیمایز‌شده) ذخیره می‌کنیم
        _readRepository.Add(searchModel);
    }

    public void Handle(PaymentConfirmed evt)
    {
        var existing = _readRepository.GetById(evt.LeadId);
        existing.Status = "CONVERTED";
        existing.ConvertedOn = evt.Timestamp;
        
        _readRepository.Update(existing);
    }
}

۳. Read Model (خواندن)

1
2
3
4
5
6
-- حالا جدول ساده و بهینه داریم
| lead_id | name          | city    | status    | converted_on | created_on |
| ------- | ------------- | ------- | --------- | ------------ | ---------- |
| 1       | Sean Callahan | Tehran  | CONVERTED | 2024-12-10   | 2024-11-01 |
| 2       | Sarah Estrada | Isfahan | CONVERTED | 2024-12-05   | 2024-11-15 |
| ...     | ...           | ...     | ...       | ...          | ...        |

کوئری اکنون بسیار سریع است:

1
2
3
4
5
6
SELECT * FROM LeadsSearchIndex 
WHERE city = 'Tehran' 
AND status = 'CONVERTED'
AND converted_on >= DATE_SUB(NOW(), INTERVAL 30 DAY)

-- این کوئری در چند میلی‌ثانیه اجرا می‌شود!

مثال واقعی: چند Projection

همان Event Stream می‌تواند چندین Read Model مختلف سازد:

Projection ۱: جستجو (SearchIndex)

1
2
3
4
5
6
LeadsSearchIndex
├── lead_id
├── name
├── city
├── status
└── converted_on

Projection ۲: تحلیل (AnalyticsModel)

1
2
3
4
5
LeadsAnalytics
├── lead_id
├── followups_count
├── days_to_conversion
└── conversion_value

Projection ۳: کمپیین (CampaignMetrics)

1
2
3
4
5
CampaignMetrics
├── campaign_id
├── leads_count
├── conversion_rate
└── revenue

همه از یک Event Stream:

1
2
3
4
5
6
7
8
9
10
11
12
13
var events = eventStore.GetAllEvents();

// Projection 1
var searchProjection = new SearchIndexProjection();
foreach(var evt in events) searchProjection.Handle(evt);

// Projection 2
var analyticsProjection = new AnalyticsProjection();
foreach(var evt in events) analyticsProjection.Handle(evt);

// Projection 3
var campaignProjection = new CampaignMetricsProjection();
foreach(var evt in events) campaignProjection.Handle(evt);

خلاصه: چرا CQRS با Event Sourcing مهم است؟

جنبهEvent Store (Write)Read Model (Read)
Optimized برایثبت رویداداتجستجو و کوئری
ساختارAppend-only logDenormalized جدول
کوئریغیرممکنبسیار سریع
تاریخچهکاملنه (فقط وضعیت فعلی)
پرفورمنسنوشتن سریعخواندن سریع

نکته مهم: Eventual Consistency

یک نکته حیاتی: Read Model همیشه کمی عقب افتاده است.

وقتی Lead ایجاد می‌شود:

  1. رویداد در Event Store ذخیره می‌شود. (فوری)
  2. Projection Handler رویداد را می‌گیرد و Read Model را آپدیت می‌کند. (تاخیر: ۱۰۰ میلی‌ثانیه)

پس اگر بلافاصله بعد از ایجاد، جستجو کنی، شاید Lead هنوز در جستجو دیده نشود!

این مسئله Eventual Consistency نام دارد. معمولاً قابل تحمل است (چند صددمین ثانیه تاخیر)، اما برای برخی موارد حساس نیاز به Compensating Logic دارد.


۱. Transaction Script Pattern

تعریف

Transaction Script یعنی هر درخواست کاربر (User Action) را با یک تابع (Script) ساده پردازش می‌کنیم که تمام منطق مورد نیاز را در یک جا انجام می‌دهد.

مثال: ایجاد سفارش (بدون DDD)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class OrderService
{
    public void CreateOrder(CreateOrderRequest request)
    {
        // 1. اعتبارسنجی ورودی
        if (request.CustomerId <= 0)
            throw new ArgumentException("Invalid customer");

        // 2. خواندن داده‌ها
        var customer = _customerRepository.GetById(request.CustomerId);
        if (customer == null)
            throw new Exception("Customer not found");

        var products = _productRepository.GetByIds(request.ProductIds);

        // 3. محاسبات و منطق تجاری
        decimal totalPrice = 0;
        foreach (var item in request.Items)
        {
            var product = products.FirstOrDefault(p => p.Id == item.ProductId);
            if (product == null)
                throw new Exception("Product not found");

            if (product.Stock < item.Quantity)
                throw new Exception("Not enough stock");

            totalPrice += product.Price * item.Quantity;
        }

        // اگر مشتری VIP است، ۱۰٪ تخفیف
        if (customer.IsVip)
            totalPrice = totalPrice * 0.9m;

        // 4. تغییرات دیتابیس
        var orderId = Guid.NewGuid();
        _database.ExecuteNonQuery(
            @"INSERT INTO Orders (Id, CustomerId, TotalPrice, CreatedOn) 
              VALUES (@id, @customerId, @total, @now)",
            new SqlParameter("@id", orderId),
            new SqlParameter("@customerId", request.CustomerId),
            new SqlParameter("@total", totalPrice),
            new SqlParameter("@now", DateTime.UtcNow)
        );

        foreach (var item in request.Items)
        {
            _database.ExecuteNonQuery(
                @"INSERT INTO OrderLineItems (OrderId, ProductId, Quantity) 
                  VALUES (@orderId, @productId, @qty)",
                new SqlParameter("@orderId", orderId),
                new SqlParameter("@productId", item.ProductId),
                new SqlParameter("@qty", item.Quantity)
            );

            // کاهش موجودی
            _database.ExecuteNonQuery(
                @"UPDATE Products SET Stock = Stock - @qty WHERE Id = @id",
                new SqlParameter("@qty", item.Quantity),
                new SqlParameter("@id", item.ProductId)
            );
        }

        // 5. پاسخ
        return orderId;
    }
}

خصوصیات Transaction Script

  • یک متد = یک درخواست: هر transaction script یک درخواست کاربر را از A تا Z پردازش می‌کند.

  • منطق و داده‌ها مخلوط: SQL، محاسبات، تصمیم‌گیری‌ها همه در یک جا هستند.

  • بدون سازوکار: نه Aggregate، نه Value Object، نه Domain Service.

  • Direct SQL: معمولاً SQL مستقیم یا ORM ساده.

مزایا

بسیار ساده: درک کردن سریع، اجرا سریع. ✓ کم Overhead: هیچ سختاری معماری نیست. ✓ برای منطق ساده ایده‌آل: وقتی قوانین کمی هستند.

معایب

تست کردن سخت: منطق و Database مخلوط هستند، mock کردن دشوار است. ✗ تکرار: همان منطق در چندین جا تکرار می‌شود. ✗ نگهداری سخت: با رشد منطق، اسکریپت‌ها خیلی بزرگ می‌شوند. ✗ عدم قابلِ استفاده مجدد: منطق را نمی‌توانید در جای دیگری استفاده کنید.

کی استفاده کنیم؟

  • Generic Subdomains (احراز هویت، لاگین).
  • Supporting Subdomains ساده (عملیات ادمین، گزارش‌دهی).
  • Prototype یا MVP.
  • منطق بسیار ساده که احتمال تغییر کم است.

۲. Active Record Pattern

تعریف

Active Record یعنی هر اشیاء (Entity) نه تنها داده را نگه‌می‌دارد، بلکه نحوه دسترسی و تغییر خود را هم مدیریت می‌کند.

مثال: فریم‌ورک Rails یا .NET

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class Customer : ActiveRecord
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public decimal Balance { get; set; }

    // متدهای Save/Delete/Find در شیء خود
    public void Save()
    {
        var sql = "UPDATE Customers SET Name = @name, Email = @email WHERE Id = @id";
        Database.Execute(sql, new { Name, Email, Id });
    }

    public static Customer FindById(int id)
    {
        var row = Database.ExecuteQuery(
            "SELECT * FROM Customers WHERE Id = @id", 
            new { id });
        return MapToCustomer(row);
    }

    public void Delete()
    {
        Database.Execute("DELETE FROM Customers WHERE Id = @id", new { Id });
    }

    // منطق تجاری
    public void ApplyDiscount(decimal percent)
    {
        Balance = Balance * (1 - percent / 100);
        Save(); // بلافاصله ذخیره می‌کند
    }
}

// استفاده
var customer = Customer.FindById(1);
customer.Name = "John";
customer.Save();

customer.ApplyDiscount(10); // خود ذخیره می‌کند

خصوصیات Active Record

  • شیء = Data + Behavior + Persistence: شیء نه تنها داده دارد، بلکه می‌تواند خود را Save/Delete کند.

  • Finder Methods: متدهای static مثل FindById، FindAll روی خود شیء.

  • Implicity: تغییرات معمولاً بلافاصله ذخیره می‌شود (مثل ApplyDiscount).

مزایا

بسیار سریع برای CRUD:

1
2
3
var product = Product.FindById(5);
product.Price = 100;
product.Save(); // انجام شد!

کوچک و دقیق: برای عملیات ساده خیلی کم کد لازم است.

شهودی: برای مبتدیان آسان است.

معایب

Tight Coupling: شیء Domain با Database مختلط است.

تست کردن سخت: نمی‌توانید شیء را بدون Database تست کنید.

توانایی محدود: وقتی منطق پیچیده می‌شود، نمی‌توانید بسهولت تغییر دهید.

1
2
3
4
5
6
7
8
9
// مثال مشکل: انتقال پول
var fromAccount = Account.FindById(1);
var toAccount = Account.FindById(2);

fromAccount.Balance -= 1000;
fromAccount.Save(); // تا اینجا خوب

toAccount.Balance += 1000;
toAccount.Save(); // اگر این ناکام شود؟ (Atomicity شکست می‌خورد!)

N+1 Query Problem:

1
2
3
4
var customers = Customer.FindAll(); // 1 query
foreach (var c in customers) {
    var orders = c.GetOrders(); // N query (برای هر customer)
}

کی استفاده کنیم؟

  • CRUD ساده (کاربران، تنظیمات).
  • Prototype یا MVP سریع.
  • فریم‌ورک‌های مدرن (Rails، Laravel) که خوب طراحی‌شده‌اند.

مقایسه: Transaction Script vs Active Record

جنبهTransaction ScriptActive RecordDomain Model (DDD)
کجا منطق است؟Service ClassEntityAggregate + Service
وابستگی DBدر Serviceدر Entityدر Repository Interface
قابلِ تستمتوسطضعیفخوب
برای منطق سادهخیلی خوبخیلی خوبToo Much
برای منطق پیچیدهضعیفضعیفخیلی خوب
Reusabilityنهنهبله
Performanceبالابالامتوسط (معمولاً)

نمودار: انتخاب الگو

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
شروع: کدام Subdomain است؟

├─ CORE Subdomain (منطق پیچیده، تفاضلی)
│  └─ نیاز به Audit/History؟
│     ├─ YES → Event Sourcing + CQRS
│     └─ NO → Domain Model (Aggregate + Value Object)
│
├─ GENERIC Subdomain (منطق معروف و استاندارد)
│  └─ سادگی مهم است؟
│     ├─ YES → Active Record یا Framework ORM
│     └─ NO → Domain Model (توصیه نمی‌شود، ORM موجود استفاده کنید)
│
└─ SUPPORTING Subdomain (کمکی)
   └─ منطق خیلی ساده؟
      ├─ YES → Transaction Script
      └─ NO → Active Record یا Domain Model (به صورت خفیف)

مثال واقعی: سیستم فروش

Core Subdomain: Order Management

✓ استفاده کنید: Domain Model (یا Event Sourcing اگر Audit لازم باشد) چرا؟ منطق پیچیده (تخفیف، اعتبار، موجودی).

Generic Subdomain: Payment Processing

✓ استفاده کنید: Active Record یا ORM Framework چرا؟ منطق استاندارد (فقط تغییر state از PENDING به CONFIRMED).

Supporting Subdomain: Reporting

✓ استفاده کنید: Transaction Script چرا؟ فقط خواندن داده‌ها (SELECT) و ساخت گزارش.


اگر تراکنش دیتابیس (ذخیره سفارش) موفق شود اما انتشار رویداد (ارسال ایمیل یا آپدیت موجودی) شکست بخورد، سیستم دچار عدم سازگاری (Inconsistency) می‌شود.

برای حل این مشکل، دو الگوی اصلی وجود دارد که تضمین می‌کنند رویدادها حتماً پردازش شوند.

۱. الگوی Outbox Pattern (الگوی صندوق خروجی) - راه‌حل طلایی

این الگو تضمین می‌کند که “ذخیره Aggregate” و “ذخیره رویداد” به صورت اتمیک (در یک تراکنش) انجام شوند.

چطور کار می‌کند؟

۱. جدول Outbox: در دیتابیس اصلی (جایی که جدول Orders هست)، یک جدول دیگر به نام OutboxEvents می‌سازیم.

۲. تراکنش واحد: وقتی می‌خواهیم سفارش را ذخیره کنیم، رویداد OrderCreated را هم در همان تراکنش SQL در جدول OutboxEvents درج می‌کنیم.

1
2
3
4
5
6
7
8
9
10
BEGIN TRANSACTION;

-- ۱. ذخیره سفارش
INSERT INTO Orders (Id, Total) VALUES (1, 1000);

-- ۲. ذخیره رویداد (به جای ارسال مستقیم)
INSERT INTO OutboxEvents (Id, EventType, Payload, Processed) 
VALUES (NewId(), 'OrderCreated', '{...}', 0);

COMMIT; -- اگر اینجا شکست بخورد، هیچ‌کدام ذخیره نمی‌شوند (امن است)

۳. Relay Process (فرستنده): یک سرویس جداگانه (Worker) داریم که مدام جدول OutboxEvents را چک می‌کند:

  • رکوردهایی که Processed = 0 هستند را می‌خواند.
  • آن‌ها را به Message Bus (مثل RabbitMQ یا Kafka) می‌فرستد.
  • اگر ارسال موفق بود، Processed = 1 می‌کند (یا رکورد را حذف می‌کند).

مزایا:

  • تضمین ۱۰۰٪: چون رویداد بخشی از تراکنش دیتابیس است، محال است سفارش ثبت شود ولی رویدادش ثبت نشود.
  • ترتیب (Ordering): رویدادها دقیقاً به همان ترتیبی که رخ داده‌اند در Outbox ذخیره می‌شوند.

۲. الگوی Change Data Capture (CDC) - راه‌حل زیرساختی

این روش شبیه Outbox است اما به جای جدول اضافی، از Transaction Log خود دیتابیس استفاده می‌کند.

چطور کار می‌کند؟

۱. برنامه فقط جدول Orders را آپدیت می‌کند (اصلاً کاری به رویداد ندارد). ۲. ابزاری مثل Debezium به لاگ‌های دیتابیس (مثلاً binlog در MySQL یا WAL در PostgreSQL) وصل می‌شود. ۳. هر تغییری که در جدول Orders می‌بیند را تبدیل به یک رویداد می‌کند و به Kafka می‌فرستد.

مزایا:

  • بدون کدنویسی: برنامه‌نویس نگران انتشار رویداد نیست.
  • پرفورمنس بالا: هیچ سرباری روی تراکنش اصلی ندارد.

معایب:

  • پیچیدگی زیرساخت: نیاز به تنظیم ابزارهای پیچیده دارد.
  • Events are coupled to DB schema: رویداد دقیقاً شکل جدول است. اگر می‌خواهید رویداد معنای بیزینسی خاصی داشته باشد (که با جدول فرق دارد)، این روش مناسب نیست.

مدیریت خطا در Consumer (گیرنده)

حالا که مطمئن شدیم رویداد ارسال شده، اگر گیرنده (مثلاً سرویس ایمیل) هنگام پردازش خطا بدهد چه؟

۱. Retry (تلاش مجدد): گیرنده باید مکانیزم Retry داشته باشد. اگر ایمیل فیل شد، ۵ ثانیه بعد دوباره تلاش کند.

۲. Idempotency (تکرارپذیری امن): چون ممکن است به خاطر Retry، یک پیام دو بار پردازش شود، گیرنده باید Idempotent باشد.

  • “اگر پیام OrderCreated با ID 123 را قبلاً پردازش کرده‌ام، بار دوم نادیده‌اش بگیر.”

۳. Dead Letter Queue (DLQ): اگر بعد از ۱۰ بار تلاش هنوز خطا می‌دهد (مثلاً باگ در کد است)، پیام را به یک صف مرده (DLQ) منتقل کن تا مهندسان بعداً بررسی کنند و فرآیند اصلی مسدود نشود.

خلاصه: چطور مطمئن باشیم؟

برای انتشار (Publishing):

  • از Outbox Pattern استفاده کنید. (توصیه اکید برای سیستم‌های حیاتی).
  • این تضمین می‌کند که “اگر دیتابیس آپدیت شد، رویداد هم حتماً وجود دارد”.

برای پردازش (Consuming):

  • از Retry + Idempotency استفاده کنید.
  • این تضمین می‌کند که “رویداد بالاخره پردازش می‌شود، حتی اگر سرویس موقتاً قطع باشد”.

فصل ۷: CQRS (Command Query Responsibility Segregation) - بخش اول

مقدمه: تفکیک خواندن و نوشتن

تا اینجا یاد گرفتیم که:

  • Domain Model برای نوشتن (Command) و اعمال قوانین تجاری مناسب است.
  • Event Sourcing برای تاریخچه کامل و تحلیل بسیار کارآمد است.

اما همان‌طور که در قسمت قبل دیدیم، کوئری گرفتن از Event Store سخت است. Event Store برای “نوشتن” آپتیمایز شده، نه “خواندن”.

CQRS این مشکل را حل می‌کند با جدا کردن کامل مدل‌های نوشتن و خواندن.

اصول CQRS

تعریف ساده

CQRS = دو مدل مختلف:

  • Write Model (Command Side): برای ثبت و تغییر داده‌ها (Event Store).
  • Read Model (Query Side): برای خواندن داده‌ها (جداول آپتیمایز‌شده).

تصویر مفهومی

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
┌────────────────────────────────────────────────────────────┐
│                          User Interface                     │
└────────────────────────────────────────────────────────────┘
              │                              │
              │ Command                      │ Query
              │ (نوشتن)                      │ (خواندن)
              ▼                              ▼

┌─────────────────────────────┐  ┌──────────────────────────┐
│   WRITE MODEL               │  │   READ MODEL             │
│  (Optimized for Writing)    │  │  (Optimized for Reading) │
│                             │  │                          │
│  ┌─────────────────────┐    │  │  ┌────────────────────┐  │
│  │  Aggregate/Domain   │    │  │  │  Denormalized      │  │
│  │  Model              │    │  │  │  View/ReadModel    │  │
│  │                     │    │  │  │                    │  │
│  │ - Order             │    │  │  │ - OrderSummary     │  │
│  │ - OrderLine         │    │  │  │ - OrderStats       │  │
│  └─────────────────────┘    │  │  └────────────────────┘  │
│           │                 │  │           ▲               │
│           │ Persist         │  │           │ Read          │
│           ▼                 │  │           │               │
│  ┌─────────────────────┐    │  │  ┌────────────────────┐  │
│  │  Event Store        │    │  │  │  Read Database     │  │
│  │  (Source of Truth)  │    │  │  │  (Cache/Replica)   │  │
│  └─────────────────────┘    │  │  └────────────────────┘  │
│           │                 │  │                          │
└─────────────┼────────────────┘  └──────────────────────────┘
              │
              │ Events
              │ (Projection Handlers)
              │
              ▼ (آپدیت Read Model)
        ┌──────────────┐
        │ Read Model   │
        │  Updated     │
        └──────────────┘

CQRS بدون Event Sourcing

یک نکته مهم: CQRS نیازی به Event Sourcing ندارد. می‌تواند با Database سنتی هم استفاده شود!

مثال: CQRS با Database سنتی

۱. Write Model (دیتابیس اصلی)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- Normalized Tables
CREATE TABLE Orders (
    Id GUID,
    CustomerId GUID,
    Total DECIMAL,
    Status VARCHAR(50),
    CreatedOn DATETIME
);

CREATE TABLE OrderLines (
    Id GUID,
    OrderId GUID,
    ProductId GUID,
    Quantity INT,
    Price DECIMAL
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CreateOrderCommandHandler
{
    public void Handle(CreateOrderCommand cmd)
    {
        var order = new Order(cmd.CustomerId);
        
        foreach (var item in cmd.Items)
        {
            order.AddLineItem(item.ProductId, item.Quantity, item.Price);
        }
        
        _orderRepository.Save(order); // ذخیره در دیتابیس سنتی
    }
}

۲. Read Model (دیتابیس مختلف یا جدول دیگری در همان دیتابیس)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- Denormalized Table (برای جستجو سریع)
CREATE TABLE OrderSearchIndex (
    Id GUID,
    OrderId GUID,
    CustomerName VARCHAR(100),
    CustomerCity VARCHAR(50),
    TotalPrice DECIMAL,
    LineCount INT,
    Status VARCHAR(50),
    CreatedOn DATETIME,
    LastModifiedOn DATETIME,
    INDEX idx_customer_city (CustomerCity),
    INDEX idx_status (Status)
);

۳. Synchronizer (درهم‌پیچنده)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class OrderSearchIndexSynchronizer
{
    // وقتی Order تغییر کند، Read Model را هم آپدیت می‌کند
    public void SynchronizeOrder(Order order)
    {
        var searchModel = new OrderSearchIndex
        {
            OrderId = order.Id,
            CustomerName = order.Customer.Name,
            CustomerCity = order.Customer.City,
            TotalPrice = order.CalculateTotal(),
            LineCount = order.LineItems.Count,
            Status = order.Status,
            CreatedOn = order.CreatedOn,
            LastModifiedOn = DateTime.UtcNow
        };
        
        _searchIndexRepository.AddOrUpdate(searchModel);
    }
}

۴. Query Handler

1
2
3
4
5
6
7
8
public class FindOrdersByCustomerCityQueryHandler
{
    public List<OrderSearchIndexReadModel> Handle(FindOrdersByCustomerCityQuery query)
    {
        // مستقیماً از Read Model می‌خوانیم (بسیار سریع!)
        return _searchIndexRepository.FindByCity(query.City);
    }
}

اینجا CQRS است بدون Event Sourcing!

CQRS + Event Sourcing (سادگی بالا)

حالا اگر Event Sourcing هم داشته باشیم:

1
2
3
4
5
Commands → Domain Model → Events → Event Store
                                       │
                                       │ Projection Handlers
                                       ▼
                                   Read Models

فرق:

جنبهCQRS بدون ESCQRS + ES
Write ModelAggregate (State-based)Aggregate (Event-based)
PersistenceDatabase سنتیEvent Store
Synchronizationمستقیم یا QueueEvents + Projections
پیچیدگیمتوسطبالا
Tazیخچهنهبله

مثال عملی: سیستم فروش

Scenario: سفارش، جستجو و تحلیل

۱. Write Model (دریافت سفارش)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Order : AggregateRoot
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public List<OrderLineItem> LineItems { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTime CreatedOn { get; private set; }

    public void CreateOrder(Guid customerId, List<OrderLineItem> items)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
        LineItems = items;
        Status = OrderStatus.Pending;
        CreatedOn = DateTime.UtcNow;
        
        // رویداد منتشر می‌کنیم
        AddDomainEvent(new OrderCreated(Id, CustomerId, items.Count));
    }

    public void Confirm()
    {
        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmed(Id));
    }
}

۲. Command Handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CreateOrderCommandHandler
{
    private readonly IOrderRepository _repository;
    private readonly IEventDispatcher _dispatcher;

    public void Handle(CreateOrderCommand cmd)
    {
        var order = new Order();
        order.CreateOrder(cmd.CustomerId, cmd.Items);
        
        // ذخیره در Event Store
        _repository.Save(order);
        
        // منتشر کردن رویدادها
        foreach (var evt in order.DomainEvents)
        {
            _dispatcher.Dispatch(evt);
        }
    }
}

۳. Query Models (چندین نمایش!)

Read Model ۱: جستجو سریع

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class OrderSearchReadModel
{
    public Guid OrderId { get; set; }
    public string CustomerName { get; set; }
    public string Status { get; set; }
    public DateTime CreatedOn { get; set; }
}

public class OrderSearchQueryHandler
{
    public List<OrderSearchReadModel> Handle(SearchOrdersQuery query)
    {
        return _searchRepository.FindByStatus(query.Status);
    }
}

Read Model ۲: تحلیل فروش

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class OrderAnalyticsReadModel
{
    public int TotalOrders { get; set; }
    public decimal TotalRevenue { get; set; }
    public int AverageOrderValue { get; set; }
    public Dictionary<string, int> OrdersByStatus { get; set; }
}

public class AnalyticsQueryHandler
{
    public OrderAnalyticsReadModel Handle(GetAnalyticsQuery query)
    {
        return _analyticsRepository.GetSummary();
    }
}

Read Model ۳: داشبورد مشتری

1
2
3
4
5
6
7
public class CustomerOrdersReadModel
{
    public Guid CustomerId { get; set; }
    public List<CustomerOrderSummary> Orders { get; set; }
    public int TotalOrderCount { get; set; }
    public decimal LifetimeValue { get; set; }
}

۴. Projection Handlers (آپدیت Read Models)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class OrderSearchProjectionHandler
{
    private readonly ISearchRepository _repo;

    public void Handle(OrderCreated evt)
    {
        var model = new OrderSearchReadModel
        {
            OrderId = evt.OrderId,
            CustomerName = GetCustomerName(evt.CustomerId),
            Status = "Pending",
            CreatedOn = evt.Timestamp
        };
        _repo.Add(model);
    }

    public void Handle(OrderConfirmed evt)
    {
        var model = _repo.GetById(evt.OrderId);
        model.Status = "Confirmed";
        _repo.Update(model);
    }
}

public class AnalyticsProjectionHandler
{
    private readonly IAnalyticsRepository _repo;

    public void Handle(OrderCreated evt)
    {
        var analytics = _repo.GetCurrent();
        analytics.TotalOrders++;
        _repo.Update(analytics);
    }

    public void Handle(OrderConfirmed evt)
    {
        // محاسبه درآمد
        var order = _eventStore.GetAggregate(evt.OrderId);
        var analytics = _repo.GetCurrent();
        analytics.TotalRevenue += order.CalculateTotal();
        _repo.Update(analytics);
    }
}

مزایا CQRS

۱. پرفورمنس

  • نوشتن: بهینه برای Aggregate (normalize شده).
  • خواندن: بهینه برای Query (denormalize شده).

۲. مقیاس‌پذیری

1
2
3
4
5
6
7
8
Read Model
├─ می‌تواند در Database مختلف باشد
├─ می‌تواند cache شود (Redis)
└─ می‌تواند در چندین سرور تکرار شود

Write Model
├─ معمولاً یک سرور
└─ برای Consistency دقیق تر

۳. تحلیل عمیق

می‌توانیم بدون هیچ تأثیری بر سیستم اصلی، Read Model جدید اضافه کنیم:

1
2
3
4
5
6
7
8
9
// Read Model جدید: فروش بر اساس منطقه جغرافیایی
public class SalesByRegionReadModel { }

// Projection جدید
public class SalesByRegionProjectionHandler { }

// اینجا کل Event Stream را دوباره می‌پردازیم
var projectionEngine = new ProjectionEngine();
projectionEngine.RebuildProjection<SalesByRegionReadModel>();

معایب CQRS

۱. Eventual Consistency

Read Model همیشه کمی عقب است:

1
2
3
4
5
6
Event Generated: 12:00:00
Event in Event Store: 12:00:00.001
Read Model Updated: 12:00:00.100 (۱۰۰ میلی‌ثانیه تاخیر)

اگر کاربر بلافاصله بعد از کراتِ سفارش جستجو کند، 
ممکن است سفارش را ندید!

۲. پیچیدگی اضافی

  • Synchronization Logic.
  • Handling Stale Reads.
  • Debugging سخت‌تر.

۳. Dual Write Problem

اگر سیستم رویداد‌محور نباشد و ما دستی دو جای مختلف را آپدیت کنیم:

1
2
3
// ❌ غلط
_writeDatabase.SaveOrder(order);
_readDatabase.SaveOrderSearch(orderSearch); // اگر این ناکام شود؟

قانون سرانگشتی

کی از CQRS استفاده کنیم؟

  • ✓ وقتی Read و Write patterns خیلی متفاوت است.
  • ✓ وقتی پرفورمنس برای خواندن حیاتی است (مثل جستجو).
  • ✓ وقتی چندین نمایش از داده‌ها لازم است (Dashboard, Report, API).
  • ✓ وقتی با Event Sourcing کار می‌کنید.

کی استفاده نکنیم:

  • ✗ وقتی سیستم ساده است (یک جدول، کم Query).
  • ✗ وقتی تیم کم و آموزش‌دیده نیست.
  • ✗ وقتی Eventual Consistency قابل تحمل نیست (سیستم بانکی شدید).

فصل ۷: CQRS - بخش دوم

Projection Engines: موتور تحول رویدادها به Read Model

تا اینجا دیدیم که Read Models نیاز دارند از رویدادها آپدیت شوند. اما این چگونه عملاً انجام می‌شود؟ یک “Projection Engine” است که این کار را مدیریت می‌کند.

۱. ساختار Projection

یک Projection یک تابع ساده است که رویدادی را می‌گیرد و Read Model را آپدیت می‌کند:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Projection: Function that transforms events to read models
public interface IProjection
{
    void Handle<T>(T @event) where T : IDomainEvent;
}

// مثال: Projection برای جستجو
public class OrderSearchProjection : IProjection
{
    private readonly IOrderSearchRepository _repository;

    public void Handle(OrderCreated evt)
    {
        var readModel = new OrderSearchModel
        {
            OrderId = evt.OrderId,
            CustomerId = evt.CustomerId,
            CreatedOn = evt.Timestamp
        };
        _repository.Save(readModel);
    }

    public void Handle(OrderConfirmed evt)
    {
        var model = _repository.GetById(evt.OrderId);
        model.Status = "Confirmed";
        _repository.Update(model);
    }

    public void Handle(OrderShipped evt)
    {
        var model = _repository.GetById(evt.OrderId);
        model.Status = "Shipped";
        model.ShippedOn = evt.Timestamp;
        _repository.Update(model);
    }
}

۲. Projection Engine: مدیر رویدادات

Projection Engine وظیفه دارد:

  1. رویدادات جدید را شناسایی کند.
  2. آن‌ها را به Projections مختلف منتقل کند.
  3. وضعیت ترجمه (Position) را ذخیره کند (تا اگر سیستم قطع شود، از جایی که رفته بود ادامه دهد).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class ProjectionEngine
{
    private readonly IEventStore _eventStore;
    private readonly IProjectionStateRepository _stateRepository;
    private readonly List<IProjection> _projections;

    public void Run()
    {
        while (true)
        {
            // ۱. آخرین موقعیتی که پردازش شده را بخوان
            var lastProcessedVersion = _stateRepository.GetLastProcessedVersion();

            // ۲. رویدادات جدید را بخوان
            var newEvents = _eventStore.GetEventsAfter(lastProcessedVersion);

            if (!newEvents.Any())
            {
                Thread.Sleep(1000); // منتظر رویداد جدید باش
                continue;
            }

            // ۳. هر رویداد را به تمام Projections منتقل کن
            foreach (var evt in newEvents)
            {
                foreach (var projection in _projections)
                {
                    ((dynamic)projection).Handle((dynamic)evt);
                }

                // ۴. وضعیت را آپدیت کن (تا اگر سیستم قطع شود بدانیم کجا بودیم)
                _stateRepository.SaveLastProcessedVersion(evt.Version);
            }
        }
    }
}

۳. Rebuilding Projections (بازسازی از صفر)

یکی از قدرتمند‌ترین ویژگی‌های CQRS این است که اگر Read Model خراب شود یا Schema تغییر کند، می‌توانیم تمام رویدادات را دوباره پردازش کنیم:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ProjectionRebuildService
{
    public void RebuildProjection<T>(IProjection projection) where T : IProjection
    {
        // ۱. پاک کن Read Model قدیم
        _repository.ClearProjectionData<T>();

        // ۲. تمام رویدادات Event Store را بخوان (از ابتدا!)
        var allEvents = _eventStore.GetAllEvents();

        // ۳. دوباره پردازش کن
        foreach (var evt in allEvents)
        {
            ((dynamic)projection).Handle((dynamic)evt);
        }

        // ۴. علامت بزن "rebuild تمام شد"
        _stateRepository.MarkProjectionAsRebuilt<T>();
    }
}

// استفاده:
// وقتی می‌خواهیم Read Model جدید بسازیم یا Schema آپدیت شود:
var service = new ProjectionRebuildService();
service.RebuildProjection<OrderStatisticsProjection>(
    new OrderStatisticsProjection()
);

مثال عملی: سه Projection متفاوت

فرض کن ما سه نیاز مختلف داریم:

۱. Search Projection (برای جستجو سریع)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class OrderSearchProjection
{
    public void Handle(OrderCreated evt)
    {
        _db.Execute(@"
            INSERT INTO OrderSearch (OrderId, Status, CreatedOn) 
            VALUES (@id, @status, @on)",
            new { id = evt.OrderId, status = "Pending", on = evt.Timestamp });
    }

    public void Handle(OrderConfirmed evt)
    {
        _db.Execute(@"
            UPDATE OrderSearch SET Status = 'Confirmed' 
            WHERE OrderId = @id",
            new { id = evt.OrderId });
    }
}

Database نتیجه:

1
2
3
4
5
-- OrderSearch (Index شده برای جستجو)
| OrderId | Status    | CreatedOn           |
| ------- | --------- | ------------------- |
| 1       | Confirmed | 2024-12-23 10:00:00 |
| 2       | Pending   | 2024-12-23 10:15:00 |

۲. Statistics Projection (برای تحلیل)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class OrderStatisticsProjection
{
    public void Handle(OrderCreated evt)
    {
        var stats = _db.QuerySingle(@"
            SELECT TotalOrders FROM OrderStatistics");
        
        _db.Execute(@"
            UPDATE OrderStatistics SET TotalOrders = @total 
            WHERE Year = YEAR(@date)",
            new { 
                total = stats.TotalOrders + 1, 
                date = evt.Timestamp 
            });
    }

    public void Handle(OrderConfirmed evt)
    {
        var order = _eventStore.GetAggregate(evt.OrderId);
        
        _db.Execute(@"
            UPDATE OrderStatistics SET ConfirmedOrders = ConfirmedOrders + 1,
                                        TotalRevenue = TotalRevenue + @revenue
            WHERE Year = YEAR(@date)",
            new { revenue = order.CalculateTotal(), date = evt.Timestamp });
    }
}

Database نتیجه:

1
2
3
4
-- OrderStatistics (برای Dashboard)
| Year | TotalOrders | ConfirmedOrders | TotalRevenue |
| ---- | ----------- | --------------- | ------------ |
| 2024 | 1500        | 1200            | 50000000     |

۳. Customer Projection (برای Profile)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CustomerOrdersProjection
{
    public void Handle(OrderCreated evt)
    {
        var customer = _customerRepository.GetById(evt.CustomerId);
        
        _db.Execute(@"
            INSERT INTO CustomerOrders (CustomerId, OrderId, CustomerName, Status)
            VALUES (@customerId, @orderId, @name, 'Pending')",
            new { 
                customerId = evt.CustomerId,
                orderId = evt.OrderId,
                name = customer.Name 
            });
    }

    public void Handle(OrderShipped evt)
    {
        _db.Execute(@"
            UPDATE CustomerOrders SET Status = 'Shipped' 
            WHERE OrderId = @id",
            new { id = evt.OrderId });
    }
}

Database نتیجه:

1
2
3
4
5
-- CustomerOrders (برای صفحه‌ی پروفایل کاربر)
| CustomerId | OrderId | CustomerName | Status  |
| ---------- | ------- | ------------ | ------- |
| C1         | 1       | John Doe     | Shipped |
| C1         | 2       | John Doe     | Pending |

مدیریت Stale Reads (خواندن اطلاعات قدیمی)

مشکل: تاخیر در Projection

فرض کن درخواست سفارش در ساعت ۱۰:۰۰:۰۰ انجام شود:

1
2
3
4
5
6
7
10:00:00.000 - Order Created (Event به Event Store می‌رود)
10:00:00.001 - Event در Event Store ذخیره شد
10:00:00.050 - Projection Handler رویداد را می‌گیرد
10:00:00.100 - Read Model آپدیت شد
10:00:00.200 - کاربر "نتایج جستجو" را می‌خواند

[سفارش را می‌بیند ✓]

اما گاهی:

1
2
3
4
5
6
10:00:00.000 - Order Created
10:00:00.001 - Event Store: ✓
10:00:00.002 - کاربر "جستجو برای سفارشات" را فوری می‌زند
10:00:00.003 - Read Model هنوز آپدیت نشده! (Projection تاخیر دارد)

[سفارش را نمی‌بیند ✗] ← Stale Read!

راه‌حل ۱: Polling (منتظر ماندن)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class OrderSearchQueryHandler
{
    public List<OrderSearchModel> Handle(SearchOrdersQuery query)
    {
        // سعی کن ۵ بار، هر بار ۱۰۰ms منتظر باش
        for (int i = 0; i < 5; i++)
        {
            var results = _readRepository.Search(query.CustomerId);
            
            if (results.Any())
                return results; // پیدا شد!
            
            Thread.Sleep(100); // منتظر Projection
        }

        // اگر هنوز پیدا نشد، بازگردان چی که داریم (خالی یا قدیمی)
        return _readRepository.Search(query.CustomerId);
    }
}

راه‌حل ۲: Event-Driven Notification

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class OrderSearchQueryHandler
{
    private readonly IWebSocketHub _webSocketHub;

    public List<OrderSearchModel> Handle(SearchOrdersQuery query)
    {
        var results = _readRepository.Search(query.CustomerId);
        
        // اگر هیچ نتیجه نیست، مراقب رویدادات جدید باش
        if (!results.Any())
        {
            // WebSocket را باز کن تا به کاربر اطلاع دهی وقتی سفارش ظاهر شد
            _webSocketHub.SubscribeToOrderCreation(query.CustomerId, (order) =>
            {
                // ارسال اطلاع به مرورگر: "سفارش جدید!"
                _webSocketHub.SendToClient(query.CustomerId, order);
            });
        }

        return results;
    }
}

راه‌حل ۳: Write-Through Cache (بهترین)

هنگام نوشتن، فوری Read Model را در cache قرار بده:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CreateOrderCommandHandler
{
    public void Handle(CreateOrderCommand cmd)
    {
        // ۱. ذخیره‌ی Order
        var order = new Order();
        order.CreateOrder(cmd.CustomerId, cmd.Items);
        _orderRepository.Save(order);

        // ۲. فوری Cache کن تا خواندن سریع باشد
        var readModel = new OrderSearchModel
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            Status = "Pending",
            CreatedOn = order.CreatedOn
        };
        _cache.Set($"order:{order.Id}", readModel, expiry: TimeSpan.FromHours(1));

        // ۳. منتشر کن رویداد (برای آپدیت دیتابیس)
        _eventDispatcher.Dispatch(order.DomainEvents);
    }
}

نتیجه: کاربر فوری سفارش را می‌بیند (از cache)، و دیتابیس Read Model به تدریج آپدیت می‌شود.

Projection Versioning

اگر Projection Handler تغییر کند، چه؟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// نسخه ۱ (قدیم)
public class OrderSearchProjectionV1
{
    public void Handle(OrderCreated evt)
    {
        _db.Insert("OrderSearch", new { OrderId = evt.OrderId, Status = "Pending" });
    }
}

// نسخه ۲ (جدید: اضافه‌کردن CustomerCity)
public class OrderSearchProjectionV2
{
    public void Handle(OrderCreated evt)
    {
        var customer = _customerRepository.GetById(evt.CustomerId);
        _db.Insert("OrderSearch", new { 
            OrderId = evt.OrderId, 
            Status = "Pending",
            CustomerCity = customer.City // فیلد جدید
        });
    }
}

حل:

1
2
3
4
5
6
7
8
9
10
// ۱. جدول جدید بسازید
_db.Execute("CREATE TABLE OrderSearchV2 (...)");

// ۲. تمام رویدادات را دوباره پردازش کنید
var projectionV2 = new OrderSearchProjectionV2();
var projectionRebuildService = new ProjectionRebuildService();
projectionRebuildService.RebuildProjection(projectionV2);

// ۳. قدیم را حذف کنید (بعد از تست)
_db.Execute("DROP TABLE OrderSearch");

خلاصه: Projection Engines

کارچگونگی
تحویل رویدادهاContinuously poll Event Store
آپدیت Read ModelsHandle دختلف برای هر Projection
بازسازیReplay تمام رویدادات
Stale ReadsPolling، WebSocket، یا Cache
VersioningRebuild جدول با نسخه جدید

فصل ۷: CQRS - بخش سوم

Anti-patterns و خطاهای عمومی در CQRS

یکی از بزرگ‌ترین خطرات CQRS این است که سهل است اشتباه کنید و سیستم را پیچیده‌تر کنید بجای ساده‌تر کردن.

۱. Anti-pattern: Dual Writes (دوبار نوشتن)

مشکل

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ غلط: دو جای متفاوت را آپدیت می‌کنیم
public class CreateOrderCommandHandler
{
    public void Handle(CreateOrderCommand cmd)
    {
        // 1. نوشتن در Write Model
        var order = new Order();
        order.CreateOrder(cmd.CustomerId, cmd.Items);
        _eventStore.Save(order);

        // 2. نوشتن در Read Model (مستقل!)
        var searchModel = new OrderSearchModel { ... };
        _readDatabase.Save(searchModel); // اگر این ناکام شود؟
    }
}

چرا خطرناک است؟

  • اگر اولی موفق و دومی ناکام شود، عدم سازگاری رخ می‌دهد.
  • Event Store و Read Model ناهماهنگ می‌شوند.
  • غیرممکن است تضمین اتمیسیتی (Atomicity) هنگام تغییر دو دیتابیس مستقل.

راه‌حل: از Events استفاده کنید

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ✓ درست: تنها یک نوشتن
public class CreateOrderCommandHandler
{
    public void Handle(CreateOrderCommand cmd)
    {
        var order = new Order();
        order.CreateOrder(cmd.CustomerId, cmd.Items);
        
        // فقط Event Store
        _eventStore.Save(order);
        
        // رویدادات خود را حمل می‌کند
        foreach (var evt in order.DomainEvents)
        {
            _eventDispatcher.Dispatch(evt);
        }
    }
}

// Projection Handler (جداگانه)
public class OrderSearchProjectionHandler
{
    public void Handle(OrderCreated evt)
    {
        // Read Model از رویداد آپدیت می‌شود
        _readDatabase.Insert(...);
    }
}

نتیجه: اگر Projection تاخیری داشته باشد، حداقل منطق حقیقی (Event Store) ثابت است.

۲. Anti-pattern: Over-normalized Read Models

مشکل

گاهی برنامه‌نویسان هنوز جداول Normalized می‌سازند در Read Model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- ❌ غلط: Read Model هنوز Normalized است!
CREATE TABLE Orders (
    Id GUID,
    CustomerId GUID
);

CREATE TABLE Customers (
    Id GUID,
    Name VARCHAR(100)
);

-- کوئری نیاز به JOIN دارد (سرعت کند)
SELECT o.Id, c.Name 
FROM Orders o 
JOIN Customers c ON o.CustomerId = c.Id;

راه‌حل: Denormalize

1
2
3
4
5
6
7
8
9
10
11
-- ✓ درست: Read Model Denormalized است
CREATE TABLE OrdersSearch (
    OrderId GUID,
    CustomerName VARCHAR(100),  -- نام کاپی شده
    CustomerCity VARCHAR(50),   -- شهر کاپی شده
    Status VARCHAR(50),
    CreatedOn DATETIME
);

-- کوئری سریع (بدون JOIN)
SELECT * FROM OrdersSearch WHERE CustomerCity = 'Tehran';

اصل: Read Model برای بخواندن بدون پردازش اضافی طراحی می‌شود. کپی کردن داده‌ها (Denormalization) کاملاً مجاز است.

۳. Anti-pattern: Complex Projections

مشکل

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ غلط: Projection بیش از حد پیچیده
public class ComplexOrderProjection
{
    public void Handle(OrderCreated evt)
    {
        // محاسبه‌ی پیچیده
        var totalRevenue = _eventStore.GetAllOrders()
            .Where(o => o.CreatedOn.Year == DateTime.Now.Year)
            .Sum(o => o.Total);

        var conversionRate = _eventStore.GetAllLeads()
            .Count(l => l.IsConverted) / (decimal)_eventStore.GetAllLeads().Count();

        // دسترسی به سرویس‌های خارجی
        var exchangeRate = _externalService.GetExchangeRate("USD", "EUR");

        // ترجمه بسیار پیچیده
        _readDatabase.Update(...);
    }
}

چرا مشکل است؟

  • Projection کند می‌شود (تمام رویدادات را دوباره پردازش کند).
  • سیستم‌های خارجی‌ای (سرویس exchange) وابستگی دارند.
  • اگر منطق تغییر کند، Rebuild کل Projection طول می‌کشد.

راه‌حل: Projections ساده نگاه دارید

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ✓ درست: Projection سادگی دارد
public class OrderSearchProjection
{
    public void Handle(OrderCreated evt)
    {
        // فقط رویداد را مپ کنید
        _readDatabase.Insert(new OrderSearchModel
        {
            OrderId = evt.OrderId,
            CustomerId = evt.CustomerId,
            Status = "Pending",
            CreatedOn = evt.Timestamp
            // بیشتر نه!
        });
    }
}

// محاسبات پیچیده را در Query Handler انجام دهید
public class GetOrderStatisticsQueryHandler
{
    public OrderStatisticsReadModel Handle(GetOrderStatisticsQuery query)
    {
        var orders = _readDatabase.GetAllOrders();
        
        return new OrderStatisticsReadModel
        {
            TotalRevenue = orders.Sum(o => o.Total),
            AverageOrderValue = orders.Average(o => o.Total),
            ConversionRate = CalculateConversionRate(orders)
        };
    }
}

اصل: Projections فقط داده‌ها را منتقل می‌کند، محاسبات در Query Handler رخ می‌دهند.

۴. Anti-pattern: Event Sourcing الزام

مشکل

1
"ما CQRS استفاده می‌کنیم، پس باید Event Sourcing داشته باشیم!"

اینجا اشتباه است. CQRS و Event Sourcing مستقل هستند.

راه‌حل: انتخاب آگاهانه

1
2
3
4
5
6
7
// ✓ CQRS بدون Event Sourcing (بسیاری موارد)
Write Model: Aggregate (State-based)  Database سنتی
Read Model: Read-optimized tables  Sync via triggers/queue

// ✓ CQRS + Event Sourcing (موارد خاص)
Write Model: Aggregate (Event-based)  Event Store
Read Model: Read-optimized tables  Projections

توصیه:

  • شروع با CQRS ساده (بدون Event Sourcing).
  • اگر نیاز به تاریخچه یا Audit شد، Event Sourcing را اضافه کنید.

۵. مدیریت خرابی در Projections

یکی از بزرگ‌ترین چالش‌های عملیاتی CQRS این است: اگر Projection Handler خراب شود چه؟

سناریو: خرابی در وسط

1
2
3
4
Event ۱: OrderCreated      ✓ پردازش شد
Event ۲: OrderConfirmed    ✓ پردازش شد
Event ۳: OrderShipped      ✗ Exception! (موجودی موجود نیست)
Event ۴: OrderDelivered    [قفل شده، پردازش نمی‌شود]

Read Model متوقف می‌شود و رویدادات جدید پردازش نمی‌شوند.

راه‌حل ۱: Retry with Exponential Backoff

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class ResilientProjectionHandler
{
    public void Handle(IDomainEvent evt, int maxRetries = 3)
    {
        int retryCount = 0;
        
        while (retryCount < maxRetries)
        {
            try
            {
                ProcessEvent(evt);
                return; // موفق!
            }
            catch (Exception ex)
            {
                retryCount++;
                
                if (retryCount >= maxRetries)
                {
                    // صرف نظر کنید، ثبت کنید، ادامه دهید
                    _logger.LogError($"Failed to process event {evt.Id} after {maxRetries} retries", ex);
                    return;
                }
                
                // exponential backoff: 100ms, 200ms, 400ms
                var delay = Math.Pow(2, retryCount - 1) * 100;
                Thread.Sleep((int)delay);
            }
        }
    }

    private void ProcessEvent(IDomainEvent evt)
    {
        ((dynamic)this).Handle((dynamic)evt);
    }
}

راه‌حل ۲: Dead Letter Queue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class ProjectionEngineWithDLQ
{
    private readonly IDeadLetterQueue _deadLetterQueue;
    private const int MAX_RETRIES = 5;

    public void ProcessEvent(IDomainEvent evt)
    {
        try
        {
            _projection.Handle((dynamic)evt);
        }
        catch (Exception ex) when (ex is TransientException)
        {
            // خرابی موقت (شاید دیتابیس مشغول است)
            throw; // Retry
        }
        catch (Exception ex) when (IsRetryable(ex) && GetRetryCount(evt.Id) < MAX_RETRIES)
        {
            // تلاش مجدد محدود
            IncrementRetryCount(evt.Id);
            throw;
        }
        catch (Exception ex)
        {
            // خرابی دائمی یا تلاش‌های زیاد
            _deadLetterQueue.Send(new DeadLetterMessage
            {
                Event = evt,
                Exception = ex,
                Timestamp = DateTime.UtcNow
            });
            
            _logger.LogError($"Event {evt.Id} moved to DLQ: {ex.Message}");
        }
    }
}

// بعداً، مهندسان می‌توانند مسئله را بررسی و حل کنند
public class DeadLetterProcessingService
{
    public void ProcessDeadLetterManually(DeadLetterMessage message)
    {
        // ۱. بررسی کنید مشکل چه بود
        // ۲. حل کنید (مثلاً کالای موجود را اضافه کنید)
        // ۳. دوباره پردازش کنید
        _projection.Handle((dynamic)message.Event);
    }
}

۶. Cache Invalidation (بی‌اعتبار کردن Cache)

مشکل

اگر Cache برای Read Model استفاده کنیم:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class GetOrderQueryHandler
{
    public OrderReadModel Handle(GetOrderQuery query)
    {
        // Cache را چک کن
        var cached = _cache.Get($"order:{query.OrderId}");
        if (cached != null)
            return cached; // فوری!

        // Cache miss: از دیتابیس
        var order = _database.GetOrder(query.OrderId);
        _cache.Set($"order:{query.OrderId}", order, TimeSpan.FromHours(1));
        return order;
    }
}

اما اگر Order آپدیت شود:

1
2
3
4
5
6
7
8
9
public class OrderConfirmedProjectionHandler
{
    public void Handle(OrderConfirmed evt)
    {
        _database.Update(...);
        // ❌ Cache را فراموش کردی!
        // کاربر هنوز وضعیت قدیمی را می‌بیند
    }
}

راه‌حل: Cache Invalidation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class OrderProjectionHandlerWithCacheInvalidation
{
    private readonly ICache _cache;

    public void Handle(OrderConfirmed evt)
    {
        _database.Update(...);
        
        // Cache را بی‌اعتبار کن
        _cache.Invalidate($"order:{evt.OrderId}");
    }

    public void Handle(OrderShipped evt)
    {
        _database.Update(...);
        
        // همچنین Order List cache را بی‌اعتبار کن
        _cache.InvalidatePattern($"orders:*");
    }
}

۷. Consistency و Eventual Consistency Guarantees

سوال: چه حدتاخیری قابل تحمل است؟

پاسخ: بستگی به Use Case دارد.

Use CaseMax Latencyمثال
Dashboard۱۰ ثانیه“کل فروش امروز”
Reporting۱ دقیقه“فروش هفتگی”
Search۱۰۰ میلی‌ثانیه“پیدا کردن سفارش”
Payment۰ میلی‌ثانیه“بررسی موجودی”

برای موارد حساس: Write-Through

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PaymentCommandHandler
{
    public void Handle(ProcessPaymentCommand cmd)
    {
        // ۱. پردازش تراکنش (Write Model)
        var payment = ProcessPayment(cmd);
        _eventStore.Save(payment);

        // ۲. برای موارد حساس، فوری Cache کنید
        var readModel = new PaymentStatusReadModel
        {
            PaymentId = payment.Id,
            Status = payment.Status,
            Amount = payment.Amount
        };
        _cache.Set($"payment:{payment.Id}", readModel, TimeSpan.FromMinutes(5));

        // ۳. رویداد را منتشر کنید (برای آپدیت دیتابیس بعداً)
        _eventDispatcher.Dispatch(payment.DomainEvents);
    }
}

خلاصه Anti-patterns CQRS

Anti-patternمشکلحل
Dual Writesعدم سازگاریفقط یک نوشتن، بقیه از Events
Over-normalized Readکوئری کندDenormalize Read Model
Complex Projectionsبازسازی طول کشدProjections ساده، منطق در Query
Event Sourcing الزامبیش از حد پیچیدهCQRS بدون ES شروع کنید
Projection Failuresسیستم تعطیلRetry + Dead Letter Queue
Cache Issuesاطلاعات قدیمCache Invalidation

فصل ۷: CQRS - بخش چهارم (پایانی)

تست کردن (Testing) سیستم‌های CQRS

تست کردن سیستم‌های CQRS متفاوت از تست کردن سیستم‌های سنتی است چون ما دو مدل مختلف داریم.

۱. تست Command Handlers (نوشتن)

Command Handlers نباید تنها چیز تست شود؛ منطق دامنه اصلی است.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
[TestClass]
public class CreateOrderCommandHandlerTests
{
    private CreateOrderCommandHandler _handler;
    private MockOrderRepository _orderRepository;
    private MockEventDispatcher _dispatcher;

    [TestInitialize]
    public void Setup()
    {
        _orderRepository = new MockOrderRepository();
        _dispatcher = new MockEventDispatcher();
        _handler = new CreateOrderCommandHandler(_orderRepository, _dispatcher);
    }

    [TestMethod]
    public void CreateOrder_WithValidCommand_SavesOrderAndDispatchesEvent()
    {
        // Arrange
        var customerId = Guid.NewGuid();
        var cmd = new CreateOrderCommand(customerId, new[]
        {
            new OrderLineItem(Guid.NewGuid(), 2, 50m)
        });

        // Act
        _handler.Handle(cmd);

        // Assert
        Assert.IsTrue(_orderRepository.SaveWasCalled);
        Assert.AreEqual(1, _dispatcher.DispatchedEvents.Count);
        Assert.IsInstanceOfType(_dispatcher.DispatchedEvents[0], typeof(OrderCreated));
    }

    [TestMethod]
    public void CreateOrder_WithNegativeQuantity_ThrowsException()
    {
        // Arrange
        var cmd = new CreateOrderCommand(Guid.NewGuid(), new[]
        {
            new OrderLineItem(Guid.NewGuid(), -1, 50m) // منفی!
        });

        // Act & Assert
        Assert.ThrowsException<InvalidOperationException>(() => _handler.Handle(cmd));
    }

    [TestMethod]
    public void CreateOrder_WithEmptyItems_ThrowsException()
    {
        // Arrange
        var cmd = new CreateOrderCommand(Guid.NewGuid(), Array.Empty<OrderLineItem>());

        // Act & Assert
        Assert.ThrowsException<InvalidOperationException>(() => _handler.Handle(cmd));
    }
}

۲. تست Query Handlers (خواندن)

Query Handlers باید از Read Model بخوانند، نه Event Store.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
[TestClass]
public class SearchOrdersQueryHandlerTests
{
    private SearchOrdersQueryHandler _handler;
    private MockOrderSearchRepository _searchRepository;

    [TestInitialize]
    public void Setup()
    {
        _searchRepository = new MockOrderSearchRepository();
        _handler = new SearchOrdersQueryHandler(_searchRepository);
    }

    [TestMethod]
    public void SearchOrders_WithValidCity_ReturnsMatchingOrders()
    {
        // Arrange
        var ordersInDb = new List<OrderSearchModel>
        {
            new OrderSearchModel { OrderId = Guid.NewGuid(), City = "Tehran", Status = "Confirmed" },
            new OrderSearchModel { OrderId = Guid.NewGuid(), City = "Isfahan", Status = "Confirmed" },
            new OrderSearchModel { OrderId = Guid.NewGuid(), City = "Tehran", Status = "Pending" }
        };
        _searchRepository.SetupOrders(ordersInDb);

        var query = new SearchOrdersQuery { City = "Tehran" };

        // Act
        var results = _handler.Handle(query);

        // Assert
        Assert.AreEqual(2, results.Count);
        Assert.IsTrue(results.All(o => o.City == "Tehran"));
    }

    [TestMethod]
    public void SearchOrders_WithNoMatches_ReturnsEmptyList()
    {
        // Arrange
        var query = new SearchOrdersQuery { City = "NonExistentCity" };

        // Act
        var results = _handler.Handle(query);

        // Assert
        Assert.AreEqual(0, results.Count);
    }

    [TestMethod]
    public void SearchOrders_PerformsWell_WithLargeDataSet()
    {
        // Arrange
        var largeDataSet = Enumerable.Range(0, 10000)
            .Select(i => new OrderSearchModel 
            { 
                OrderId = Guid.NewGuid(), 
                City = i % 2 == 0 ? "Tehran" : "Isfahan" 
            })
            .ToList();
        _searchRepository.SetupOrders(largeDataSet);

        var query = new SearchOrdersQuery { City = "Tehran" };

        // Act
        var stopwatch = System.Diagnostics.Stopwatch.StartNew();
        var results = _handler.Handle(query);
        stopwatch.Stop();

        // Assert
        Assert.IsTrue(stopwatch.ElapsedMilliseconds < 100, "Query took too long!");
        Assert.AreEqual(5000, results.Count);
    }
}

۳. تست Projections

تست کردن Projections یعنی رویداد را اعمال کنید و بررسی کنید Read Model درست آپدیت شد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
[TestClass]
public class OrderSearchProjectionTests
{
    private OrderSearchProjection _projection;
    private MockOrderSearchRepository _repository;

    [TestInitialize]
    public void Setup()
    {
        _repository = new MockOrderSearchRepository();
        _projection = new OrderSearchProjection(_repository);
    }

    [TestMethod]
    public void OrderCreatedEvent_InsertsNewSearchRecord()
    {
        // Arrange
        var evt = new OrderCreated
        {
            OrderId = Guid.NewGuid(),
            CustomerId = Guid.NewGuid(),
            Timestamp = DateTime.UtcNow
        };

        // Act
        _projection.Handle(evt);

        // Assert
        var inserted = _repository.GetById(evt.OrderId);
        Assert.IsNotNull(inserted);
        Assert.AreEqual("Pending", inserted.Status);
        Assert.AreEqual(evt.Timestamp, inserted.CreatedOn);
    }

    [TestMethod]
    public void OrderConfirmedEvent_UpdatesStatus()
    {
        // Arrange: ابتدا یک Order ایجاد شود
        var createEvent = new OrderCreated { OrderId = Guid.NewGuid(), Timestamp = DateTime.UtcNow };
        _projection.Handle(createEvent);

        // Act: تایید آن
        var confirmEvent = new OrderConfirmed { OrderId = createEvent.OrderId, Timestamp = DateTime.UtcNow };
        _projection.Handle(confirmEvent);

        // Assert
        var updated = _repository.GetById(createEvent.OrderId);
        Assert.AreEqual("Confirmed", updated.Status);
    }

    [TestMethod]
    public void MultipleEvents_ProducesCorrectFinalState()
    {
        // Arrange
        var orderId = Guid.NewGuid();
        var events = new IDomainEvent[]
        {
            new OrderCreated { OrderId = orderId, Timestamp = new DateTime(2024, 12, 24, 10, 0, 0) },
            new OrderConfirmed { OrderId = orderId, Timestamp = new DateTime(2024, 12, 24, 10, 5, 0) },
            new OrderShipped { OrderId = orderId, Timestamp = new DateTime(2024, 12, 24, 10, 10, 0) }
        };

        // Act
        foreach (var evt in events)
        {
            _projection.Handle((dynamic)evt);
        }

        // Assert
        var final = _repository.GetById(orderId);
        Assert.AreEqual("Shipped", final.Status);
        Assert.AreEqual(new DateTime(2024, 12, 24, 10, 10, 0), final.ShippedOn);
    }
}

۴. تست Integration

تست تمام سیستم با هم (یا حداقل Write Model + Read Model):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
[TestClass]
public class CQRS_IntegrationTests
{
    private OrderService _orderService; // Command side
    private OrderQueryService _queryService; // Query side
    private InMemoryEventStore _eventStore;
    private InMemorySearchRepository _searchRepository;

    [TestInitialize]
    public void Setup()
    {
        _eventStore = new InMemoryEventStore();
        _searchRepository = new InMemorySearchRepository();
        
        _orderService = new OrderService(_eventStore);
        _queryService = new OrderQueryService(_searchRepository);
    }

    [TestMethod]
    public void EndToEnd_CreateOrderAndSearch()
    {
        // Arrange
        var customerId = Guid.NewGuid();
        var cmd = new CreateOrderCommand(customerId, new[]
        {
            new OrderLineItem(Guid.NewGuid(), 2, 100m)
        });

        // Act 1: ایجاد سفارش
        _orderService.CreateOrder(cmd);

        // مانند Projection (در تست، بلافاصله انجام می‌شود)
        var projection = new OrderSearchProjection(_searchRepository);
        foreach (var evt in _eventStore.GetAllEvents())
        {
            projection.Handle((dynamic)evt);
        }

        // Act 2: جستجو
        var searchResults = _queryService.SearchByCustomer(customerId);

        // Assert
        Assert.AreEqual(1, searchResults.Count);
        Assert.AreEqual(customerId, searchResults[0].CustomerId);
    }

    [TestMethod]
    public void EndToEnd_ConsistencyAfterMultipleCommands()
    {
        // Arrange
        var customerId = Guid.NewGuid();
        var orderId = Guid.NewGuid();

        // Act: ایجاد، تایید، ارسال
        _orderService.CreateOrder(new CreateOrderCommand(customerId, ...));
        _orderService.ConfirmOrder(new ConfirmOrderCommand(orderId));
        _orderService.ShipOrder(new ShipOrderCommand(orderId));

        // Projections
        var projection = new OrderSearchProjection(_searchRepository);
        foreach (var evt in _eventStore.GetAllEvents())
        {
            projection.Handle((dynamic)evt);
        }

        // Assert
        var order = _queryService.GetOrderById(orderId);
        Assert.AreEqual("Shipped", order.Status);
    }
}

استراتژی نهایی: State-Based vs Event-Sourced

جدول تصمیم‌گیری نهایی

معیارState-Based ModelEvent-Sourced Model
منطق تجاریساده تا متوسطپیچیده
نیاز به تاریخچهخیربله
Audit Logنیاز به جدول جداگانهدرون‌ساخت
پرفورمنس نوشتنسریعمعمولی
پرفورمنس خواندنمعمولی (بدون CQRS)بهتر (با CQRS)
پیچیدگیپایینبالا
تحلیل زمانیغیرممکنممکن
منحنی یادگیریکمزیاد

درخت تصمیم نهایی

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
شروع: چه نوع Subdomain است؟

├─ CORE (منطق پیچیده)
│  └─ نیاز به نگاه کردن به گذشته یا Audit؟
│     ├─ YES → Event Sourcing + CQRS
│     └─ NO → Domain Model + CQRS (اختیاری)
│
├─ GENERIC (منطق معروف)
│  └─ CRUD ساده؟
│     ├─ YES → Transaction Script یا Active Record
│     └─ NO → Domain Model
│
└─ SUPPORTING (کمکی)
   └─ فقط خواندن یا نوشتن ساده؟
      ├─ YES → Transaction Script
      └─ NO → Active Record

مثال نهایی: سیستم بانکی

Write Model: Event-Sourced

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class BankAccount : AggregateRoot
{
    private decimal _balance;
    private List<DomainEvent> _uncommittedEvents = new();

    public BankAccount() { }

    public static BankAccount Create(string accountNumber, decimal initialBalance)
    {
        var account = new BankAccount();
        account.ApplyEvent(new AccountCreated
        {
            AccountNumber = accountNumber,
            InitialBalance = initialBalance,
            Timestamp = DateTime.UtcNow
        });
        return account;
    }

    public void Withdraw(decimal amount)
    {
        if (amount > _balance)
            throw new InsufficientFundsException();

        ApplyEvent(new MoneyWithdrawn
        {
            Amount = amount,
            Timestamp = DateTime.UtcNow
        });
    }

    private void ApplyEvent(DomainEvent evt)
    {
        Handle((dynamic)evt);
        _uncommittedEvents.Add(evt);
    }

    public void Handle(AccountCreated evt)
    {
        _balance = evt.InitialBalance;
    }

    public void Handle(MoneyWithdrawn evt)
    {
        _balance -= evt.Amount;
    }
}

Read Model: Denormalized

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- تاریخچه تمام تراکنش‌ها برای Audit
CREATE TABLE TransactionHistory (
    TransactionId GUID PRIMARY KEY,
    AccountNumber VARCHAR(20),
    TransactionType VARCHAR(50),
    Amount DECIMAL,
    Timestamp DATETIME
);

-- وضعیت فعلی برای Query سریع
CREATE TABLE AccountsBalance (
    AccountNumber VARCHAR(20) PRIMARY KEY,
    Balance DECIMAL,
    LastTransactionOn DATETIME
);

Projection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class BankAccountProjection
{
    public void Handle(AccountCreated evt)
    {
        _historyDb.Insert("TransactionHistory", new
        {
            TransactionId = Guid.NewGuid(),
            AccountNumber = evt.AccountNumber,
            TransactionType = "CREATE",
            Amount = evt.InitialBalance,
            Timestamp = evt.Timestamp
        });

        _balanceDb.Insert("AccountsBalance", new
        {
            AccountNumber = evt.AccountNumber,
            Balance = evt.InitialBalance,
            LastTransactionOn = evt.Timestamp
        });
    }

    public void Handle(MoneyWithdrawn evt)
    {
        _historyDb.Insert("TransactionHistory", new
        {
            TransactionId = Guid.NewGuid(),
            AccountNumber = evt.AccountNumber,
            TransactionType = "WITHDRAW",
            Amount = evt.Amount,
            Timestamp = evt.Timestamp
        });

        _balanceDb.Execute(@"
            UPDATE AccountsBalance 
            SET Balance = Balance - @amount, LastTransactionOn = @timestamp
            WHERE AccountNumber = @account",
            new { amount = evt.Amount, timestamp = evt.Timestamp, account = evt.AccountNumber });
    }
}

خلاصه نهایی فصل ۷

CQRS چیست؟ جدا کردن مدل نوشتن (Write) از مدل خواندن (Read).

چرا مفید است؟

  • پرفورمنس بالا برای خواندن.
  • مقیاس‌پذیری بهتر.
  • تحلیل عمیق‌تر.

خطرات:

  • Eventual Consistency (تاخیر).
  • پیچیدگی اضافی.
  • Dual writes.

بهترین روش:

  • شروع ساده (State-based + واحد Database).
  • اگر نیاز شد، CQRS اضافه کنید.
  • فقط اگر نیاز به تاریخچه باشد، Event Sourcing استفاده کنید.

فصل ۸: الگوهای معماری (Architectural Patterns) - بخش اول

مقدمه: از Tactical به Architectural

تا اینجا فوکوس ما بر منطق تجاری (Business Logic) بود:

  • چگونه Aggregateها را طراحی کنیم.
  • چگونه Domain Events استفاده کنیم.
  • چگونه CQRS پیاده کنیم.

اما یک سیستم صرفاً منطق تجاری نیست. باید:

  • کاربران با سیستم تعامل داشته باشند (UI).
  • داده‌ها پایدار ذخیره شوند (Database).
  • سیستم‌های خارجی یکپارچه شوند (Integration).
  • مسائل فنی (logging, caching, security) مدیریت شوند.

معماری نرم‌افزار این تمام جزئیات را سازمان‌دهی و هماهنگ می‌کند.

الگوهای معماری اصلی

۱. Layered Architecture (معماری لایه‌ای)

یک روش کلاسیک و پر استفاده برای سازمان‌دهی کد.

ساختار

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────┐
│   Presentation Layer        │  (UI، API Controllers)
│   (User Interface)          │
└────────────────┬────────────┘
                 │
┌────────────────▼────────────┐
│   Application Layer         │  (Use Cases، Services)
│   (Business Logic           │
│    Orchestration)           │
└────────────────┬────────────┘
                 │
┌────────────────▼────────────┐
│   Domain Layer              │  (Entities، Value Objects،
│   (Business Rules)          │   Domain Services)
└────────────────┬────────────┘
                 │
┌────────────────▼────────────┐
│   Infrastructure Layer      │  (Database، APIs،
│   (Technical Details)       │   External Services)
└─────────────────────────────┘

مثال: سفارش را پردازش کنید

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Layer 1: Presentation (کاربر یا API)
[ApiController]
[Route("api/orders")]
public class OrdersController
{
    private readonly CreateOrderUseCase _createOrderUseCase;

    [HttpPost]
    public IActionResult Create(CreateOrderRequest request)
    {
        // درخواست را پاس بده به Application Layer
        _createOrderUseCase.Execute(request);
        return Ok("Order created");
    }
}

// Layer 2: Application (Use Case - Orchestration)
public class CreateOrderUseCase
{
    private readonly OrderService _orderService;
    private readonly IOrderRepository _repository;
    private readonly IEventDispatcher _dispatcher;

    public void Execute(CreateOrderRequest request)
    {
        // ۱. اعتبارسنجی
        if (!IsValid(request))
            throw new InvalidRequestException();

        // ۲. فراخوانی Domain Logic
        var order = _orderService.CreateOrder(request.CustomerId, request.Items);

        // ۳. ذخیره (Infrastructure)
        _repository.Save(order);

        // ۴. منتشر رویدادات
        foreach (var evt in order.DomainEvents)
            _dispatcher.Dispatch(evt);
    }
}

// Layer 3: Domain (Business Rules)
public class OrderService
{
    public Order CreateOrder(Guid customerId, List<OrderItem> items)
    {
        // قوانین تجاری
        if (items.Count == 0)
            throw new InvalidOperationException("Order must have items");

        return new Order(customerId, items);
    }
}

// Layer 4: Infrastructure (Technical)
public class SqlOrderRepository : IOrderRepository
{
    public void Save(Order order)
    {
        // SQL queries، database operations
        var sql = "INSERT INTO Orders (...) VALUES (...)";
        _database.Execute(sql);
    }
}

مزایا

سادگی: درک و پیمایش آسان. ✓ تقسیم مسئولیت: هر لایه وظیفه‌ای دارد. ✓ آزمایش: هر لایه مستقل قابل تست است.

معایب

Big Ball of Mud: اگر سیستم بزرگ شود، لایه‌ها می‌توانند “پشتیبان تمام چیز” شوند. ✗ وابستگی‌های دوره‌ای: اگر نگاه دقیق نشود، وابستگی‌های دایره‌ای رخ می‌دهند.

۲. Hexagonal Architecture (معماری شش‌گوش)

یک روش جدیدتر که تأکید می‌کند بر استقلال دامنه.

ایده

دامنه در مرکز است. همه چیز (UI، Database، API) در لبه (Ports و Adapters).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
                    ┌─────────────────┐
                    │      UI         │
                    └────────┬────────┘
                             │
          ┌──────────────────┼──────────────────┐
          │                  │                  │
    ┌─────▼──┐         ┌─────▼──┐        ┌─────▼──┐
    │ Port   │         │ Port   │        │ Port   │
    │(HTTP)  │         │(Email) │        │(Queue) │
    └─────┬──┘         └─────┬──┘        └─────┬──┘
          │                  │                  │
          └──────────────────┼──────────────────┘
                             │
                    ┌────────▼────────┐
                    │   Application   │  ← Orchestration
                    └────────┬────────┘
                             │
                    ┌────────▼────────┐
                    │  Domain Model   │  ← Business Rules
                    │  (Aggregates)   │
                    └────────┬────────┘
                             │
          ┌──────────────────┼──────────────────┐
          │                  │                  │
    ┌─────▼──┐         ┌─────▼──┐        ┌─────▼──┐
    │Adapter │         │Adapter │        │Adapter │
    │(SQL)   │         │(File)  │        │(REST)  │
    └────────┘         └────────┘        └────────┘

مثال

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// Center: Domain Model (مستقل کاملاً)
public class Order : AggregateRoot
{
    public void CreateOrder(Guid customerId, List<OrderItem> items)
    {
        // منطق پاک، بدون وابستگی به Database یا HTTP
        if (items.Count == 0)
            throw new InvalidOperationException();
        // ...
    }
}

// Port: Interface (قرارداد)
public interface IOrderRepository
{
    void Save(Order order);
    Order GetById(Guid id);
}

// Adapter: پیاده‌سازی (SQL)
public class SqlOrderRepository : IOrderRepository
{
    private readonly SqlConnection _connection;

    public void Save(Order order)
    {
        // تفاصیل SQL
    }
}

// Port: Interface (برای Email)
public interface IEmailService
{
    void SendOrderConfirmation(Order order);
}

// Adapter: پیاده‌سازی (Gmail SMTP)
public class GmailEmailService : IEmailService
{
    public void SendOrderConfirmation(Order order)
    {
        // تفاصیل SMTP
    }
}

// Application Layer: Orchestration
public class CreateOrderUseCase
{
    private readonly IOrderRepository _orderRepository;
    private readonly IEmailService _emailService;

    public void Execute(CreateOrderCommand cmd)
    {
        // Port استفاده کنید (نه Adapter)
        var order = new Order();
        order.CreateOrder(cmd.CustomerId, cmd.Items);

        _orderRepository.Save(order); // Port
        _emailService.SendOrderConfirmation(order); // Port
    }
}

مزایا

دامنه مستقل: منطق تجاری از تفاصیل فنی جدا است. ✓ تست‌پذیری: Ports را mock کنید و دامنه را تست کنید. ✓ تعویض Adapters: من‌فعال SQL و جایگزین MongoDB کنید بدون تغییر Domain.

۳. Clean Architecture

یک معماری جامع که تمام بهترین‌روش‌ها را یکپارچه می‌کند.

ساختار (حلقه‌های متراکز)

1
2
3
4
5
6
7
8
9
10
11
12
┌────────────────────────────────────┐
│  Frameworks & Tools                │  (Spring, Django، ASP.NET)
│  (Web, DB, etc.)                   │
├────────────────────────────────────┤
│  Interface Adapters                │  (Controllers، Gateways)
├────────────────────────────────────┤
│  Application Business Rules        │  (Use Cases)
├────────────────────────────────────┤
│  Enterprise Business Rules         │  (Entities، Domain Services)
├────────────────────────────────────┤
│  Dependency Inversion              │  (Port/Adapter)
└────────────────────────────────────┘

اصول

۱. وابستگی‌ها تنها به داخل اشاره کنند:

  • Frameworks → Adapters → Use Cases → Entities
  • نه برعکس!

۲. منطق تجاری (Entities) از Framework مستقل است.

۳. استفاده از Dependency Injection برای وارونگی وابستگی.

مثال

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// Layer 4: Entities (مستقل کاملاً)
public class Order
{
    public Guid Id { get; private set; }
    public decimal Total { get; private set; }

    public void ConfirmOrder()
    {
        // منطق خالص
    }
}

// Layer 3: Use Cases (منطق اپلیکیشن)
public class ConfirmOrderUseCase
{
    private readonly IOrderRepository _repository; // Abstraction
    private readonly IPaymentProcessor _payment; // Abstraction

    public void Execute(Guid orderId)
    {
        var order = _repository.GetById(orderId);
        var result = _payment.Process(order);
        
        if (result.IsSuccess)
            order.ConfirmOrder();
        
        _repository.Save(order);
    }
}

// Layer 2: Adapters (تفاصیل فنی)
public class SqlOrderRepository : IOrderRepository
{
    public void Save(Order order) { /* SQL */ }
}

public class StripePaymentProcessor : IPaymentProcessor
{
    public PaymentResult Process(Order order) { /* API */ }
}

// Layer 1: Framework (کنترلرها)
[ApiController]
public class OrderController
{
    private readonly ConfirmOrderUseCase _useCase;

    [HttpPost("{orderId}/confirm")]
    public IActionResult Confirm(Guid orderId)
    {
        _useCase.Execute(orderId);
        return Ok();
    }
}

معماری مناسب برای DDD

سوال: کدام معماری برای DDD بهترین است؟

پاسخ: معمولاً Layered Architecture (با هوشیاری) یا Hexagonal Architecture.

ترکیب بهینه

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──────────────────────────────┐
│   Presentation Layer         │  (Controllers، API)
├──────────────────────────────┤
│   Application Layer          │  (Use Cases، Commands/Queries)
├──────────────────────────────┤
│   Domain Layer               │  (Aggregates، Value Objects،
│                              │   Domain Services)
├──────────────────────────────┤
│   Infrastructure Layer       │  (Repositories، Event Store،
│                              │   External Services)
└──────────────────────────────┘
        ↓
    Ports (Interfaces تعریف شده در Domain)
        ↓
Adapters (پیاده‌سازی در Infrastructure)

Folder Structure برای DDD + Layered Architecture

یک ساختار فولدر پیشنهادی برای یک پروژه .NET:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
MyDomain.Solution/
│
├── MyDomain.Domain/                (Domain Layer)
│   ├── Aggregates/
│   │   ├── Order/
│   │   │   ├── Order.cs
│   │   │   ├── OrderLineItem.cs
│   │   │   └── OrderStatus.cs
│   │   └── Customer/
│   │       ├── Customer.cs
│   │       └── CustomerId.cs
│   │
│   ├── ValueObjects/
│   │   ├── Money.cs
│   │   ├── Email.cs
│   │   └── Address.cs
│   │
│   ├── DomainServices/
│   │   ├── MoneyTransferService.cs
│   │   └── OrderCreationService.cs
│   │
│   ├── DomainEvents/
│   │   ├── OrderCreated.cs
│   │   ├── OrderConfirmed.cs
│   │   └── IDomainEvent.cs
│   │
│   └── Ports/ (Abstractions)
│       ├── IOrderRepository.cs
│       ├── IEmailService.cs
│       └── IEventStore.cs
│
├── MyDomain.Application/        (Application Layer)
│   ├── Services/
│   │   ├── CreateOrderService.cs
│   │   └── ConfirmOrderService.cs
│   │
│   ├── DTOs/
│   │   ├── CreateOrderRequest.cs
│   │   └── OrderResponse.cs
│   │
│   ├── Handlers/
│   │   ├── CommandHandlers/
│   │   │   └── CreateOrderCommandHandler.cs
│   │   └── QueryHandlers/
│   │       └── SearchOrdersQueryHandler.cs
│   │
│   └── CQRS/
│       ├── Commands/
│       │   └── CreateOrderCommand.cs
│       └── Queries/
│           └── SearchOrdersQuery.cs
│
├── MyDomain.Infrastructure/     (Infrastructure Layer)
│   ├── Repositories/
│   │   ├── SqlOrderRepository.cs
│   │   └── OrderSearchRepository.cs
│   │
│   ├── EventStore/
│   │   └── InMemoryEventStore.cs
│   │
│   ├── Adapters/
│   │   ├── EmailAdapter/
│   │   │   └── GmailEmailService.cs
│   │   └── PaymentAdapter/
│   │       └── StripePaymentProcessor.cs
│   │
│   ├── Projections/
│   │   ├── OrderSearchProjection.cs
│   │   └── OrderAnalyticsProjection.cs
│   │
│   └── Configuration/
│       └── DependencyInjection.cs
│
├── MyDomain.API/                (Presentation Layer)
│   ├── Controllers/
│   │   ├── OrdersController.cs
│   │   └── CustomersController.cs
│   │
│   ├── Middleware/
│   │   └── ErrorHandlingMiddleware.cs
│   │
│   └── Startup.cs
│
└── MyDomain.Tests/              (Tests)
    ├── Domain.Tests/
    │   └── OrderTests.cs
    ├── Application.Tests/
    │   └── CreateOrderServiceTests.cs
    └── Integration.Tests/
        └── OrderWorkflowTests.cs

Cross-Cutting Concerns (نگرانی‌های عرضی)

بعضی مسائل تمام لایه‌ها را تأثیر می‌دهند:

  • Logging (ثبت رویدادات)
  • Exception Handling (مدیریت خطا)
  • Caching (حافظه پنهان)
  • Security (احراز هویت، مجوز)
  • Performance Monitoring (نظارت عملکرد)

حل: Middleware یا Aspect-Oriented Programming (AOP)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Middleware: برای کل Request Pipeline
public class LoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<LoggingMiddleware> _logger;

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();
        
        _logger.LogInformation($"Request: {context.Request.Method} {context.Request.Path}");
        
        await _next(context);
        
        stopwatch.Stop();
        _logger.LogInformation($"Response: {context.Response.StatusCode} in {stopwatch.ElapsedMilliseconds}ms");
    }
}

// AOP: برای متدهای خاص
[Aspect]
public class CachingAspect
{
    [Around]
    public object InterceptCacheable(object target, MethodInfo method, object[] args)
    {
        var cacheKey = $"{method.Name}:{string.Join(":", args)}";
        
        if (_cache.TryGetValue(cacheKey, out var cached))
            return cached;
        
        var result = method.Invoke(target, args);
        _cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
        return result;
    }
}

پاسخ کوتاه: چون ارسال ایمیل بخشی از “قراردادِ” دامنه است، نه جزئیات فنی آن.

بیایید عمیق‌تر بررسی کنیم:

۱. اصل وارونگی وابستگی (DIP)

اگر IEmailService را در لایه Infrastructure بگذاریم، لایه Domain باید به Infrastructure وابسته شود تا بتواند ایمیل بفرستد. این غلط است.

لایه Domain باید مستقل باشد. نباید بداند SMTP چیست، یا SendGrid چیست، یا جیمیل چیست.

اما Domain ممکن است نیاز داشته باشد که پیامی بفرستد (مثلاً برای تایید سفارش).

پس Domain می‌گوید:

“من نمی‌دانم ایمیل چطور کار می‌کند، اما می‌دانم که نیاز دارم یک تاییدیه سفارش بفرستم.”

بنابراین، Domain قرارداد (Interface) را تعریف می‌کند:

1
2
3
4
5
// Domain Layer
public interface IEmailService 
{
    void SendOrderConfirmation(Order order);
}

و Infrastructure اجرا می‌کند:

1
2
3
4
5
6
7
// Infrastructure Layer
public class SendGridEmailService : IEmailService 
{
    public void SendOrderConfirmation(Order order) {
        // وصل شدن به سرور SendGrid و ارسال واقعی...
    }
}

۲. زبان مشترک (Ubiquitous Language)

ارسال ایمیل تاییدیه سفارش، یک نیاز بیزینسی است. کارفرما می‌گوید: “وقتی مشتری خرید کرد، باید برایش تاییدیه بفرستیم”.

پس متد SendOrderConfirmation بخشی از زبان دامنه است. اما پروتکل SMTP یا پورت ۲۵ بخشی از زبان دامنه نیست.

۳. تفاوت با Application Service

ممکن است بپرسید: “چرا IEmailService را در لایه Application نمی‌گذاریم؟”

پاسخ: معمولاً بهتر است در Application باشد، مگر اینکه منطق دامنه مستقیماً به آن نیاز داشته باشد.

رویکرد ۱: ایمیل در Application (رایج‌تر و بهتر)

در ۹۰٪ موارد، Aggregate نباید ایمیل بفرستد. Aggregate فقط وضعیت خود را تغییر می‌دهد و یک رویداد (OrderConfirmed) تولید می‌کند.

سپس یک EventHandler در لایه Application (یا یک Process Manager) آن رویداد را می‌گیرد و ایمیل را می‌فرستد.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Domain Layer
public class Order {
    public void Confirm() {
        Status = Confirmed;
        AddEvent(new OrderConfirmed(Id)); // فقط رویداد تولید می‌کند
    }
}

// Application Layer
public class OrderConfirmedHandler {
    private readonly IEmailService _emailService; // اینجاست!

    public void Handle(OrderConfirmed evt) {
        _emailService.SendOrderConfirmation(...);
    }
}

در این رویکرد، IEmailService می‌تواند در لایه Application تعریف شود چون Domain اصلاً آن را صدا نمی‌زند.

رویکرد ۲: ایمیل در Domain (موارد خاص)

اگر منطق بیزینس شما طوری است که ارسال پیام بخشی جدایی‌ناپذیر از فرآیند دامنه است (مثلاً تولید کد OTP و ارسال آن برای لاگین)، آن‌وقت Domain Service ممکن است نیاز داشته باشد آن را صدا بزند.

در این حالت، اینترفیس حتماً باید در Domain باشد.

جمع‌بندی

  1. اگر Aggregate مستقیماً نیاز به سرویس خارجی دارد (که توصیه نمی‌شود)، اینترفیس باید در Domain باشد.
  2. اگر (مانند اکثر موارد) ارسال ایمیل یک Side Effect بعد از تغییر وضعیت است، بهتر است اینترفیس در Application باشد و توسط Event Handler فراخوانی شود.

در مثالی که در متن قبلی بود، نویسنده برای نشان دادن “Hexagonal Architecture” و مفهوم پورت‌ها، آن را در Domain گذاشت تا نشان دهد چگونه Domain می‌تواند به بیرون وصل شود بدون اینکه وابسته شود. اما در پیاده‌سازی مدرن DDD، رویکرد Event-Driven (رویکرد ۱) ترجیح داده می‌شود.


پاسخ کوتاه این است: این چک‌ها چون نیاز به دسترسی به دیتابیس (داده‌های بیرونی) دارند، معمولاً در Application Layer (یا یک Domain Service) انجام می‌شوند، نه داخل خودِ Entity.

بیایید دقیق بررسی کنیم:

۱. چک کردن “آیا ایمیل قبلاً ثبت شده؟” (Uniqueness)

این یک قانون بیزینسی است (“ایمیل باید یکتا باشد”)، اما اجرای آن نیاز به دیدن کل دیتابیس دارد.

چرا در Entity (مثلاً User) نمی‌شود؟

چون کلاس User فقط از خودش خبر دارد. نمی‌داند کاربر دیگری در دیتابیس هست یا نه. ما هم نمی‌توانیم کل کاربران را در سازنده User لود کنیم!

راه حل ۱: در لایه Application (روش رایج و پراگماتیک)

سرویس اپلیکیشن قبل از اینکه اصلاً به دامین برسد، دیتابیس را چک می‌کند.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Application Layer (RegisterUserUseCase)
public void Execute(string email) 
{
    // 1. چک کردن با دیتابیس (وظیفه اپلیکیشن/زیرساخت)
    if (_userRepository.Exists(email)) 
    {
        throw new DuplicateEmailException();
    }

    // 2. حالا که مطمئنیم، دامین را صدا می‌زنیم
    var user = new User(email); 
    _userRepository.Save(user);
}

راه حل ۲: در Domain Service (روش سخت‌گیرانه DDD)

اگر خیلی اصرار دارید که این قانون حتماً در لایه دامین باشد، باید یک سرویس دامین بسازید.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Domain Layer (UserRegistrationService)
public class UserRegistrationService 
{
    private readonly IUserRepository _repo;

    public User Register(string email) 
    {
        // چک کردن داخل دامین سرویس
        if (_repo.Exists(email)) 
            throw new DuplicateEmailException();

        return new User(email);
    }
}

نتیجه: هرگز این چک را درون کلاس User (Entity) ننویسید.

۲. چک کردن “آیا کالا موجود است؟” (Availability)

این مورد کمی متفاوت است چون “موجودی” معمولاً یک عدد درون Aggregate مربوط به Inventory یا Product است.

اشتباه: چک کردن در لایه Application با if/else

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ Application Layer (غلط)
public void PlaceOrder(Guid productId, int count) 
{
    var product = _repo.GetById(productId);

    // این لاجیک بیزینس است و نباید اینجا باشد!
    // اپلیکیشن نباید بداند موجودی چطور کار می‌کند
    if (product.StockQuantity < count) 
        throw new Exception("Out of stock");

    product.StockQuantity -= count; // تغییر مستقیم فیلد غلط است
    _repo.Save(product);
}

درست: چک کردن در لایه Domain (داخل Aggregate)

خودِ Aggregate باید مسئول موجودی خودش باشد.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ✅ Domain Layer (Product Aggregate)
public class Product : AggregateRoot 
{
    public int StockQuantity { get; private set; }

    public void DecreaseStock(int quantity) 
    {
        // چک کردن (Validation) داخل دامین انجام می‌شود
        if (this.StockQuantity < quantity) 
        {
            throw new DomainException("Not enough stock available.");
        }

        this.StockQuantity -= quantity;
    }
}

// ✅ Application Layer
public void PlaceOrder(Guid productId, int count) 
{
    var product = _repo.GetById(productId);
    
    // اپلیکیشن فقط دستور می‌دهد، چک کردن با دامین است
    product.DecreaseStock(count); 
    
    _repo.Save(product);
}

جمع‌بندی: قانون تشخیص

برای اینکه بفهمید یک چک (Validation) باید کجا باشد، این سوال را بپرسید:

“آیا برای انجام این چک، نیاز دارم به دیتابیس یا سرویس‌های خارجی وصل شوم؟”

۱. بله (نیاز به بیرون دارم):

  • مثل: ایمیل تکراری، نام کاربری تکراری.
  • جایگاه: لایه Application (یا Domain Service).

۲. خیر (داده‌ها در حافظه خودم هست):

  • مثل: موجودی منفی نشود، تاریخ تولد معتبر باشد، قیمت بیشتر از صفر باشد.
  • جایگاه: لایه Domain (درون Entity یا Value Object).

فصل ۸: الگوهای معماری - بخش دوم

یکپارچه‌سازی و بهترین‌روش‌ها (Best Practices)

حالا که با اجزا آشنا شدیم، بیایید ببینیم چطور همه این‌ها را به یک “سیستم زنده و کارآمد” تبدیل کنیم. اینجا ۱۰ قانون طلایی برای معماری پروژه‌های DDD آورده شده است.

۱. قانون مرزهای تراکنش (Transactional Boundaries)

یکی از رایج‌ترین اشتباهات معماران، بزرگ کردن محدوده تراکنش است.

اشتباه: تراکنش سراسری (Global Transaction)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Application Service
public void PlaceOrder(OrderRequest req)
{
    using (var tx = _db.BeginTransaction()) 
    {
        // 1. ذخیره سفارش
        _orderRepo.Save(order);
        
        // 2. کاهش موجودی (Aggregate دیگر)
        _inventoryRepo.DecreaseStock(item);
        
        // 3. افزایش امتیاز وفاداری (Aggregate سوم)
        _loyaltyRepo.AddPoints(customer);
        
        tx.Commit(); // خیلی بزرگ و کند!
    }
}

درست: تراکنش‌های کوچک و Eventual Consistency

  • قانون: هر درخواست وب باید دقیقاً یک Aggregate را تغییر دهد.
  • بقیه تغییرات باید از طریق Domain Events و به صورت غیرهمزمان (Async) انجام شود.
1
2
3
4
5
6
7
8
9
10
11
12
// 1. تراکنش اول (Order)
_orderRepo.Save(order); // پایان درخواست وب

// 2. در پس‌زمینه (Event Handler)
public void Handle(OrderCreated evt) {
    _inventoryRepo.DecreaseStock(evt.Items);
}

// 3. در پس‌زمینه دیگر
public void Handle(OrderCreated evt) {
    _loyaltyRepo.AddPoints(evt.CustomerId);
}

۲. قانون وابستگی لایه‌ها (Dependency Rule)

هیچ کدی در لایه‌های داخلی نباید به لایه‌های بیرونی ارجاع دهد.

  • غلط: Domain.Entities.User از Application.DTOs.UserDto استفاده کند.
  • غلط: Domain.Services از Infrastructure.EmailSender استفاده کند (مگر از طریق Interface).
  • غلط: Application از Web.Controllers بداند.

چطور چک کنیم؟ در .NET می‌توانید از ابزارهایی مثل NetArchTest استفاده کنید تا در Unit Testها این قوانین را چک کنید:

1
2
3
4
var result = Types.InAssembly(DomainAssembly)
    .ShouldNot()
    .HaveDependencyOn("Application")
    .GetResult();

۳. استفاده از DTO (Data Transfer Objects)

هرگز موجودیت‌های دامین (Domain Entities) را مستقیماً به کلاینت (API) نفرستید.

چرا؟

  1. امنیت: ممکن است فیلدهای حساس (PasswordHash) لو برود.
  2. تغییرپذیری: اگر دامین عوض شود، API کلاینت‌ها می‌شکند.
  3. Circular Reference: سریالایز کردن Aggregateها معمولاً باعث حلقه بی‌پایان می‌شود (Order -> Customer -> Orders -> …).

درست:

همیشه یک لایه تبدیل (Mapping) داشته باشید:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Domain
public class Order { ... }

// API (DTO)
public class OrderResponse 
{
    public Guid Id { get; set; }
    public decimal Total { get; set; }
    // فقط فیلد هایی که کلاینت نیاز دارد
}

// Controller
var order = _service.GetOrder(id);
return Ok(_mapper.Map<OrderResponse>(order));

۴. اعتبارسنجی: کجای سیستم؟

اعتبارسنجی (Validation) باید در چندین لایه انجام شود، اما با اهداف متفاوت:

  1. UI/Presentation:
    • هدف: تجربه کاربری (UX).
    • مثال: “فرمت ایمیل غلط است”، “فیلد خالی است”.
    • ابزار: FluentValidation، DataAnnotations.
  2. Application:
    • هدف: سازگاری با دیتابیس و منطق Use Case.
    • مثال: “آیا ایمیل قبلاً ثبت شده؟”، “آیا کالا موجود است؟”.
  3. Domain:
    • هدف: محافظت از Invariantها.
    • مثال: “تعداد سفارش نمی‌تواند منفی باشد”، “تاریخ پایان نباید قبل از شروع باشد”.
    • نکته: دامین باید همیشه معتبر باشد. حتی اگر UI را دور بزنند، دامین نباید اجازه ساخت آبجکت ناقص بدهد.

۵. مدیریت استثناها (Exception Handling)

چطور خطاها را به کاربر نشان دهیم؟

روش غلط: try-catch در همه‌جا

کد پر از بلوک‌های try-catch می‌شود و ناخوانا می‌گردد.

روش درست: Global Exception Handler

یک Middleware در لایه API بسازید که همه خطاها را می‌گیرد و ترجمه می‌کند.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Domain Exception
public class InsufficientFundsException : DomainException { ... }

// Middleware
public async Task Invoke(HttpContext context)
{
    try 
    {
        await _next(context);
    }
    catch (DomainException ex)
    {
        // خطای بیزینسی -> 400 Bad Request
        context.Response.StatusCode = 400;
        await context.Response.WriteAsJsonAsync(new { Error = ex.Message });
    }
    catch (Exception ex)
    {
        // خطای فنی -> 500 Internal Server Error
        _logger.Error(ex);
        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(new { Error = "خطای سرور" });
    }
}

۶. Microservices vs Monolith

آیا باید از همان روز اول Microservice بسازیم؟ خیر!

بهترین استراتژی برای DDD: Modular Monolith.

Modular Monolith چیست؟

  • یک پروژه واحد (یک Deployable Unit).
  • اما کدها دقیقاً بر اساس Bounded Contextها جدا شده‌اند (فولدرهای جدا، حتی اسمبلی‌های جدا).
  • ارتباط بین ماژول‌ها فقط از طریق Public Interface است.
1
2
3
4
5
6
7
8
Solution
├── Context.Sales (Module)
│   ├── Domain
│   ├── Application
│   └── Infrastructure
├── Context.Shipping (Module)
│   ├── Domain ...
└── Context.Payment (Module)

مزیت: هر وقت نیاز به Scale داشتید، می‌توانید راحت یک ماژول را بکنید و به Microservice تبدیل کنید. اما پیچیدگی شبکه و توزیع‌شده را از روز اول ندارید.

۷. تست‌نویسی: هرم تست (Testing Pyramid)

در معماری DDD، توزیع تست‌ها باید اینطور باشد:

  1. Unit Tests (۷۰٪):
    • تست کردن Aggregateها، Value Objectها و Domain Services.
    • سریع، بدون دیتابیس، بدون Mock پیچیده.
    • “آیا منطق بیزینس درست کار می‌کند؟”
  2. Integration Tests (۲۰٪):
    • تست کردن Application Services با دیتابیس واقعی (معمولاً in-memory یا Docker).
    • “آیا داده درست ذخیره و بازیابی می‌شود؟”
  3. E2E / API Tests (۱۰٪):
    • تست کردن از طریق فراخوانی API.
    • “آیا کل سیستم از بیرون درست کار می‌کند؟”

۸. استراتژی دیتابیس

آیا باید دیتابیس مشترک داشته باشیم؟

  • قانون: هر Bounded Context باید صاحبِ داده‌های خودش باشد (Schema جداگانه یا حتی دیتابیس جداگانه).
  • ممنوع: Context A نباید به جداول Context B جوین (JOIN) بزند.
  • اگر نیاز به داده‌های مشترک دارید -> از Event Replication یا API استفاده کنید.

۹. استفاده از کتابخانه‌های آماده (MediatR)

در دات‌نت، کتابخانه MediatR پیاده‌سازی الگوی CQRS و ارتباط بین لایه‌ها را بسیار تمیز می‌کند.

  • کنترلرها هیچ وابستگی به سرویس‌ها ندارند، فقط یک Command یا Query به Mediator می‌فرستند.
1
2
3
4
5
6
// Controller
public async Task<IActionResult> CreateOrder(CreateOrderCommand cmd)
{
    var orderId = await _mediator.Send(cmd);
    return Ok(orderId);
}

این باعث می‌شود لایه API بسیار نازک (Thin) شود و منطق کاملاً در Application متمرکز گردد.

۱۰. مستندسازی زنده (Living Documentation)

کد شما باید گویای معماری باشد.

  • نام‌گذاری‌ها باید دقیقاً طبق Ubiquitous Language باشد.
  • ساختار فولدرها باید نشان‌دهنده Bounded Contextها باشد.
  • استفاده از ابزارهایی مثل Swagger برای API و C4 Model برای نمودارهای معماری.

جمع‌بندی نهایی کتاب

ما سفر طولانی‌ای داشتیم:

  1. از تحلیل کسب‌وکار و کشف Domain شروع کردیم.
  2. با Bounded Contextها سیستم را تکه تکه کردیم.
  3. با Aggregateها و Value Objectها منطق را ساختیم.
  4. با Domain Events و CQRS پیچیدگی را مدیریت کردیم.
  5. و در نهایت با Layered Architecture همه را منظم کردیم.

پیام نهایی: DDD یک “دستورالعمل سخت” نیست؛ یک “طرز تفکر” است.

  • هدف نوشتن کد زیبا نیست؛ هدف حل کردن مشکلات پیچیده بیزینس است.
  • ابزارها (Microservices, Event Sourcing, CQRS) فقط ابزارند. هر جا لازم نیستند، استفاده نکنید.
  • همیشه، همیشه، همیشه با کارشناس دامنه (Domain Expert) صحبت کنید. کد شما باید بازتابِ ذهنِ او باشد.

پایان کتاب “Learning Domain-Driven Design”.