طراحی دامنهمحور (DDD)
کد را به زبان کسبوکار بنویس. سیستم بزرگ را به چند بخش کوچک با مرز روشن تقسیم کن. قانونهای مهم را داخل خود مدل نگه دار.
نویسنده: bezzad
تصویر کلی
DDD دو بخش دارد. یک بخش به کل سیستم نگاه میکند. بخش دیگر به کد داخل هر قسمت نگاه میکند.
طراحی استراتژیک
سیستم را چطور تقسیم کنیم؟
- زبان مشترک (Ubiquitous Language)
- Bounded Context
- Context Map
طراحی تاکتیکی
داخل هر بخش، کد را چطور بنویسیم؟
- Entity و Value Object
- Aggregate
- Domain Event
- Repository
مشکل: یک مدل بزرگ برای همه
در یک فروشگاه اینترنتی، سه تیم با «محصول» کار میکنند: فروش، انبار و ارسال. راه ساده این است که یک کلاس Product بسازیم و همه از همان استفاده کنند.
این کلاس چند مشکل دارد:
- کلاس روز به روز بزرگتر میشود. هر تیم چند فیلد اضافه میکند.
- تیمها کار هم را خراب میکنند. تیم انبار یک فیلد را عوض میکند و صفحه فروش خطا میدهد.
- کلمهها معنای دقیق ندارند. وقتی کسی میگوید «موجودی»، منظورش تعداد در قفسه است یا تعداد قابل فروش؟
DDD برای حل همین مشکلها است.
زبان مشترک (Ubiquitous Language)
برنامهنویس و کارشناس کسبوکار باید با یک زبان حرف بزنند. همان کلمهای که در جلسه گفته میشود، باید در کد هم باشد.
کارشناس فروش میگوید: «مشتری سفارش را ثبت نهایی میکند. بعد از ثبت نهایی، سفارش دیگر تغییر نمیکند.»
بدکد به زبان فنی
order.Status = 3;
order.IsLocked = true;
db.SaveChanges();عدد ۳ یعنی چه؟ کسی نمیداند. قانون «تغییر ممنوع» هم جایی در کد نیست.
خوبکد به زبان کسبوکار
order.Place();اسم متد همان کلمه کارشناس است. قانونها داخل همین متد هستند.
Bounded Context: هر کلمه، یک مرز
یک کلمه در بخشهای مختلف کسبوکار معنای مختلف دارد. پس به جای یک مدل بزرگ، چند مدل کوچک میسازیم. هر مدل یک مرز دارد. به این مرز Bounded Context میگوییم.
روی هر بخش بزنید تا ببینید «محصول» آنجا چه معنایی دارد:
هر بخش مدل خودش را دارد. اسم کلاس هم میتواند فرق کند.
هر Bounded Context:
- مدل خودش را دارد. فقط فیلدهایی که خودش لازم دارد.
- زبان خودش را دارد. «محصول» در هر بخش معنای دقیق خودش را دارد.
- معمولاً یک تیم صاحب آن است. تیم میتواند بدون ترس مدلش را تغییر دهد.
- معمولاً دیتابیس یا دستکم جدولهای خودش را دارد. بخشهای دیگر مستقیم به جدولهای آن دست نمیزنند.
بخشها چطور با هم حرف میزنند؟
بخشها از هم جدا هستند، ولی باید با هم کار کنند. بهترین راه معمولاً رویداد است. هر بخش خبر میدهد چه اتفاقی افتاد. بخشهای دیگر اگر لازم داشته باشند، به آن واکنش نشان میدهند.
به نقشهای که نشان میدهد بخشها چطور به هم وصلاند، Context Map میگوییم. دو نکته مهم در این نقشه:
- هر بخش مدل بخش دیگر را ترجمه میکند. انبار مدل فروش را مستقیم استفاده نمیکند. فقط شناسه محصول و تعداد را از رویداد برمیدارد. به این لایه ترجمه Anti-Corruption Layer میگوییم.
- رویداد به زبان گذشته است. «سفارش ثبت شد»، نه «انبار را رزرو کن». فرستنده نمیداند چه کسی گوش میدهد.
Entity و Value Object
حالا داخل یک بخش میرویم. اولین سؤال درباره هر چیز این است: آیا هویت دارد؟
Entity
- یک شناسه (Id) دارد.
- در طول زمان تغییر میکند، ولی همان چیز میماند. مشتری اسمش را عوض میکند، ولی همان مشتری است.
- دو Entity را با شناسه مقایسه میکنیم.
- مثال: مشتری، سفارش، حساب بانکی.
Value Object
- شناسه ندارد.
- تغییر نمیکند (Immutable). برای تغییر، یک مقدار جدید میسازیم.
- دو Value Object را با مقدارشان مقایسه میکنیم.
- مثال: مبلغ پول، آدرس، بازه تاریخ.
در C#، نوع record برای Value Object عالی است. مقایسه بر اساس مقدار را خودش انجام میدهد:
public sealed record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException("Currencies are different.");
return this with { Amount = Amount + other.Amount };
}
}
var a = new Money(100_000, "IRT");
var b = new Money(100_000, "IRT");
Console.WriteLine(a == b); // True: same value, same money
و یک Entity ساده:
public sealed class Customer(Guid id, string name)
{
public Guid Id { get; } = id;
public string Name { get; private set; } = name;
public void Rename(string newName) => Name = newName;
}
// Two customers named "Ali" are two different people.
// We compare customers by Id, never by Name.
Aggregate: نگهبان قانونها
سفارش چند ردیف دارد (OrderLine). یک قانون داریم: جمع سفارش نباید از ۲۰ میلیون تومان بیشتر شود. این قانون به کل سفارش مربوط است، نه به یک ردیف.
اگر هر قسمت کد بتواند مستقیم یک ردیف اضافه کند، دیر یا زود یک نفر قانون را فراموش میکند. پس سفارش و ردیفهایش را یک واحد میکنیم. به این واحد Aggregate میگوییم. فقط یک در ورودی دارد: Aggregate Root.
قانونهای سفارش: جمع حداکثر ۲۰ میلیون تومان. بعد از ثبت نهایی، هیچ تغییری مجاز نیست.
سفارش ۱۰۴۲ پیشنویس
کد Aggregate سفارش. همه قانونها داخل خود کلاس هستند و لیست ردیفها از بیرون قابل تغییر نیست:
public sealed class Order
{
private const decimal MaxTotal = 20_000_000;
private readonly List<OrderLine> _lines = [];
public Guid Id { get; } = Guid.NewGuid();
public Guid CustomerId { get; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public IReadOnlyList<OrderLine> Lines => _lines;
public decimal Total => _lines.Sum(line => line.Price * line.Quantity);
public Order(Guid customerId) => CustomerId = customerId;
public void AddLine(Guid productId, decimal price, int quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("A placed order cannot change.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
if (Total + price * quantity > MaxTotal)
throw new DomainException("Order total is over the limit.");
_lines.Add(new OrderLine(productId, price, quantity));
}
public void Place()
{
if (_lines.Count == 0)
throw new DomainException("An empty order cannot be placed.");
Status = OrderStatus.Placed;
}
}
چهار قانون طلایی Aggregate
- فقط از طریق Root. کد بیرونی هیچ وقت مستقیم یک OrderLine را تغییر نمیدهد.
- کوچک نگهش دار. فقط چیزهایی را داخل Aggregate بگذار که باید با هم درست بمانند. مشتری داخل Aggregate سفارش نیست.
- به Aggregate دیگر فقط با شناسه اشاره کن. سفارش فقط CustomerId را دارد، نه کل شیء مشتری را.
- در هر تراکنش، فقط یک Aggregate تغییر کند. اگر Aggregate دیگری هم باید تغییر کند، این کار را با Domain Event انجام بده.
Domain Event: خبر دادن از یک اتفاق
وقتی سفارش ثبت نهایی شد، چند کار دیگر هم باید انجام شود. ولی سفارش نباید همه این کارها را خودش بداند. سفارش فقط یک خبر میدهد: «من ثبت شدم».
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total);
public sealed class Order
{
private readonly List<object> _events = [];
public IReadOnlyList<object> Events => _events;
public void Place()
{
// ... the same checks as before ...
Status = OrderStatus.Placed;
_events.Add(new OrderPlaced(Id, CustomerId, Total));
}
}
public sealed class ReserveStockHandler(IWarehouse warehouse)
: IDomainEventHandler<OrderPlaced>
{
public Task HandleAsync(OrderPlaced e, CancellationToken ct)
=> warehouse.ReserveAsync(e.OrderId, ct);
}
Repository: یک Aggregate کامل را بیاور و ذخیره کن
Repository برای هر Aggregate یک «مجموعه» در حافظه را شبیهسازی میکند. Aggregate را کامل میآورد و کامل ذخیره میکند.
- برای هر Aggregate Root یک Repository. برای OrderLine جداگانه Repository نمیسازیم.
- متدها به زبان دامنه. متدی مثل «بهروزرسانی ردیف» نداریم. تغییر فقط از متدهای خود سفارش انجام میشود.
public interface IOrderRepository
{
Task<Order?> GetAsync(Guid id, CancellationToken ct);
void Add(Order order);
}
// Application layer: load, call the domain, save.
var order = await orders.GetAsync(command.OrderId, ct)
?? throw new NotFoundException();
order.AddLine(command.ProductId, command.Price, command.Quantity);
await unitOfWork.SaveChangesAsync(ct);
به ترتیب این سه خط دقت کن: بیاور، تصمیم را به دامنه بسپار، ذخیره کن. لایه Application هیچ قانونی را خودش چک نمیکند.
اشتباههای رایج
| اشتباه | چرا بد است؟ | راه درست |
|---|---|---|
| مدل کمخون (Anemic): کلاس فقط property دارد | قانونها در سرویسهای مختلف پخش میشوند و تکرار یا فراموش میشوند. | قانون را داخل متد خود Entity بگذار. |
| Aggregate خیلی بزرگ: همه سفارشها داخل مشتری | برای هر تغییر کوچک، داده زیادی لود میشود. تغییرهای همزمان با هم تداخل پیدا میکنند. | مشتری و سفارش دو Aggregate جدا. ارتباط فقط با شناسه. |
| اشاره مستقیم به Aggregate دیگر | مرزها از بین میرود. یک تراکنش چند Aggregate را قفل میکند. | فقط شناسه نگه دار. |
| یک دیتابیس مشترک بین همه بخشها | هر بخش جدول بخش دیگر را میخواند. باز همان مدل بزرگ ساخته میشود. | هر بخش داده خودش. ارتباط با رویداد یا API. |
| اسمهای فنی مثل Manager و Helper | کسی نمیفهمد کلاس در کسبوکار چه کاری میکند. | از کلمههای کارشناس کسبوکار استفاده کن. |
| DDD برای یک CRUD ساده | کد پیچیده میشود، بدون هیچ سودی. | برای بخش ساده، کد ساده بنویس. |
چه وقت DDD؟
مناسب
- قانونهای کسبوکار زیاد و پیچیده هستند.
- سیستم سالها زنده میماند و مدام تغییر میکند.
- چند تیم روی یک سیستم کار میکنند.
- کارشناس کسبوکار در دسترس است.
نامناسب
- برنامه بیشتر فرم و جدول است (CRUD).
- پروژه کوچک یا کوتاهمدت است.
- پیچیدگی اصلی فنی است، نه کسبوکار. مثلاً یک سرویس تبدیل فایل.
خلاصه در شش خط
- کد را با کلمههای کارشناس کسبوکار بنویس.
- سیستم را به چند Bounded Context تقسیم کن. هر کدام مدل و زبان خودش را دارد.
- بخشها با رویداد با هم حرف بزنند، نه با دیتابیس مشترک.
- چیزی که هویت دارد Entity است. چیزی که فقط مقدار دارد Value Object است.
- Aggregate را کوچک نگه دار. همه تغییرها فقط از Root عبور کنند.
- برای خبر دادن از یک اتفاق، Domain Event بفرست.
سؤالهای مرتبط: ۲۴. دادهای که در سرویس دیگر است · ۳۶. Aggregate و قانونهای دامنه · ۳۷. مدل کمخون یا غنی · ۳۸. Bounded Context و زبان مشترک