Architecture & Design · معماری و طراحی سنیورSenior ~44 دقیقه مطالعه~39 min read
طراحی دامنهمحور (DDD)Domain-Driven Design
یاد میگیری DDD قبل از هر کلاس جاوایی، دعوا سر «زبان» و «مرز» است: چطور دامنه را به زمینههای محدود بشکنی، زبان همهفهم بسازی، و با شیء مقداری و تجمیع و رویداد دامنه، مدلی صادق با مرزهای سازگاری کوچک بنویسی.You will learn that DDD is a fight about language and boundaries long before it is about Java classes: how to split a domain into bounded contexts, forge a ubiquitous language, and use value objects, aggregates, and domain events to keep your model honest and your consistency boundaries small.
بیا رُک شروع کنیم: خیلیها فکر میکنند DDD یعنی یک مشت کلاس جاوا با اسمهای قشنگ (Entity, Aggregate, Repository) که روی یک اپ معمولی میپاشی. این بزرگترین سوءتفاهم دربارهی DDD است. در این فصل قرار است از صفر بسازیمش — نه با پرتاب اصطلاح، بلکه با تشبیه، با کد واقعی، و با توضیح تکتک واژهها. آخر فصل باید بتوانی هم در پروژهی واقعی درست تصمیم بگیری، هم در مصاحبهی سنیور از پسِ سختترین سؤالها بربیایی.
ما دو نیمه را جدا یاد میگیریم:
- DDD راهبردی (strategic) — بخش «باربر». اینجا دربارهی زبان و مرز حرف میزنیم: دامنه، زیردامنه، زبان همهفهم، زمینهی محدود، نگاشت زمینه، و لایهی ضدفساد.
- DDD تاکتیکی (tactical) — «ابزارها». درون یک زمینه چطور مدل غنی بسازیم: شیء مقداری، موجودیت، تجمیع و ناورداییها، رویداد دامنه، مخزن، سرویس دامنه، کارخانه. بعد میرویم سراغ نگاشت به دیتابیس (JPA/CQRS)، اینکه DDD کجا صرفه دارد و کجا زیادهروی است، دامهای رایج، و در پایان ۱۵ سؤال مصاحبه با پاسخ کامل. هرچه جلوتر میرویم عمیقتر میشویم؛ عجله نکن.
بخش ۰ — واژههایی که باید حس کنی
قبل از هر چیز، سه واژه را با تشبیه بشکافیم تا بقیهی فصل روان شود.
تصور کن یک کارخانهی خودروسازی داری. دامنه (domain) کل کاری است که کارخانه انجام میدهد. مدل (model) نقشهای است که تیم بر سرش توافق کرده — «موتور اینجاست، گیربکس آنجا». مرز (boundary) دیوار بین سالن رنگ و سالن مونتاژ است؛ درون هر سالن قوانین و زبان خودش حاکم است. DDD میگوید ارزش واقعی در «نقشهی مشترک» و «دیوارهای درست» است، نه در پیچومهرهها (کلاسهای جاوا).
سه واژهی کلیدی که مدام برمیگردند:
- ناوردایی (invariant): قاعدهای که همیشه باید درست باشد — مثل «موجودی حساب هرگز منفی نمیشود». مثل قانون فیزیک درون سیستمت.
- سازگاری (consistency): اینکه دادهها با هم بخوانند و قاعده نقض نشود.
- تراکنش (transaction): یک بستهی «همه یا هیچ» از تغییرات؛ یا کامل انجام میشود یا اصلاً انجام نمیشود (rollback).
اینها را همینجا حس کن؛ در ادامه بارها لازمشان داریم.
مدل ذهنی: DDD دربارهی زبان و مرزهاست، نه پوشهها
طراحی دامنهمحور (DDD) را اریک اونز (Eric Evans) در کتاب Domain-Driven Design (۲۰۰۳) معرفی کرد و وان ورنون (Vaughn Vernon) در Implementing Domain-Driven Design (۲۰۱۳) پالایشش کرد. ادعای مرکزیاش ساده بهنظر میرسد ولی در عمل سخت است:
بزرگترین گلوگاه در نرمافزار پیچیده، فناوری نیست — اتلاف ترجمه (translation loss) میان خبرگان دامنه و کد است.
بگذار «اتلاف ترجمه» را باز کنم، چون قلب همهچیز است.
همان بازی کودکی: یک جمله را در گوش نفر اول میگویی، تا نفر دهم برسد شده یک چیز دیگر. در نرمافزار، خبرهی بیمه میگوید «بیمهنامه bound شد»، تحلیلگر مینویسد «وضعیت تأیید»، و برنامهنویس کد میزند setStatus(2). هر پله یک تکه از معنا گم میشود. این «اتلاف ترجمه» است — و در دامنههای پیچیده، همین باگسازترین چیز است، نه کندی CPU.
DDD به این اتلاف حمله میکند با سه حرکت: (۱) مدل کسبوکار را به مصنوع اصلی تبدیل میکند، (۲) آن را در قالب زبان همهفهم (ubiquitous language) — واژگان مشترک بین توسعهدهنده و خبره — بیان میکند، و (۳) این زبان را با مرزهای صریح در کد اعمال میکند. هرچیز دیگری — موجودیت، شیء مقداری، تجمیع — فقط ماشینآلات تاکتیکی در خدمت همین هدف است.
اگر الگوها را یاد بگیری اما کارِ زبان و مرز را نادیده بگیری، به کدی میرسی که پر از کلاسهای Aggregate و Repository است ولی هیچ بینشی اضافه نکرده — فقط تشریفات بیشتر. این «Cargo Cult DDD» است. برای همین این فصل، بخش راهبردی را باربر میداند و تاکتیکی را ابزار.
DDD دو نیمه دارد:
| سطح | دغدغه | خروجیها |
|---|---|---|
| راهبردی (strategic) | تقسیم مسئله؛ همراستاسازی تیمها و مدلها | زیردامنه، زمینهی محدود، نقشهی زمینه، زبان همهفهم |
| تاکتیکی (tactical) | مدلسازی رفتار درون یک زمینه؛ اعمال ناوردایی | شیء مقداری، موجودیت، تجمیع، رویداد دامنه، مخزن، سرویس دامنه، کارخانه |
DDD راهبردی
دامنه و زیردامنههایش
دامنه (domain) کل فضای مسئلهای است که کسبوکارت در آن فعالیت میکند — مثلاً «خردهفروشی آنلاین». تقریباً هیچوقت آن را یکنواخت مدل نمیکنی؛ میشکنیاش به زیردامنه (subdomain). و نکتهی طلایی این است: زیردامنهها را بر اساس ارزش راهبردی دستهبندی میکنی، چون همین تعیین میکند انرژی مهندسیات را کجا خرج کنی.
یک رستوران را در نظر بگیر. دستور پختِ اختصاصی سرآشپز چیزی است که مشتری برایش میآید — این «هسته» است و خودت با بهترین آدمهایت میسازیاش. چیدمان میزها لازم است ولی متمایزکننده نیست — «پشتیبان»؛ خوب انجامش بده ولی ساده. برق و لولهکشی مسئلهی حلشده است — «عمومی»؛ نمیروی خودت سیمکشی اختراع کنی، از برقکار میخری.
سه دسته:
- دامنهی هستهای (core domain) — بخشی که مزیت رقابتی میسازد؛ دلیل وجود کسبوکار (مثلاً موتور امتیازدهی تقلب برای یک شرکت پرداخت). این را خودت بساز، با بهترین افرادت و DDD غنی.
- زیردامنهی پشتیبان (supporting) — لازم اما متمایزکننده نیست (مثلاً قابلیت یادداشت مشتری). بساز، اما ساده؛ DDD سبک کافی است.
- زیردامنهی عمومی (generic) — مسئلهای حلشده (احراز هویت، صورتحساب، تولید PDF). بخر یا آماده بگیر؛ با عشق دستی نساز.
باارزشترین تصمیم راهبردی، شناسایی دامنهی هستهای و هدر ندادن انرژی مدلسازی روی زیردامنههای عمومی است. تیمی که یک سیستم هویتِ خانگیِ دستساز میسازد در حالی که هستهی واقعیاش (مثلاً مسیریابی لجستیک) نحیف مانده، کمیابترین منبعش را بد تخصیص داده: توجه.
زبان همهفهم
زبان همهفهم (ubiquitous language) واژگانی دقیق و مشترک است — اسمها، فعلها و قواعد کسبوکار — که بهطور یکسان توسط خبرگان دامنه، در گفتوگو، در تستها و در کد به کار میرود. «ubiquitous» یعنی «همهجا حاضر»: همان کلمه در جلسه، در تست و در کلاس جاوا.
اگر خبره میگوید «یک بیمهنامه وقتی bound میشود که ارزیابی ریسک تأییدش کند»، آنگاه کدت این است:
policy.bind(); // زبان خبره، مستقیم در کد
// نه:
policy.setStatus(2); // معنا گم شده — «۲» یعنی چه؟
قاعدهی ساده: زبان مبهم ⇐ مدل مبهم. اگر نتوانی رفتار را با یک فعل از زبان خبره نامگذاری کنی، هنوز آن را نفهمیدهای.
واژهی «Account» برای تیم هویت یعنی «اعتبارنامهی ورود» و برای تیم مالی یعنی «ماندهی دفتر کل». این دو معنا واقعاً فرق دارند. تلاش برای تحمیل یک کلاس Account جهانی روی هر دو، همان اشتباه کلاسیکی است که مدلی متورم و خودمتناقض میسازد. زبان همهفهم به یک زمینهی محدود مقید است، نه به کل سازمان.
زمینههای محدود
زمینهی محدود (bounded context) مرزی صریح است که درون آن یک مدل خاص — و زبان همهفهمش — سازگار و کامل است. درون مرز، هر واژه دقیقاً یک معنا دارد. میان مرزها، یک واژه میتواند معناهای متفاوت داشته باشد، و این اشکالی ندارد — دقیقاً همین مرز است که به هر تیم اجازه میدهد مدلش را مستقل تکامل دهد.
به مبلفروش بگو «صندلی» به یک چیز فکر میکند؛ به دندانپزشک بگو «صندلی» به چیز کاملاً دیگری. هیچکس گیج نمیشود، چون هر کلمه درون «مغازهی» خودش معنا دارد. زمینهی محدود همان دیوار مغازه است: تضمین میکند درون آن، «Order» یا «Customer» دقیقاً یک چیز است.
یک زمینهی محدود معمولاً نگاشت میشود به:
- حوزهی مالکیت یک تیم،
- اغلب یک سرویس قابلاستقرار (در میکروسرویس) یا یک ماژول/پکیج (در مونولیت ماژولار)،
- یک اسکیمای دیتابیس که فقط خودش مالکش است.
اینجا یک نکتهی مصاحبهایِ حساس است: رابطهی «زمینهی محدود» و «میکروسرویس» یکبهیک نیست. زمینهی محدود مرز مدلسازی/زبانی است؛ میکروسرویس مرز استقرار (deployment). یک مونولیت خوشماژول میتواند چند زمینهی محدود را میزبانی کند. اول زمینهها را درست تعیین کن، بعد توپولوژی استقرار را — چون شکستن به سرویسها روی خطوط اشتباه، بسیار گرانتر از ادغام پکیجهاست.
به این نمودار دقت کن — یک واژه، دو زمینه، دو معنا:
زمینهی فروش زمینهی ارسال
┌───────────────────────┐ ┌──────────────────────────┐
│ Customer │ │ Recipient │
│ Order (draft→placed) │─────▶│ Shipment (packed→sent) │
│ Product (قیمت،تخفیف) │ │ Package (وزن،ابعاد) │
└───────────────────────┘ └──────────────────────────┘
«Order» = قصد خرید اینجا «Order» وجود ندارد؛
فقط یک Shipment با آیتمها
میبینی که «Order» در سمت راست اصلاً وجود ندارد — فقط یک Shipment هست. هر تیم زبان خودش را دارد.
نگاشت زمینه (context mapping)
حالا که زمینهها را جدا کردی، باید بگویی چطور به هم وصل میشوند. نقشهی زمینه (context map) مستند میکند که زمینهها چگونه مرتبطاند — هم فنی و هم سازمانی.
«قانون کانوی (Conway's Law)» میگوید ساختار سیستمی که میسازی، آینهی ساختار ارتباطی تیمهایت است. اگر دو تیم کم حرف میزنند، مرز بینشان در کد هم سفتوسخت میشود. پس نقشهی زمینه فقط فنی نیست — ساختار تیم به معماری «نشت» میکند و باید آگاهانه همراستایش کنی.
الگوهای استاندارد نگاشت زمینه:
| الگو | معنا |
|---|---|
| Partnership | دو زمینه با هم موفق یا شکست میخورند؛ تیمها نزدیک هماهنگ میشوند. |
| Shared Kernel | دو زمینه زیرمجموعهی کوچک و مشترکاً-مالک از مدل را به اشتراک میگذارند. قدرتمند اما تیمها را جفت میکند؛ تغییر نیاز به رضایت طرفین دارد. |
| Customer–Supplier | پاییندست (مشتری) بر بکلاگ بالادست (تأمینکننده) نفوذ واقعی دارد. |
| Conformist | پاییندست مدل بالادست را همانطور که هست میپذیرد (بدون نفوذ، بدون ترجمه). ارزان، اما نقصهای بالادست را ارث میبری. |
| Anticorruption Layer (ACL) | پاییندست لایهی ترجمه میسازد که مدل بالادست را به مدل تمیزِ خودش نگاشت میکند و از مفاهیم بیگانه محافظت میشود. |
| Open Host Service / Published Language | بالادست پروتکلی مستند و پایدار (مثلاً اسکیمای REST/رویداد نسخهدار) برای مصرفکنندگان متعدد عرضه میکند. |
| Separate Ways | یکپارچهسازی ارزشش را ندارد؛ زمینهها جدا میمانند. |
«بالادست (upstream)» یعنی زمینهای که داده/مدل را تولید میکند؛ «پاییندست (downstream)» یعنی زمینهای که مصرفش میکند — مثل رودخانه: آب از بالادست به پاییندست میرود.
لایهی ضدفساد (Anticorruption Layer)
از میان همهی الگوها، ACL شایستهی تأکید ویژه است، چون بیشترین کاربرد را دارد و بیشتر از همه نادیده گرفته میشود.
سفیری را تصور کن که با کشوری بیگانه مذاکره میکند. هرگز مستقیم به زبان آنها فکر نمیکند و اجازه نمیدهد اصطلاحات و رسوم آنها ذهنش را آلوده کند؛ یک مترجم میایستد وسط و حرفها را به زبان خودش برمیگرداند. لایهی ضدفساد همان مترجم است: بین دامنهی تمیز تو و مدل شلختهی سیستم بیرونی میایستد.
وقتی با یک سیستم قدیمی (legacy)، یک API شخص ثالث، یا مدل شلختهی تیم دیگری یکپارچه میشوی، نگذار مفاهیمشان به دامنهات نشت کند. آداپتوری بساز که واژگان آنها را به واژگان تو ترجمه کند. این مدل هسته را تمیز نگه میدارد و اجازه میدهد بعداً سیستم بیرونی را عوض کنی بدون اینکه دامنهات به هم بریزد.
// مفهوم تمیز دامنهی ما
public record CreditScore(int value) {
public CreditScore {
if (value < 300 || value > 850)
throw new IllegalArgumentException("FICO خارج از بازه: " + value);
}
public boolean isPrime() { return value >= 660; }
}
// ارائهدهندهی بیرونی زبانی متفاوت و شلختهتر دارد.
// این لایهی ضدفساد است: مدل «آنها» را به مدل «ما» ترجمه میکند.
public class BureauAntiCorruptionLayer {
private final LegacyBureauClient legacyClient; // SDK فروشنده، واژگان بیگانه
public CreditScore fetchScore(SSN ssn) {
// فروشنده انبوهی فیلد با مقیاس ۰..۱۰۰ و کدهای وضعیت برمیگرداند
BureauResponse raw = legacyClient.query(ssn.masked());
if (!"OK".equals(raw.getStatusFlag()))
throw new ScoreUnavailableException(raw.getStatusFlag());
// ترجمهی مقیاس ۰..۱۰۰ فروشنده به FICO خودمان ۳۰۰..۸۵۰ — ریاضی مرزی «اینجا»ست، نه در دامنه
int fico = 300 + Math.round(raw.getNormalizedScore() / 100f * 550);
return new CreditScore(fico);
}
}
دقت کن: تمام «کثیفی» — کدهای وضعیت "OK"، مقیاس عجیب ۰ تا ۱۰۰، فیلدهای درهم — درون ACL حبس شده. دامنهی تو فقط یک CreditScore تمیز میبیند. فردا اگر فروشنده را عوض کنی، فقط این کلاس تغییر میکند، نه کل دامنه.
DDD تاکتیکی
حالا درون یک زمینهی محدود طراحی میکنیم. الگوهای تاکتیکی، بلوکهای سازندهی یک مدل دامنهی غنی (rich domain model) هستند.
دو جور سیستم را تصور کن. در مدل غنی، هر شیء مثل یک کارمند باهوش است: هم داده دارد هم میداند با آن داده چه کند و چه قواعدی را باید رعایت کند («من اجازه نمیدهم موجودیام منفی شود»). در مدل نحیف (anemic)، اشیاء فقط پروندههای بیجاناند — مشتی getter/setter — و تمام عقل در کلاسهای «سرویس» بیرون نشسته. مدل نحیف در DDD یک ضدالگو است، چون ناورداییها را پخشوپلا میکند و همان شکاف ترجمه را دوباره باز میکند.
شیء مقداری (value object)
شیء مقداری چیزی را مدل میکند که کاملاً با صفاتش تعریف میشود و هویتی ندارد.
دو اسکناس ۵ دلاری کاملاً قابلتعویضاند — برایت مهم نیست کدام را داری، چون فقط «مقدار»شان مهم است، نه «کدام»شان. این شیء مقداری است. حالا کارت شناساییات را با کارت یک نفر دیگر که دقیقاً همان نام و تاریخ تولد را دارد مقایسه کن — اینها قابلتعویض نیستند، چون هرکدام یک «هویت» جداست. آن دومی، موجودیت است (که بعداً میبینیم).
ویژگیهای شیء مقداری:
- تغییرناپذیر (immutable) — پس از ساخت هرگز جهش نمیکند؛ «تغییر» یعنی نمونهی جدید ساختن.
- برابری بر اساس مقدار —
equals/hashCodeروی همهی صفات. - خوداعتبارسنج — نمیتواند در حالت نامعتبر وجود داشته باشد.
- رفتار بدون عارضهی جانبی — متدها مقدار جدید برمیگردانند، جهش نمیدهند.
بیشتر قواعد دامنه باید در شیء مقداری زندگی کنند. چرا؟ چون سطح خطا را کوچک میکنند: یک Money یا EmailAddress نامعتبر اصلاً ساخته نمیشود، پس هرگز لازم نیست جای دیگری چکش کنی. record جاوا (نهاییشده در جاوا ۱۶) گزینهی عالی است؛ از سازندهی فشرده (compact constructor) برای اعتبارسنجی استفاده کن — سازندهی فشرده تکه کدی است که پیش از انتساب فیلدها اجرا میشود و نقش «دروازهبان» را دارد.
public record Money(BigDecimal amount, Currency currency) {
// سازندهی فشرده: پیش از انتساب فیلدها اجرا میشود — دروازهی اعتبارسنجی
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
if (amount.scale() > currency.getDefaultFractionDigits())
throw new IllegalArgumentException("رقم اعشار بیش از حد برای " + currency);
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency); // نمونهی «جدید» برمیگرداند
}
public Money multiply(int factor) {
return new Money(amount.multiply(BigDecimal.valueOf(factor)), currency);
}
public boolean isNegative() { return amount.signum() < 0; }
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency))
throw new CurrencyMismatchException(currency, other.currency);
}
}
به رفتار معنادارِ دامنه (add، multiply، isNegative) دقت کن — این «کارمند باهوش» بودن است. و مهمتر: جمع دو ارز متفاوت ناممکن است (استثنا پرتاب میشود)، نه اینکه بیسروصدا غلط شود. record برابری مقداری و تغییرناپذیری را رایگان به تو میدهد.
رکوردها فقط سطحی (shallow) تغییرناپذیرند. یک record Range(List<Integer> points) هنوز میتواند از طریق لیستِ درونش جهش کند، چون فیلد فقط یک ارجاعِ final به همان لیست تغییرپذیر است. اگر تغییرناپذیری واقعی میخواهی، اجزای تغییرپذیر را در سازندهی فشرده بهصورت تدافعی کپی کن: List.copyOf(points). (این دقیقاً موضوع سؤال مصاحبهی ۱۴ است — یادت باشد.)
موجودیت (entity)
موجودیت (entity) یک هویت (identity) دارد که در طول تغییر پایدار میماند.
تو از پنجسالگی تا حالا کاملاً عوض شدهای — قد، وزن، اسم فامیل شاید، حتی سلولهای بدنت. ولی هنوز «همان» آدمی، چون یک هویت پایدار داری (بگو شمارهی ملی). موجودیت هم همین است: Customer حتی پس از تغییر نام و آدرس و ایمیل، همان مشتری است.
پس برابری موجودیت بر اساس هویت (شناسه) است، نه صفات — دو شیء Customer برابرند اگر و فقط اگر شناسهشان برابر باشد، صرفنظر از مقدار فعلی فیلدها. موجودیتها تغییرپذیرند (صفاتشان در طول زمان عوض میشود)، اما هر جهش باید از متدهایی بگذرد که ناورداییها را حفظ میکنند.
public class Customer {
private final CustomerId id; // هویت — یکبار تخصیص مییابد، هرگز عوض نمیشود
private EmailAddress email; // صفات میتوانند عوض شوند
private CustomerName name;
// equals/hashCode فقط بر اساس id
@Override public boolean equals(Object o) {
return o instanceof Customer c && id.equals(c.id);
}
@Override public int hashCode() { return id.hashCode(); }
}
- شیء مقداری: بدون هویت، تغییرناپذیر، برابری با مقدار (همهی صفات). مثل
Money. - موجودیت: با هویت پایدار، تغییرپذیر، برابری با شناسه. مثل
Customer. هر وقت گیر کردی از خودت بپرس: «اگر دو تای این با صفات یکسان داشتم، آیا قابلتعویضاند؟» بله ⟵ شیء مقداری. نه ⟵ موجودیت.
تجمیع، ریشهی تجمیع، و ناورداییها
این عمیقترین و پرسوءاستفادهترین مفهوم کل DDD است، پس آهسته بخوان.
یک سفارش رستوران را تصور کن: یک برگهی سفارش با چند خط غذا. تو (مشتری) مستقیم نمیروی توی آشپزخانه یک خط اضافه کنی — از پیشخدمت میخواهی. پیشخدمت مطمئن میشود سفارش هنوز باز است، تعداد منطقی است، جمع از سقف نمیگذرد. برگهی سفارش + خطهایش = تجمیع؛ پیشخدمت = ریشهی تجمیع؛ و آن قواعد («سفارشِ ارسالشده را نمیشود تغییر داد») = ناورداییها. هیچکس اجازه ندارد از پیشخدمت رد شود.
حالا با زبان دقیق: تجمیع (aggregate) خوشهای از موجودیتها و اشیای مقداری است که بهعنوان یک واحد واحد برای تغییر داده و سازگاری رفتار میشود. یک موجودیت ریشهی تجمیع (aggregate root) است — تنها عضوی که کد بیرونی مجاز به ارجاع به آن است. اشیای بیرونی فقط ارجاع به ریشه دارند؛ هرگز نباید به یک عضو داخلی ارجاع نگه دارند. تمام دسترسی و اصلاح از طریق ریشه انجام میشود، که ناورداییهای (invariants) تجمیع را اعمال میکند — قواعدی که باید در مرز سازگاری، در پایان هر تراکنش، درست باشند.
چهار قاعدهی ورنون برای طراحی تجمیع — اینها را حفظ کن، چون سؤال مصاحبهی ۴ دقیقاً همین است:
- ناورداییهای واقعی را در مرزهای سازگاری مدل کن. دقیقاً آنچه باید فوری و تراکنشی سازگار بماند را در یک تجمیع بگذار. اگر دو داده باید همیشه در لحظهی commit با هم بخوانند، به یک تجمیع تعلق دارند.
- تجمیعهای کوچک طراحی کن. تجمیع بزرگ (مثلاً
Customerشامل همهی سفارشهایش) رقابت بر سر قفل، تراکنشهای غولآسا و تورم حافظه ایجاد میکند. تجمیعهای کوچکِ متعدد را ترجیح بده. - به تجمیعهای دیگر با هویت (شناسه) ارجاع بده، نه با ارجاع شیء. یک
OrderیکCustomerIdنگه میدارد، نه یکCustomer. این تجمیعها را غیرجفت نگه میدارد، تراکنش را کوچک نگه میدارد و اجازه میدهد هر تجمیع مستقل بار/ذخیره شود. - بین تجمیعها از سازگاری نهایی (eventual consistency) استفاده کن. هرچیز بیرون مرز تجمیع بهصورت ناهمگام (معمولاً از طریق رویداد دامنه) بهروز میشود، نه در همان تراکنش. یک تراکنش باید دقیقاً یک نمونهی تجمیع را اصلاح کند. این قاعدهای است که بیشتر مردم نقض میکنند.
چون مرز تجمیع همان مرز سازگاری تراکنشیِ توست. اگر بگذاری یک تراکنش دو تجمیع را جهش دهد، دوباره جفتشان میکنی، رقابت قفل و بنبست (deadlock) دعوت میکنی، و استخراج شاردینگ/میکروسرویس را در آینده ناممکن میسازی. «سازگاری نهایی (eventual consistency)» یعنی «سازگار در عرض یک ثانیه، نه دقیقاً همین لحظه» — و این تقریباً همیشه برای کسبوکار کافی است. جایی که واقعاً کافی نیست، همان نشانهای است که آن دو چیز در واقع یک تجمیعاند.
اینجا یک تجمیع کامل که ناورداییهایش را اعمال میکند — با دقت بخوان که ترتیب بررسی و جهش چطور است:
// ریشهی تجمیع
public class Order {
private final OrderId id;
private final CustomerId customerId; // ارجاع به تجمیع «دیگر» با شناسه (قاعده ۳)
private OrderStatus status;
private final List<OrderLine> lines; // موجودیتهای داخلی — هرگز با ارجاع افشا نمیشوند
private Money total;
private final List<DomainEvent> events = new ArrayList<>();
private static final Money MAX_ORDER = new Money(new BigDecimal("10000"), Currency.getInstance("USD"));
public static Order place(OrderId id, CustomerId customerId) {
Order o = new Order(id, customerId);
o.status = OrderStatus.DRAFT;
o.total = Money.zero(Currency.getInstance("USD"));
return o;
}
// تنها راه افزودن یک خط — ناورداییها اینجا، بهصورت اتمیک، اعمال میشوند
public void addLine(ProductId product, int qty, Money unitPrice) {
// ناوردایی ۱: سفارش ثبتشده قابل اصلاح نیست
if (status != OrderStatus.DRAFT)
throw new OrderNotModifiableException(id, status);
// ناوردایی ۲: تعداد باید مثبت باشد
if (qty <= 0)
throw new IllegalArgumentException("qty باید > 0 باشد");
Money newTotal = total.add(unitPrice.multiply(qty));
// ناوردایی ۳: جمع نباید از سقف بگذرد — «پیش از» جهش حالت بررسی میشود
if (newTotal.isGreaterThan(MAX_ORDER))
throw new OrderLimitExceededException(newTotal, MAX_ORDER);
lines.add(new OrderLine(product, qty, unitPrice));
this.total = newTotal; // حالت فقط پس از عبور همهی بررسیها عوض میشود
}
public void place() {
if (status != OrderStatus.DRAFT)
throw new IllegalStateException("قبلاً ثبت شده");
// ناوردایی ۴: سفارش برای ثبت باید حداقل یک خط داشته باشد
if (lines.isEmpty())
throw new EmptyOrderException(id);
this.status = OrderStatus.PLACED;
// هرچیز «بیرون» این تجمیع از طریق رویداد و در تراکنش «جدا» واکنش میدهد (قاعده ۴)
events.add(new OrderPlaced(id, customerId, total, Instant.now()));
}
// نمای «تغییرناپذیر» افشا کنید — فراخواننده میخواند، اما نمیتواند ریشه را دور بزند
public List<OrderLine> lines() { return Collections.unmodifiableList(lines); }
public List<DomainEvent> pullEvents() {
List<DomainEvent> copy = List.copyOf(events);
events.clear();
return copy;
}
}
سه چیز را در این کد به خاطر بسپار:
- ناورداییها پیش از هر جهش حالت بررسی میشوند، پس یک عملیات ردشده تجمیع را دستنخورده میگذارد (بدون جهش جزئی).
- مجموعه فقط بهصورت نمای تغییرناپذیر (
unmodifiableList) افشا میشود — کسی نمیتواند از پشت ریشه، خطی اضافه کند. - اثرات بینتجمیعی (کاهش موجودی انبار، اعطای امتیاز وفاداری) از طریق رویداد
OrderPlacedرخ میدهند، نه درونخطی و نه در همین تراکنش.
رویدادهای دامنه (domain events)
رویداد دامنه (domain event) ثبتِ چیزی معنادار که در دامنه رخ داده است، همیشه با فعل زمان گذشته: OrderPlaced، PaymentCaptured، ShipmentDispatched. اینها اشیای مقداریِ تغییرناپذیرند.
تجمیع مثل یک اتفاق در شهر است؛ رویداد دامنه مثل تیتر روزنامهی فردا صبح: «سفارش ثبت شد». روزنامه اتفاق را عوض نمیکند، فقط اعلامش میکند تا هرکس علاقه دارد (انبار، واحد وفاداری، ایمیل تأیید) واکنش نشان دهد. مهم این است: روزنامه بعد از وقوع اتفاق چاپ میشود، نه وسطش.
رویدادهای دامنه سازوکار اصلیِ سازگاری نهایی بین تجمیعها و جداسازی (decoupling) مدل هسته از عوارض جانبیاند. تجمیع رویداد را «بلند میکند» (raise)؛ یک توزیعکننده در لایهی اپلیکیشن آن را پس از commit تراکنش منتشر میکند؛ هندلرها در همان زمینه یا زمینههای دیگر واکنش میدهند.
public record OrderPlaced(OrderId orderId, CustomerId customerId,
Money total, Instant occurredOn) implements DomainEvent {}
در اسپرینگ، روش اصطلاحی استفاده از ApplicationEventPublisher بههمراه @TransactionalEventListener(phase = AFTER_COMMIT) است تا انتشار پس از commit انجام شود — بنابراین هیچ هندلری هرگز به تغییری که بعداً rollback میشود واکنش نمیدهد:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderPlaced event) {
// فقط اگر تراکنش سفارش واقعاً commit شده باشد اجرا میشود
inventoryService.reserve(event.orderId()); // تراکنش خودش، تجمیع خودش
}
یک دام ظریف: وقتی میخواهی هم در دیتابیس بنویسی و هم به بروکر پیام (Kafka/RabbitMQ) پیام بفرستی، این دو نوشتن اتمیک نیستند — ممکن است دیتابیس commit شود ولی ارسال پیام شکست بخورد. این «مسئلهی دو-نوشتن» است. راهحل، الگوی outbox تراکنشی است: رویداد را در همان تراکنش دیتابیسِ تجمیع، در جدول outbox بنویس؛ بعد یک poller جدا آن ردیف را میخواند و به بروکر میرساند. این تحویل «حداقل-یک-بار (at-least-once)» را تضمین میکند و شکاف دو-نوشتن را میبندد.
مخزنها (repositories)
مخزن (repository) توهمِ یک مجموعهی درونحافظهای از ریشههای تجمیع را میسازد. یعنی به کدت اجازه میدهد طوری با تجمیعها کار کند که انگار همه در یک List توی حافظهاند، در حالی که پشتصحنه دارند از دیتابیس بار/ذخیره میشوند.
قواعد کلیدی:
- یک مخزن برای هر ریشهی تجمیع — نه برای هر جدول، نه برای هر موجودیت.
- واسطش به لایهی دامنه تعلق دارد و به زبان همهفهم صحبت میکند؛ پیادهسازی در زیرساخت (infrastructure) زندگی میکند.
- مخزنها کل تجمیع را بار و ذخیره میکنند، هرگز اجزای داخلیِ جزئی را.
// لایهی دامنه — یک واسط، بیانشده با اصطلاحات دامنه
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order); // «کل» تجمیع را ذخیره میکند
List<Order> findOpenOrdersFor(CustomerId customer);
}
دو اشتباه رایج: (۱) ساختن مخزن برای موجودیت غیرریشه — مثلاً OrderLineRepository. غلط است؛ خطوط سفارش فقط از طریق Order قابلدسترسیاند. (۲) نشتِ مفاهیم query/ORM مثل findByStatusAndTotalGreaterThan. این مخزن را به یک DAO نازک (Data Access Object — شیء خام دسترسی به داده) تبدیل میکند و زبان همهفهم را میفرساید. مخزن باید به زبان کسبوکار حرف بزند (findOpenOrdersFor)، نه به زبان SQL.
سرویسهای دامنه (domain services)
گاهی تکهای از منطق دامنه بهطور طبیعی به هیچ موجودیت یا شیء مقداری واحدی تعلق ندارد — معمولاً چون چند تجمیع را هماهنگ میکند یا یک محاسبهی بدون حالت است. آن را بهعنوان سرویس دامنه (domain service) مدل کن.
انتقال پول بین دو حساب به کدام حساب «تعلق» دارد؟ به هیچکدام — کارِ «انتقال» بین آن دوست. پس یک صرافی (سرویس دامنه) را تصور کن که وسط میایستد و عملیات را هماهنگ میکند. خودش نه پول نگه میدارد نه حساب دارد؛ فقط یک فعالیت است.
سرویس دامنه هویت و حالت ندارد؛ به نام یک فعالیت در زبان همهفهم نامیده میشود (FundsTransferService، PricingService). آن را با سرویس اپلیکیشن (application service) اشتباه نگیر:
- سرویس دامنه = منطق کسبوکار که در یک موجودیت جا نمیگیرد.
- سرویس اپلیکیشن = هماهنگکنندهی use-case که تراکنشها را مدیریت و مخزنها را صدا میزند (هماهنگسازی/orchestration، نه قاعدهی کسبوکار).
// دو تجمیع Account را هماهنگ میکند — به هیچکدام تعلق ندارد، پس یک سرویس دامنه است.
public class MoneyTransferService {
public void transfer(Account from, Account to, Money amount) {
from.withdraw(amount); // هر تجمیع ناورداییهای خودش را اعمال میکند
to.deposit(amount);
// نکته: سرویس «اپلیکیشن» دامنهی تراکنش را تصمیم میگیرد؛ در حالت ایدهآل اینها
// دو تجمیعاند که با رویداد آشتی میکنند، نه یک تراکنش دو-تجمیعی.
}
}
کارخانهها (factories)
وقتی ساختن یک تجمیع پیچیده است — ناورداییهای زیاد، مونتاژ اشیای داخلی، انتخاب زیرنوع — یک کارخانه (factory) آن ساخت را کپسوله میکند تا یک تجمیع معتبر سازگار زاده شود.
بهجای اینکه یک شیء نیمهساخته بسازی و بعد قدمبهقدم پُرش کنی (و در این میان در حالتی نامعتبر باشد)، کارخانه تضمین میکند از همان لحظهی تولد کامل و معتبر است. کارخانه میتواند یک متد استاتیک روی خود ریشه باشد یا یک کلاس جدا. همان متد استاتیک Order.place(...) که بالاتر دیدی، یک متد کارخانه است.
نگاشت تجمیعها به پایداری (persistence)
اینجا یک تنش بنیادی وجود دارد: مدل رابطهای (جدولها و ردیفها) و مدل تجمیع (اشیای تودرتوی رفتارمند) با هم میجنگند. DDD این را عدمتطابق مقاومت شیء-رابطهای (object-relational impedance mismatch) مینامد — مثل وقتی میخواهی یک شکل گرد را در سوراخی چهارگوش جا بدهی.
دو رویکرد:
- ایدهآل: بیاطلاعی از پایداری (persistence ignorance). اشیای دامنه هیچ annotation از ORM ندارند؛ یک لایهی نگاشت جدا ترجمه میکند. مدل ناب میماند اما هزینهی boilerplate دارد.
- عملگرایانه: JPA روی موجودیتها، با پذیرش مقداری جفتشدگی. اگر این را انتخاب کردی، مرزهای تجمیع را رعایت کن: از ریشه به اجزای داخلی
@OneToMany(cascade = ALL, orphanRemoval = true)تا تجمیع بهعنوان یک واحد ذخیره/حذف شود؛ اما هرگز یک association از JPA از یک ریشهی تجمیع به ریشهی دیگر نگاشت نکن — بهجایش شناسهی خارجی را بهعنوان یک مقدار ساده (CustomerId) ذخیره کن.
@Entity
public class Order {
@EmbeddedId private OrderId id;
// موجودیت داخلی: cascade تا کل تجمیع اتمیک ذخیره شود
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id")
private List<OrderLine> lines = new ArrayList<>();
// تجمیع «دیگر»: فقط شناسه را ذخیره کن — «نه» @ManyToOne Customer
@Embedded private CustomerId customerId;
}
اگر از یک ریشه به ریشهی دیگر association بزنی، JPA وسوسه میشود با «lazy-loading» (بارگذاری تنبل — یعنی هر وقت به فیلد دست زدی، آنموقع از دیتابیس میآورد) بهطور تصادفی مرز تجمیع را رد کند و نیمی از دیتابیس را به یک تراکنش بکشد. ذخیرهی شناسه بهعنوان مقدار ساده، این پیمایشهای خطرناک را از ریشه میبندد.
اشیای مقداری بهطور طبیعی به @Embeddable/@Embedded در JPA (ستونهای درونخطی) نگاشت میشوند، یا در جاوا به انواع مبتنی بر record از طریق AttributeConverter. و برای queryهای خواندنی-سنگین که چند تجمیع را در بر میگیرند، مدل دامنه را پیچوتاب نده — از CQRS استفاده کن.
CQRS مخفف «تفکیک مسئولیت فرمان و پرسوجو (Command Query Responsibility Segregation)» است: سمت نوشتن (command) از تجمیع و ناورداییها استفاده میکند؛ سمت خواندن (query) یک مدل جدا و ساده دارد — DTOهای تخت، یا حتی SQL/view خام — که فقط برای نمایش سریع است. اینطور تجمیع را با نیازهای گزارشگیری آلوده نمیکنی.
DDD کجا صرفه دارد — و کجا زیادهروی است
بگذار صادق باشیم: DDD رایگان نیست. مدلسازی غنی، انضباط زبان همهفهم و نگهداری مرزها زمان و انرژی میبرد. پس کِی ارزشش را دارد؟
صرفه دارد وقتی:
- دامنه واقعاً پیچیده است — قواعد کسبوکار غیربدیهی، لبههای زیاد، ناورداییهایی که تکامل مییابند (بیمه، معاملات، لجستیک، صورتحساب سلامت).
- سیستم بلندعمر است و توسط تیمهای چرخشی نگهداری میشود.
- به خبرگان دامنه دسترسی داری تا زبان همهفهم را بسازی — بدون آنها DDD نهفقط سخت، بلکه ناممکن است.
زیادهروی است وقتی:
- اپ اساساً CRUD است — فرم روی داده با قواعد نازک. سبک transaction-script یا active-record ارزانتر و روشنتر است؛ تحمیل تجمیع فقط تشریفات میافزاید بدون ناورداییای برای محافظت.
- ابزاری کوتاهعمر، نمونهی اولیه یا دورانداختنی است.
- زیردامنه عمومی است — چیزی را که باید بخری با عشق مدل نکن.
لازم نیست همهجا یا هیچجا. حرفهایها اینطور جیرهبندی میکنند: DDD تاکتیکیِ کامل در دامنهی هسته؛ DDD سبک (هنوز زمینهی محدود + زبان همهفهم، اما درونیات سادهتر) در زیردامنههای پشتیبان؛ راهحل آماده برای عمومیها. DDD راهبردی (زمینهها، زبان، دستهبندی زیردامنه) تقریباً همیشه ارزان و ارزشمند است؛ الگوهای تاکتیکیِ سنگین همان بخشیاند که باید جیرهبندی کنی.
دامها و نکات ظریف رایج
- مدل دامنهی نحیف. getter/setter + کلاسهای «سرویس» که تمام منطق را نگه میدارند. فقط به اسم OOP است؛ ناورداییها پراکنده و شکاف ترجمه دوباره باز میشود.
- تجمیعهای خیلی بزرگ. یک
Customerکه همهی سفارشها را دارد ⇐ رقابت قفل، تراکنشهای غولآسا، انفجار حافظه. تجمیعهای کوچک با ارجاع با شناسه را ترجیح بده. - اصلاح چند تجمیع در هر تراکنش. دوباره جفتشان میکند و بنبست دعوت میکند. یک تجمیع در هر تراکنش؛ بینتجمیعی از طریق رویداد + سازگاری نهایی.
- افشای مجموعههای داخلی.
return lines;اجازه میدهد فراخواننده ناورداییهای ریشه را دور بزند. نمای تغییرناپذیر برگردان. - زمینهی محدود = میکروسرویس، همیشه. نه. زمینه زبانی/مدلسازی است؛ سرویس استقرار. اول زمینهها را درست کن.
- رد کردن ACL و گذاشتن اینکه مدل فروشنده/قدیمی به هستهات خونریزی کند.
- مخزنهایی که DAO هستند. آشِ شلهقلمکارِ متدهای query زبان همهفهم را میفرساید و پایداری را به دامنه نشت میدهد.
- DDD همهجا، حتی زیردامنههای عمومی و CRUD. تخصیص بد تلاش؛ دام کلاسیک over-engineering.
- نادیده گرفتن قانون کانوی. مرزهای زمینه که مالکیت تیم را قطع میکنند فرسوده میشوند؛ همراستایشان کن.
سؤالات مصاحبه
هر سؤال را در یک بلوک جدا آوردهام. اول خودت جواب بده، بعد پاسخ را باز کن.
واژگانی دقیق و مشترک که بهطور یکسان توسط خبرگان دامنه و در کد/تست به کار میرود و به یک زمینهی محدود مقید است. مرکزی است چون هزینهی غالب در نرمافزار پیچیده، اتلاف ترجمه میان خبره و توسعهدهنده است؛ مدل همان زبان است، پس policy.bind() بر setStatus(2) برتری دارد. زبان مبهم ⇐ مدل مبهم. مقید به زمینه است، نه سراسری — یک واژه در زمینههای مختلف معناهای مختلف دارد.
موجودیت هویتی پایدار دارد که از تغییر صفات جان بهدر میبرد؛ برابری با شناسه. شیء مقداری کاملاً با صفاتش تعریف میشود، هویت ندارد، تغییرناپذیر است، و برابری با مقدار (همهی صفات). Customer موجودیت است؛ Money/EmailAddress شیء مقداری. بیشتر ناورداییها را در شیء مقداری بگذار چون نمونهی نامعتبرش ساخته نمیشود.
تجمیع خوشهای از موجودیت/شیء مقداری است که بهعنوان یک واحد برای سازگاری و پایداری رفتار میشود. ریشه تنها نقطهی ورود است: کد بیرونی فقط به ریشه ارجاع میدهد و تمام جهش از آن میگذرد تا ناورداییها در یک جا، اتمیک، اعمال شوند. مرز سازگاری تراکنشی را تعریف میکند.
(۱) ناورداییهای واقعی را در مرزهای سازگاری مدل کن — آنچه باید تراکنشی سازگار بماند را گروه کن. (۲) تجمیعهای کوچک طراحی کن. (۳) به تجمیعهای دیگر با هویت (شناسه) ارجاع بده، نه ارجاع شیء. (۴) تجمیعهای دیگر را با سازگاری نهایی (رویداد دامنه) بهروز کن، یک تجمیع در هر تراکنش.
مرز تجمیع مرز سازگاری است. جهش دو تجمیع در یک تراکنش دوباره جفتشان میکند، رقابت قفل/بنبست ایجاد میکند، تراکنش را متورم میکند و شاردینگ یا استخراج سرویس بعدی را مسدود میکند. اگر دو چیز باید فوری سازگار باشند، این نشانهی آن است که واقعاً یک تجمیعاند؛ وگرنه از رویداد استفاده و سازگاری نهایی را بپذیر (معمولاً برای کسبوکار کافی است).
نه. زمینهی محدود مرز زبانی/مدلسازی است که درونش یک مدل سازگار است؛ میکروسرویس مرز استقرار است. یک مونولیت ماژولار میتواند چند زمینهی محدود را میزبانی کند. اول زمینهها را تصمیم بگیر، بعد توپولوژی استقرار را — شکستن سرویس روی خطوط اشتباه بازگرداندنش بسیار گران است.
لایهی ترجمه میان زمینهی تو و مدل بیرونی/قدیمی/شخصثالث که واژگان آنها را به مدل تمیز تو نگاشت میکند تا مفاهیم بیگانه به هسته نشت نکنند. هر وقت با مدلی که کنترلش نمیکنی یکپارچه میشوی و میخواهی دامنهات ناب و قابلتعویض بماند بسازش.
هستهای = متمایزکنندهی رقابتی ⇐ با بهترین افراد و DDD غنی بساز. پشتیبان = لازم اما نامتمایزکننده ⇐ ساده بساز. عمومی = مسئلهی حلشده (احراز هویت، صورتحساب) ⇐ بخر/بگیر. برد راهبردی یعنی خرج تلاش مدلسازی روی هسته و نساختن دستی زیردامنههای عمومی.
ثبت تغییرناپذیرِ چیزی معنادار که رخ داده، زمان گذشته (OrderPlaced). سازگاری نهایی را میراند و عوارض جانبی را جدا میکند. پس از commit منتشر کن (@TransactionalEventListener(AFTER_COMMIT)) تا هندلر هرگز به تغییری که بعداً rollback میشود واکنش ندهد. برای تحویل حداقل-یک-بار بین سرویسها، با outbox تراکنشی جفت کن تا مسئلهی دو-نوشتن حل شود.
سرویس دامنه منطق کسبوکارِ بدون حالت را نگه میدارد که در یک موجودیت/شیء مقداری جا نمیگیرد (اغلب هماهنگکنندهی تجمیعها)، نامیدهشده در زبان همهفهم. سرویس اپلیکیشن هماهنگکنندهی use-case است: تراکنش را مدیریت، از مخزن بار/ذخیره و اشیای دامنه را صدا میزند. قواعد کسبوکار در سرویس دامنه؛ هماهنگسازی در سرویس اپلیکیشن.
وقتی دامنه اساساً CRUD با قواعد نازک است، سیستم کوتاهعمر/دورانداختنی است، یا زیردامنه عمومی است. بدون ناوردایی واقعی برای محافظت و بدون خبره برای گفتوگو، تجمیع فقط تشریفات بیفایده میافزاید. بهجایش transaction-script/active-record یا DDD گزینشی (غنی در هسته، سبک در جاهای دیگر) به کار ببر.
از ریشه به موجودیتهای داخلی cascade کن (@OneToMany(cascade=ALL, orphanRemoval=true)) تا تجمیع اتمیک ذخیره/حذف شود؛ ارجاع به تجمیعهای دیگر را بهعنوان مقدار شناسهی ساده (CustomerId) ذخیره کن، هرگز بهعنوان association از @ManyToOne، تا پیمایشهای lazy از مرزها عبور نکنند. اشیای مقداری را @Embeddable نگاشت کن. برای خواندنهای بینتجمیعی، از CQRS با مدل خواندنی جدا استفاده کن نه تحریف تجمیع.
public List<OrderLine> getLines() { return lines; } // لیست داخلی زنده را برمیگرداند
public void addLine(OrderLine l) { total = total.add(l.subtotal()); lines.add(l); }
دو باگ. اول، getLines() مجموعهی داخلی زنده را برمیگرداند، پس فراخواننده میتواند order.getLines().add(x) بزند و کل ریشه را دور بزند — total هرگز بهروز نمیشود و هر بررسی ناوردایی رد میشود. Collections.unmodifiableList(lines) برگردان. دوم، addLine قبل از افزودن خط total را جهش میدهد و هیچ بررسی ناوردایی ندارد؛ اگر بعداً اعتبارسنجیای (مثلاً سقف سفارش) اضافه شود، ترتیب مهم است — همیشه اول اعتبارسنجی، بعد جهش، تا عملیات ردشده تجمیع را دستنخورده بگذارد.
record Basket(List<String> items) {}
var list = new ArrayList<>(List.of("a"));
var b = new Basket(list);
list.add("b");
System.out.println(b.items().size());
عدد ۲ را چاپ میکند. یک record فقط سطحی تغییرناپذیر است — فیلد items یک ارجاع final به همان ArrayList تغییرپذیر است. جهش list پس از ساخت، نمای basket را جهش میدهد. برای یک شیء مقداری واقعی، در سازندهی فشرده تدافعی کپی کن: Basket { items = List.copyOf(items); }.
ضدالگوست چون داده (موجودیتهای احمق) را از رفتار (کلاسهای سرویس) جدا میکند و ناورداییها را پراکنده میسازد، پس هیچ شیء واحدی حالت معتبر را تضمین نمیکند — و شکاف ترجمهی خبره-به-کد که DDD برای بستنش وجود دارد دوباره باز میشود. برای اپهای واقعیِ CRUD/قواعد-نازک قابلقبول (حتی ترجیحداده) است، جایی که سبک transaction-script سادهتر است و ناوردایی کمی برای محافظت وجود دارد؛ تحمیل تجمیع غنی آنجا over-engineering است. ملاک، پیچیدگی دامنه است، نه جزماندیشی.
- DDD قبل از هرچیز دربارهی زبان و مرز است، نه پوشهها. دشمن اصلی، اتلاف ترجمه بین خبره و کد است.
- راهبردی (باربر): دامنه را به زیردامنه بشکن و بر اساس ارزش دستهبندی کن (هستهای/پشتیبان/عمومی). درون هر زمینهی محدود یک زبان همهفهم بساز. زمینهها را با نقشهی زمینه (Partnership، Shared Kernel، Conformist، ACL، …) به هم وصل کن. ACL دامنهات را از مدلهای بیگانه محافظت میکند.
- زمینهی محدود ≠ میکروسرویس. یکی مرز زبانی است، دیگری مرز استقرار.
- تاکتیکی (ابزار): شیء مقداری (بدون هویت، تغییرناپذیر، برابری با مقدار) بیشتر قواعد را میگیرد؛ موجودیت (هویت پایدار، برابری با شناسه) در طول تغییر میماند. تجمیع مرز سازگاری توست: از ریشه رد نشو، ناورداییها را پیش از جهش چک کن، به تجمیع دیگر با شناسه ارجاع بده، یک تجمیع در هر تراکنش، و بین تجمیعها رویداد دامنه + سازگاری نهایی.
- رویداد را پس از commit منتشر کن (
AFTER_COMMIT) و برای اطمینان بین سرویسها از outbox تراکنشی استفاده کن. - مخزن توهم مجموعهی درونحافظهای است، یکی بهازای هر ریشه، به زبان دامنه — نه DAO.
- پایداری: JPA را با احترام به مرز تجمیع به کار ببر (
cascadeبه داخل، شناسهی ساده به بیرون)؛ برای خواندن، CQRS. - DDD رایگان نیست: برای دامنهی پیچیده و بلندعمر با خبرهی در دسترس صرفه دارد؛ برای CRUD و عمومی زیادهروی است. گزینشی به کارش ببر.
Let us start bluntly: many people think DDD means a pile of Java classes with pretty names (Entity, Aggregate, Repository) that you sprinkle over an ordinary app. That is the single biggest misconception about DDD. In this chapter we build it from zero — not by throwing jargon at you, but with analogies, real code, and every term unpacked. By the end you should make good decisions on a real project and survive the hardest senior-interview questions.
We learn two halves separately:
- Strategic DDD — the load-bearing part. Here we talk about language and boundaries: the domain, subdomains, ubiquitous language, bounded contexts, context mapping, and the anticorruption layer.
- Tactical DDD — the tools. How to build a rich model inside one context: value objects, entities, aggregates and invariants, domain events, repositories, domain services, factories. Then we cover persistence mapping (JPA/CQRS), where DDD pays off and where it is overkill, common pitfalls, and finally 15 interview questions with full answers. It gets deeper as we go; don't rush.
Part 0 — words you must feel
Before anything else, let us unpack three words with analogies so the rest of the chapter flows.
Picture a car factory. The domain is everything the factory does. The model is the shared blueprint the team agreed on — "engine here, gearbox there." A boundary is the wall between the paint shop and the assembly hall; inside each hall its own rules and vocabulary rule. DDD says the real value is in the shared blueprint and the right walls, not the nuts and bolts (the Java classes).
Three key words that keep coming back:
- Invariant: a rule that must always be true — like "an account balance is never negative." A law of physics inside your system.
- Consistency: data agreeing with itself so no rule is broken.
- Transaction: an all-or-nothing bundle of changes; either it all happens or none of it does (rollback).
Feel these now; we will need them repeatedly.
Mental model: DDD is about language and boundaries, not folders
Domain-Driven Design (DDD) was introduced by Eric Evans in Domain-Driven Design (2003) and refined by Vaughn Vernon in Implementing Domain-Driven Design (2013). Its central claim sounds simple but is hard in practice:
The biggest bottleneck in complex software is not technology — it is the translation loss between domain experts and code.
Let me unpack "translation loss," because it is the heart of everything.
The childhood game: you whisper a sentence to the first person, and by the tenth it is something else. In software, the insurance expert says "the policy was bound," the analyst writes "status = approved," and the developer codes setStatus(2). Each hop loses a piece of meaning. That is translation loss — and in complex domains it is the most bug-producing thing there is, not CPU speed.
DDD attacks that loss with three moves: (1) it makes the model of the business the primary artifact, (2) it expresses that model in a ubiquitous language — vocabulary shared by developer and expert — and (3) it enforces that language with explicit boundaries in the code. Everything else — entities, value objects, aggregates — is merely tactical machinery in service of that goal.
If you learn the patterns but skip the language and boundary work, you get code full of Aggregate and Repository classes that adds no insight — just more ceremony. That is "Cargo Cult DDD." This is why the chapter treats the strategic part as load-bearing and the tactical part as tools.
DDD has two halves:
| Level | Concern | Deliverables |
|---|---|---|
| Strategic | Carve the problem into pieces; align teams and models | Subdomains, bounded contexts, context map, ubiquitous language |
| Tactical | Model behavior inside one context; enforce invariants | Value objects, entities, aggregates, domain events, repositories, domain services, factories |
Strategic DDD
The domain and its subdomains
The domain is the whole problem space your business operates in — say, "online retail." You almost never model it uniformly; you break it into subdomains. And here is the golden move: you classify subdomains by their strategic value, because that dictates where to spend engineering effort.
Think of a restaurant. The chef's signature recipe is why customers come — that is the "core," and you build it yourself with your best people. The table layout is necessary but not distinguishing — "supporting"; do it well but plainly. The electrical wiring and plumbing is a solved problem — "generic"; you don't invent your own wiring, you buy an electrician.
The three classes:
- Core domain — the part that gives competitive advantage; the reason the business exists (e.g. a fraud-scoring engine for a payments company). Build this yourself, with your best people, using rich DDD.
- Supporting subdomain — necessary but not differentiating (e.g. a customer-notes feature). Build it, but plainly; DDD-lite is fine.
- Generic subdomain — a solved problem (auth, billing, PDF generation). Buy or adopt off-the-shelf; do not lovingly hand-craft it.
The single most valuable strategic decision is identifying your core domain and not wasting modeling energy on generic subdomains. A team that builds an artisanal in-house identity system while their actual core (say, logistics routing) stays anemic has misallocated its scarcest resource: attention.
Ubiquitous language
The ubiquitous language is a rigorous, shared vocabulary — nouns, verbs, and business rules — used identically by domain experts, in conversation, in tests, and in code. "Ubiquitous" means "present everywhere": the same word in the meeting, in the test, and in the Java class.
If experts say "a policy is bound when underwriting approves it," then your code is:
policy.bind(); // the expert's language, straight in code
// not:
policy.setStatus(2); // meaning lost — what does "2" mean?
The simple rule: fuzzy language ⇒ fuzzy model. If you cannot name a behavior with a verb from the expert's language, you don't understand it yet.
"Account" means "a login credential" to the Identity team and "a ledger balance" to the Finance team. These are genuinely different meanings. Forcing one universal Account class across both is the classic mistake that produces a bloated, self-contradictory model. The ubiquitous language is scoped to a bounded context, not to the whole org.
Bounded contexts
A bounded context is an explicit boundary within which a particular model — and its ubiquitous language — is consistent and complete. Inside the boundary, every term has exactly one meaning. Across boundaries, the same word may mean different things, and that is fine — the boundary is precisely what lets each team evolve its model independently.
Say "chair" to a furniture seller and they think of one thing; say "chair" to a dentist and they picture something quite different. Nobody is confused, because each word has meaning inside its own "shop." A bounded context is that shop wall: it guarantees that inside it, "Order" or "Customer" means exactly one thing.
A bounded context typically maps to:
- one team's area of ownership,
- often one deployable service (in microservices) or one module/package (in a modular monolith),
- one database schema it owns exclusively.
Here is a sharp interview point: the relationship between "bounded context" and "microservice" is not one-to-one. A bounded context is a modeling/linguistic boundary; a microservice is a deployment boundary. A well-modularized monolith can host several bounded contexts. Get the contexts right first, then decide deployment topology — because splitting into services along the wrong lines is far more expensive than merging packages.
Study this diagram — one word, two contexts, two meanings:
Sales Context Shipping Context
┌───────────────────────┐ ┌──────────────────────────┐
│ Customer │ │ Recipient │
│ Order (draft→placed) │─────▶│ Shipment (packed→sent) │
│ Product (price,promo)│ │ Package (weight,dims) │
└───────────────────────┘ └──────────────────────────┘
"Order" = a purchase intent "Order" doesn't exist here;
only a Shipment with items
Notice "Order" does not exist on the right at all — only a Shipment. Each team has its own language.
Context mapping
Now that you have separated contexts, you must describe how they connect. A context map documents how contexts relate — both technically and organizationally.
Conway's Law says the structure of the system you build mirrors the communication structure of your teams. If two teams rarely talk, the boundary between them hardens in the code too. So a context map is not purely technical — team structure "leaks" into architecture, and you must align it deliberately.
The standard context-mapping patterns:
| Pattern | Meaning |
|---|---|
| Partnership | Two contexts succeed or fail together; teams coordinate closely. |
| Shared Kernel | Two contexts share a small, jointly-owned subset of the model. Powerful but couples the teams — change requires mutual consent. |
| Customer–Supplier | Downstream (customer) has real influence over the upstream (supplier) backlog. |
| Conformist | Downstream simply accepts the upstream model as-is (no influence, no translation). Cheap, but you inherit the upstream's warts. |
| Anticorruption Layer (ACL) | Downstream builds a translation layer that maps the upstream model into its own clean model, protecting itself from foreign concepts. |
| Open Host Service / Published Language | Upstream offers a well-documented, stable protocol (e.g. a versioned REST/event schema) for many consumers. |
| Separate Ways | Integration isn't worth it; contexts stay disconnected. |
"Upstream" means the context that produces the data/model; "downstream" means the one that consumes it — like a river, water flows from upstream to downstream.
The Anticorruption Layer (ACL)
Of all the patterns, the ACL deserves special emphasis, because it is the one most often needed and most often skipped.
Picture an ambassador negotiating with a foreign country. They never think directly in the other side's language, nor let its idioms and customs pollute their mind; an interpreter stands in the middle and renders everything into their own tongue. The anticorruption layer is that interpreter: it stands between your clean domain and the external system's messy model.
When you integrate with a legacy system, a third-party API, or another team's messy model, do not let their concepts leak into your domain. Build an adapter that translates their vocabulary into yours. This keeps your core model clean and lets you swap the external system later without your domain falling apart.
// Our clean domain concept
public record CreditScore(int value) {
public CreditScore {
if (value < 300 || value > 850)
throw new IllegalArgumentException("FICO out of range: " + value);
}
public boolean isPrime() { return value >= 660; }
}
// The external provider speaks a different, messier language.
// This is the Anticorruption Layer: it translates THEIR model into OURS.
public class BureauAntiCorruptionLayer {
private final LegacyBureauClient legacyClient; // vendor SDK, foreign vocabulary
public CreditScore fetchScore(SSN ssn) {
// Vendor returns a bag of fields with a scale of 0..100 and status codes
BureauResponse raw = legacyClient.query(ssn.masked());
if (!"OK".equals(raw.getStatusFlag()))
throw new ScoreUnavailableException(raw.getStatusFlag());
// Translate vendor's 0..100 into our FICO 300..850 — the boundary math lives HERE, not in the domain
int fico = 300 + Math.round(raw.getNormalizedScore() / 100f * 550);
return new CreditScore(fico);
}
}
Notice how all the "dirt" — the "OK" status codes, the odd 0..100 scale, the tangled fields — is jailed inside the ACL. Your domain only ever sees a clean CreditScore. Swap the vendor tomorrow and only this class changes, not the whole domain.
Tactical DDD
Now we design inside one bounded context. The tactical patterns are building blocks for a rich domain model.
Picture two kinds of system. In a rich model, each object is like a smart employee: it holds data and knows what to do with it and which rules to honor ("I won't let my balance go negative"). In an anemic model, objects are lifeless files — bags of getters/setters — and all the brains sit outside in "service" classes. The anemic model is an anti-pattern in DDD because it scatters invariants and re-opens the very translation gap DDD exists to close.
Value objects
A value object models a thing defined entirely by its attributes, with no identity.
Two $5 notes are perfectly interchangeable — you don't care which one you hold, because only their value matters, not their which. That is a value object. Now compare your ID card with someone else's card that happens to have your exact name and birthdate — those are not interchangeable, because each is a separate identity. That second one is an entity (which we meet next).
Properties of a value object:
- Immutable — once created, never mutated; "changing" it means creating a new instance.
- Equality by value —
equals/hashCodeover all attributes. - Self-validating — it cannot exist in an invalid state.
- Side-effect-free behavior — methods return new values, they don't mutate.
Most of your domain rules should live in value objects. Why? Because they shrink the surface area for bugs: an invalid Money or EmailAddress simply cannot be constructed, so you never have to check for it elsewhere. Java records (finalized in Java 16) are an excellent fit; use the compact constructor to validate — a bit of code that runs before the fields are assigned, acting as a gatekeeper.
public record Money(BigDecimal amount, Currency currency) {
// Compact constructor: runs before the fields are assigned — the validation gate
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
if (amount.scale() > currency.getDefaultFractionDigits())
throw new IllegalArgumentException("too many decimals for " + currency);
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency); // returns NEW instance
}
public Money multiply(int factor) {
return new Money(amount.multiply(BigDecimal.valueOf(factor)), currency);
}
public boolean isNegative() { return amount.signum() < 0; }
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency))
throw new CurrencyMismatchException(currency, other.currency);
}
}
Notice the domain-meaningful behavior (add, multiply, isNegative) — that is the "smart employee." And crucially: adding two different currencies is impossible (it throws) rather than silently wrong. record gives you value equality and immutability for free.
Records are only shallowly immutable. A record Range(List<Integer> points) can still be mutated through its inner list, because the field is just a final reference to that same mutable list. If you want true immutability, defensively copy mutable components in the compact constructor: List.copyOf(points). (This is exactly interview question 14 — remember it.)
Entities
An entity has an identity that persists through change.
You have changed completely since age five — height, weight, maybe your last name, even the cells in your body. Yet you are still the "same" person, because you have a persistent identity (say, a national ID). An entity is the same: a Customer is still the same customer even after changing name, address, and email.
So entity equality is by identity (ID), not by attributes — two Customer objects are equal iff their IDs are equal, regardless of current field values. Entities are mutable (their attributes change over time), but every mutation must go through methods that preserve invariants.
public class Customer {
private final CustomerId id; // identity — assigned once, never changes
private EmailAddress email; // attributes can change
private CustomerName name;
// equals/hashCode based ONLY on id
@Override public boolean equals(Object o) {
return o instanceof Customer c && id.equals(c.id);
}
@Override public int hashCode() { return id.hashCode(); }
}
- Value object: no identity, immutable, equality by value (all attributes). Like
Money. - Entity: persistent identity, mutable, equality by ID. Like
Customer. When stuck, ask yourself: "If I had two of these with identical attributes, are they interchangeable?" Yes ⟶ value object. No ⟶ entity.
Aggregates, aggregate roots, and invariants
This is the deepest and most misused concept in all of DDD, so read slowly.
Picture a restaurant order: an order slip with several food lines. You (the customer) don't walk into the kitchen to add a line yourself — you ask the waiter. The waiter checks the order is still open, the quantity is sane, the total stays under the cap. The order slip + its lines = the aggregate; the waiter = the aggregate root; and those rules ("you can't change a dispatched order") = the invariants. Nobody is allowed to bypass the waiter.
Now in precise terms: an aggregate is a cluster of entities and value objects treated as a single unit for the purpose of data changes and consistency. One entity is the aggregate root — the only member outside code is allowed to reference. External objects hold a reference only to the root; they may never hold a reference to an internal member. All access and modification go through the root, which enforces the aggregate's invariants — rules that must hold true at the consistency boundary at the end of every transaction.
Vernon's four rules of aggregate design — memorize these, because interview question 4 is exactly this:
- Model true invariants in consistency boundaries. Put inside one aggregate exactly what must be kept consistent immediately, transactionally. If two pieces of data must always agree at commit time, they belong in the same aggregate.
- Design small aggregates. A large aggregate (e.g.
Customercontaining all their orders) causes lock contention, huge transactions, and memory bloat. Prefer many small aggregates. - Reference other aggregates by identity, not by object reference. An
Orderholds aCustomerId, not aCustomer. This keeps aggregates decoupled, keeps transactions small, and lets each aggregate be loaded/persisted independently. - Use eventual consistency between aggregates. Anything outside the aggregate boundary is updated asynchronously (typically via a domain event), not in the same transaction. One transaction should modify exactly one aggregate instance. This is the rule most people violate.
Because the aggregate boundary is your transactional consistency boundary. If you let a single transaction mutate two aggregates, you re-couple them, invite lock contention and deadlocks, and make later sharding/microservice extraction impossible. "Eventual consistency" means "consistent within a second, not this exact instant" — and that is almost always good enough for the business. Where it truly isn't, that's a signal the two things are actually one aggregate.
Here is a full aggregate that enforces its invariants — watch closely how the order of check and mutation works:
// AGGREGATE ROOT
public class Order {
private final OrderId id;
private final CustomerId customerId; // reference OTHER aggregate BY ID (rule 3)
private OrderStatus status;
private final List<OrderLine> lines; // internal entities — never exposed by reference
private Money total;
private final List<DomainEvent> events = new ArrayList<>();
private static final Money MAX_ORDER = new Money(new BigDecimal("10000"), Currency.getInstance("USD"));
public static Order place(OrderId id, CustomerId customerId) {
Order o = new Order(id, customerId);
o.status = OrderStatus.DRAFT;
o.total = Money.zero(Currency.getInstance("USD"));
return o;
}
// The ONLY way to add a line — invariants enforced here, atomically
public void addLine(ProductId product, int qty, Money unitPrice) {
// INVARIANT 1: cannot modify a placed order
if (status != OrderStatus.DRAFT)
throw new OrderNotModifiableException(id, status);
// INVARIANT 2: quantity must be positive
if (qty <= 0)
throw new IllegalArgumentException("qty must be > 0");
Money newTotal = total.add(unitPrice.multiply(qty));
// INVARIANT 3: total must not exceed the cap — checked BEFORE mutating state
if (newTotal.isGreaterThan(MAX_ORDER))
throw new OrderLimitExceededException(newTotal, MAX_ORDER);
lines.add(new OrderLine(product, qty, unitPrice));
this.total = newTotal; // state changes only after all checks pass
}
public void place() {
if (status != OrderStatus.DRAFT)
throw new IllegalStateException("already placed");
// INVARIANT 4: an order must have at least one line to be placed
if (lines.isEmpty())
throw new EmptyOrderException(id);
this.status = OrderStatus.PLACED;
// Anything OUTSIDE this aggregate reacts via an event, in a SEPARATE transaction (rule 4)
events.add(new OrderPlaced(id, customerId, total, Instant.now()));
}
// Expose an UNMODIFIABLE view — callers can read, but cannot bypass the root
public List<OrderLine> lines() { return Collections.unmodifiableList(lines); }
public List<DomainEvent> pullEvents() {
List<DomainEvent> copy = List.copyOf(events);
events.clear();
return copy;
}
}
Burn three things from this code into memory:
- Invariants are checked before any state mutates, so a rejected operation leaves the aggregate untouched (no partial mutation).
- The collection is exposed only as an unmodifiable view (
unmodifiableList) — nobody can sneak a line in behind the root's back. - Cross-aggregate effects (decrementing inventory, awarding loyalty points) happen via the
OrderPlacedevent, not inline and not in this same transaction.
Domain events
A domain event records something meaningful that happened in the domain, always in the past tense: OrderPlaced, PaymentCaptured, ShipmentDispatched. They are immutable value objects.
The aggregate is like an event in town; the domain event is like tomorrow morning's headline: "Order Placed." The paper doesn't change the event, it merely announces it so anyone interested (the warehouse, the loyalty desk, the confirmation email) can react. The key: the paper is printed after the event happened, not in the middle of it.
Domain events are the primary mechanism for eventual consistency between aggregates and for decoupling the core model from side effects. The aggregate "raises" the event; an application-layer dispatcher publishes it after the transaction commits; handlers in the same or other contexts react.
public record OrderPlaced(OrderId orderId, CustomerId customerId,
Money total, Instant occurredOn) implements DomainEvent {}
In Spring, the idiomatic way is ApplicationEventPublisher with @TransactionalEventListener(phase = AFTER_COMMIT) so publication happens after the transaction commits — so a handler never reacts to a change that later rolls back:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderPlaced event) {
// runs only if the order transaction actually committed
inventoryService.reserve(event.orderId()); // its own transaction, its own aggregate
}
A subtle trap: when you want to both write to the database and send a message to a broker (Kafka/RabbitMQ), those two writes are not atomic — the DB can commit while the message send fails. That is the "dual-write problem." The fix is the transactional outbox pattern: write the event to an outbox table in the same DB transaction as the aggregate; then a separate poller reads that row and relays it to the broker. This guarantees at-least-once delivery and closes the dual-write gap.
Repositories
A repository provides the illusion of an in-memory collection of aggregate roots. That is, it lets your code work with aggregates as if they all sat in a List in memory, while behind the scenes it loads/stores them from the database.
Key rules:
- One repository per aggregate root — not per table, not per entity.
- Its interface belongs to the domain layer and speaks the ubiquitous language; the implementation lives in infrastructure.
- Repositories load and store whole aggregates, never partial internals.
// Domain layer — an interface, expressed in domain terms
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order); // persists the WHOLE aggregate
List<Order> findOpenOrdersFor(CustomerId customer);
}
Two common mistakes: (1) making a repository for a non-root entity — e.g. OrderLineRepository. Wrong; order lines are reached through Order. (2) leaking query/ORM concepts like findByStatusAndTotalGreaterThan. That turns the repository into a thin DAO (Data Access Object — a raw data-access shim) and erodes the ubiquitous language. A repository should speak the business language (findOpenOrdersFor), not SQL.
Domain services
Sometimes a piece of domain logic doesn't naturally belong to any single entity or value object — usually because it coordinates several aggregates or is a stateless calculation. Model it as a domain service.
Which account does "transfer money between two accounts" belong to? Neither — the act of "transferring" is between them. So picture a money-changer (a domain service) standing in the middle, coordinating the operation. It holds no money and owns no account; it is just an activity.
A domain service has no identity or state; it's named after an activity in the ubiquitous language (FundsTransferService, PricingService). Don't confuse it with an application service:
- Domain service = business logic that doesn't fit one entity.
- Application service = a use-case orchestrator that manages transactions and calls repositories (orchestration, not business rules).
// Coordinates two Account aggregates — belongs to neither, so it's a domain service.
public class MoneyTransferService {
public void transfer(Account from, Account to, Money amount) {
from.withdraw(amount); // each aggregate enforces its own invariants
to.deposit(amount);
// Note: the APPLICATION service decides transaction scope; ideally these
// are two aggregates reconciled via events, not one 2-aggregate transaction.
}
}
Factories
When constructing an aggregate is complex — many invariants, assembling internal objects, choosing a subtype — a factory encapsulates that creation so a valid aggregate is born consistent.
Instead of building a half-formed object and filling it in step by step (invalid in between), a factory guarantees it is complete and valid from the moment of birth. A factory can be a static method on the root itself or a separate class. The Order.place(...) static method you saw above is a factory method.
Mapping aggregates to persistence
There is a fundamental tension here: the relational model (tables and rows) and the aggregate model (nested, behavior-rich objects) fight each other. DDD calls this the object-relational impedance mismatch — like fitting a round shape into a square hole.
Two approaches:
- Ideal: persistence ignorance. Domain objects have no ORM annotations; a separate mapping layer translates. Keeps the model pure but costs boilerplate.
- Pragmatic: JPA on the entities, accepting some coupling. If you do this, respect aggregate boundaries:
@OneToMany(cascade = ALL, orphanRemoval = true)from root to internals so the aggregate is saved/deleted as one unit; but never map a JPA association from one aggregate root to another — store the foreign ID as a plain value (CustomerId) instead.
@Entity
public class Order {
@EmbeddedId private OrderId id;
// internal entity: cascade so the whole aggregate persists atomically
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id")
private List<OrderLine> lines = new ArrayList<>();
// OTHER aggregate: store the ID only — NOT @ManyToOne Customer
@Embedded private CustomerId customerId;
}
If you map an association from one root to another, JPA is tempted to cross the aggregate boundary accidentally via "lazy loading" (fetch-on-touch — whenever you touch the field it fetches from the DB) and drag half the database into one transaction. Storing the ID as a plain value shuts that dangerous traversal down at the source.
Value objects map naturally to JPA @Embeddable/@Embedded (inlined columns), or in Java to record-backed types via an AttributeConverter. And for read-heavy queries that span aggregates, do not contort the domain model — use CQRS.
CQRS stands for Command Query Responsibility Segregation: the write side (command) uses the aggregate and its invariants; the read side (query) has a separate, simple model — flat DTOs, or even raw SQL/views — just for fast display. This way you never pollute the aggregate with reporting needs.
Where DDD pays off — and where it's overkill
Let us be honest: DDD is not free. Rich modeling, ubiquitous-language discipline, and boundary maintenance take time and energy. So when is it worth it?
It pays off when:
- The domain is genuinely complex — non-trivial business rules, many edge cases, invariants that evolve (insurance, trading, logistics, healthcare billing).
- The system is long-lived and maintained by rotating teams.
- You have access to domain experts to build the ubiquitous language — without them, DDD is impossible, not just hard.
It's overkill when:
- The app is essentially CRUD — forms over data with thin rules. A transaction-script or active-record style is cheaper and clearer; forcing aggregates adds ceremony with no invariant to protect.
- It's a short-lived tool, prototype, or throwaway.
- The subdomain is generic — don't lovingly model what you should buy.
It is not all-or-nothing. Professionals ration it like this: full tactical DDD in the core domain; DDD-lite (still bounded contexts + ubiquitous language, but simpler internals) in supporting subdomains; off-the-shelf for generic ones. Strategic DDD (contexts, language, subdomain classification) is cheap and valuable almost always; the heavy tactical patterns are the part you ration.
Common pitfalls & gotchas
- Anemic domain model. Getters/setters + "service" classes holding all logic. It's OOP in name only; invariants scatter and re-open the translation gap.
- Aggregates too big. A
Customerowning all orders → lock contention, giant transactions, memory blowups. Prefer small aggregates referencing by ID. - Modifying multiple aggregates per transaction. Re-couples them and invites deadlocks. One aggregate per transaction; cross-aggregate via events + eventual consistency.
- Exposing internal collections.
return lines;lets callers bypass the root's invariants. Return unmodifiable views. - Bounded context = microservice, always. No. Context is linguistic/modeling; service is deployment. Get contexts right first.
- Skipping the ACL and letting a vendor/legacy model bleed into your core.
- Repositories that are DAOs. Query-method soup erodes the ubiquitous language and leaks persistence into the domain.
- DDD everywhere, including generic subdomains and CRUD. Misallocated effort; the classic over-engineering trap.
- Ignoring Conway's Law. Context boundaries that cut across team ownership will erode; align them.
Interview Questions
Each question is in its own block. Try answering yourself first, then open the answer.
A rigorous shared vocabulary used identically by domain experts and in code/tests, scoped to a bounded context. It's central because the dominant cost in complex software is translation loss between experts and developers; the model is the language, so policy.bind() beats setStatus(2). Fuzzy language ⇒ fuzzy model. It's context-scoped, not global — the same word can mean different things in different contexts.
An entity has a persistent identity that survives attribute changes; equality is by ID. A value object is defined entirely by its attributes, has no identity, is immutable, and equality is by value (all attributes). A Customer is an entity; Money/EmailAddress are value objects. Put most invariants in value objects because an invalid one can't be constructed.
An aggregate is a cluster of entities/value objects treated as one unit for consistency and persistence. The root is the sole entry point: external code references only the root, and all mutation goes through it so invariants are enforced in one place, atomically. It defines the transactional consistency boundary.
(1) Model true invariants within consistency boundaries — group what must be transactionally consistent. (2) Design small aggregates. (3) Reference other aggregates by identity (ID), not object reference. (4) Update other aggregates via eventual consistency (domain events), one aggregate per transaction.
The aggregate boundary is the consistency boundary. Mutating two aggregates in one transaction re-couples them, causes lock contention/deadlocks, bloats transactions, and blocks later sharding or service extraction. If two things must be immediately consistent, that's a signal they're actually one aggregate; otherwise use events and accept eventual consistency (usually fine for the business).
No. A bounded context is a linguistic/modeling boundary within which one model is consistent; a microservice is a deployment boundary. A modular monolith can host several bounded contexts. Decide contexts first, deployment topology second — splitting services on wrong lines is very costly to undo.
A translation layer between your context and an external/legacy/third-party model that maps their vocabulary into your clean model, so foreign concepts don't leak into your core. Build it whenever you integrate with a model you don't control and want to keep your domain pure and swappable.
Core = competitive differentiator → build with your best people using rich DDD. Supporting = necessary but not differentiating → build plainly. Generic = solved problem (auth, billing) → buy/adopt. The strategic win is spending modeling effort on the core and not hand-crafting generic subdomains.
An immutable record of something meaningful that happened, past tense (OrderPlaced). It drives eventual consistency and decouples side effects. Publish after commit (@TransactionalEventListener(AFTER_COMMIT)) so handlers never react to a change that later rolls back. For cross-service at-least-once delivery, pair with a transactional outbox to avoid the dual-write problem.
A domain service holds stateless business logic that doesn't fit one entity/VO (often coordinating aggregates), named in the ubiquitous language. An application service is a use-case orchestrator: it manages transactions, loads/saves via repositories, and calls domain objects/services. Business rules go in the domain service; orchestration in the application service.
When the domain is essentially CRUD with thin rules, the system is short-lived/throwaway, or the subdomain is generic. With no real invariants to protect and no domain experts to talk to, aggregates add ceremony without payoff. Use transaction scripts/active record instead, or apply DDD selectively (rich in core, lite elsewhere).
Cascade from root to internal entities (@OneToMany(cascade=ALL, orphanRemoval=true)) so the aggregate saves/deletes atomically; store references to other aggregates as plain ID values (CustomerId), never as @ManyToOne associations, to prevent lazy traversals crossing boundaries. Map value objects as @Embeddable. For cross-aggregate reads, use CQRS with a separate read model instead of distorting the aggregate.
public List<OrderLine> getLines() { return lines; } // returns the live internal list
public void addLine(OrderLine l) { total = total.add(l.subtotal()); lines.add(l); }
Two bugs. First, getLines() returns the live internal collection, so a caller can do order.getLines().add(x) and bypass the root entirely — total never updates and every invariant check is skipped. Return Collections.unmodifiableList(lines). Second, addLine mutates total before adding the line and performs no invariant check; if a later validation (e.g. max-order limit) is added, ordering matters — always validate first, then mutate, so a rejected operation leaves the aggregate untouched.
record Basket(List<String> items) {}
var list = new ArrayList<>(List.of("a"));
var b = new Basket(list);
list.add("b");
System.out.println(b.items().size());
It prints 2. A record is only shallowly immutable — the items field is a final reference to the same mutable ArrayList. Mutating list after construction mutates the basket's view. For a true value object, defensively copy in the compact constructor: Basket { items = List.copyOf(items); }.
It's an anti-pattern because it splits data (dumb entities) from behavior (service classes), scattering invariants so no single object guarantees a valid state — reopening the expert-to-code translation gap DDD exists to close. It's acceptable (even preferable) for genuine CRUD/thin-rule apps, where a transaction-script style is simpler and there are few invariants to protect; forcing rich aggregates there is over-engineering. The judgment call is domain complexity, not dogma.
- DDD is about language and boundaries first, not folders. The real enemy is translation loss between expert and code.
- Strategic (load-bearing): split the domain into subdomains and classify by value (core/supporting/generic). Inside each bounded context, forge a ubiquitous language. Connect contexts with a context map (Partnership, Shared Kernel, Conformist, ACL, …). The ACL shields your domain from foreign models.
- Bounded context ≠ microservice. One is a linguistic boundary, the other a deployment boundary.
- Tactical (tools): value objects (no identity, immutable, equality by value) hold most rules; entities (persistent identity, equality by ID) survive change. The aggregate is your consistency boundary: don't bypass the root, check invariants before mutating, reference other aggregates by ID, one aggregate per transaction, and between aggregates use domain events + eventual consistency.
- Publish events after commit (
AFTER_COMMIT) and use a transactional outbox for reliability across services. - A repository is the illusion of an in-memory collection, one per root, speaking the domain language — not a DAO.
- Persistence: use JPA while respecting the aggregate boundary (
cascadeinward, plain ID outward); for reads, CQRS. - DDD is not free: it pays off for complex, long-lived domains with available experts; it's overkill for CRUD and generic subdomains. Apply it selectively.