Levelwise
فارسی
طراحی کد

طراحی دامنه‌محور (DDD)

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

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۵ دقیقهمثال یک فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

تصویر کلی

DDD دو بخش دارد. یک بخش به کل سیستم نگاه می‌کند. بخش دیگر به کد داخل هر قسمت نگاه می‌کند.

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

سیستم را چطور تقسیم کنیم؟

  • زبان مشترک (Ubiquitous Language)
  • Bounded Context
  • Context Map

طراحی تاکتیکی

داخل هر بخش، کد را چطور بنویسیم؟

  • Entity و Value Object
  • Aggregate
  • Domain Event
  • Repository
نکته مهم: بخش استراتژیک مهم‌تر است. بیشتر تیم‌ها فقط بخش تاکتیکی را یاد می‌گیرند. نتیجه این است که اسم کلاس‌ها عوض می‌شود، ولی مشکل اصلی سیستم سر جایش می‌ماند.

مشکل: یک مدل بزرگ برای همه

در یک فروشگاه اینترنتی، سه تیم با «محصول» کار می‌کنند: فروش، انبار و ارسال. راه ساده این است که یک کلاس Product بسازیم و همه از همان استفاده کنند.

class ProductName, PriceDiscount, ImagesDescriptionSku, ShelfCodeQuantityReorderLevelWeight, SizeIsFragileCourierType... ۴۰ فیلد دیگرتیم فروشتیم انبارتیم ارسالهر تغییر یک تیمکد تیم‌های دیگر را می‌شکند
رنگ هر فیلد نشان می‌دهد کدام تیم واقعاً به آن نیاز دارد.

این کلاس چند مشکل دارد:

  1. کلاس روز به روز بزرگ‌تر می‌شود. هر تیم چند فیلد اضافه می‌کند.
  2. تیم‌ها کار هم را خراب می‌کنند. تیم انبار یک فیلد را عوض می‌کند و صفحه فروش خطا می‌دهد.
  3. کلمه‌ها معنای دقیق ندارند. وقتی کسی می‌گوید «موجودی»، منظورش تعداد در قفسه است یا تعداد قابل فروش؟

DDD برای حل همین مشکل‌ها است.

زبان مشترک (Ubiquitous Language)

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

کارشناس فروش می‌گوید: «مشتری سفارش را ثبت نهایی می‌کند. بعد از ثبت نهایی، سفارش دیگر تغییر نمی‌کند.»

بدکد به زبان فنی

order.Status = 3;
order.IsLocked = true;
db.SaveChanges();

عدد ۳ یعنی چه؟ کسی نمی‌داند. قانون «تغییر ممنوع» هم جایی در کد نیست.

خوبکد به زبان کسب‌وکار

order.Place();

اسم متد همان کلمه کارشناس است. قانون‌ها داخل همین متد هستند.

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

Bounded Context: هر کلمه، یک مرز

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

روی هر بخش بزنید تا ببینید «محصول» آنجا چه معنایی دارد:

فروشSales ContextProductNamePriceDiscountImagesیعنی: چیزی برای فروختنانبارWarehouse ContextStockItemSkuShelfCodeQuantityReorderLevelیعنی: جعبه‌ای در قفسهارسالShipping ContextParcelWeightSizeIsFragileAddressیعنی: بسته‌ای برای ارسال

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

هر Bounded Context:

  • مدل خودش را دارد. فقط فیلدهایی که خودش لازم دارد.
  • زبان خودش را دارد. «محصول» در هر بخش معنای دقیق خودش را دارد.
  • معمولاً یک تیم صاحب آن است. تیم می‌تواند بدون ترس مدلش را تغییر دهد.
  • معمولاً دیتابیس یا دست‌کم جدول‌های خودش را دارد. بخش‌های دیگر مستقیم به جدول‌های آن دست نمی‌زنند.
Bounded Context با Microservice یکی نیست. یک Bounded Context می‌تواند یک ماژول داخل یک Monolith باشد. اول مرزها را درست پیدا کن. جدا کردن سرویس‌ها می‌تواند بعداً انجام شود.

بخش‌ها چطور با هم حرف می‌زنند؟

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

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

به نقشه‌ای که نشان می‌دهد بخش‌ها چطور به هم وصل‌اند، Context Map می‌گوییم. دو نکته مهم در این نقشه:

  • هر بخش مدل بخش دیگر را ترجمه می‌کند. انبار مدل فروش را مستقیم استفاده نمی‌کند. فقط شناسه محصول و تعداد را از رویداد برمی‌دارد. به این لایه ترجمه Anti-Corruption Layer می‌گوییم.
  • رویداد به زبان گذشته است. «سفارش ثبت شد»، نه «انبار را رزرو کن». فرستنده نمی‌داند چه کسی گوش می‌دهد.

Entity و Value Object

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

Entityهویت داردعلی رضاییId: 17علی رضاییId: 42≠اسم یکی است، ولی دو نفر متفاوت‌اند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.
چرا Value Object مفید است؟ قانون‌های کوچک یک جا جمع می‌شوند. مثلاً جمع دو مبلغ با دو واحد پول مختلف ممنوع است. این قانون فقط یک بار، داخل Money نوشته می‌شود.

Aggregate: نگهبان قانون‌ها

سفارش چند ردیف دارد (OrderLine). یک قانون داریم: جمع سفارش نباید از ۲۰ میلیون تومان بیشتر شود. این قانون به کل سفارش مربوط است، نه به یک ردیف.

اگر هر قسمت کد بتواند مستقیم یک ردیف اضافه کند، دیر یا زود یک نفر قانون را فراموش می‌کند. پس سفارش و ردیف‌هایش را یک واحد می‌کنیم. به این واحد Aggregate می‌گوییم. فقط یک در ورودی دارد: Aggregate Root.

مرز سفارش و ردیف‌هایشOrderAggregate RootOrderLineOrderLineOrderLineکد بیرونی✓ از طریق Root✗ مستقیم، ممنوع
همه تغییرها از Order عبور می‌کنند. پس Order همیشه می‌تواند قانون‌ها را چک کند.
مثال زنده: سفارش را تغییر بده

قانون‌های سفارش: جمع حداکثر ۲۰ میلیون تومان. بعد از ثبت نهایی، هیچ تغییری مجاز نیست.

از طریق Root (درست):
دور زدن 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

      1. فقط از طریق Root. کد بیرونی هیچ وقت مستقیم یک OrderLine را تغییر نمی‌دهد.
      2. کوچک نگهش دار. فقط چیزهایی را داخل Aggregate بگذار که باید با هم درست بمانند. مشتری داخل Aggregate سفارش نیست.
      3. به Aggregate دیگر فقط با شناسه اشاره کن. سفارش فقط CustomerId را دارد، نه کل شیء مشتری را.
      4. در هر تراکنش، فقط یک Aggregate تغییر کند. اگر Aggregate دیگری هم باید تغییر کند، این کار را با Domain Event انجام بده.

      Domain Event: خبر دادن از یک اتفاق

      وقتی سفارش ثبت نهایی شد، چند کار دیگر هم باید انجام شود. ولی سفارش نباید همه این کارها را خودش بداند. سفارش فقط یک خبر می‌دهد: «من ثبت شدم».

      order.Place()OrderPlacedرزرو کالا در انبارارسال ایمیل به مشتریامتیاز باشگاه مشتریان
      فردا اگر کار چهارمی لازم شد، کد سفارش تغییر نمی‌کند. فقط یک گیرنده جدید اضافه می‌کنیم.
      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);
      }
      مراقب باش: اگر رویداد باید به سرویس دیگری برسد (مثلاً با Kafka)، آن را مستقیم بعد از ذخیره نفرست. اگر ارسال شکست بخورد، رویداد گم می‌شود. رویداد را در همان تراکنش دیتابیس، در جدول Outbox ذخیره کن و بعد بفرست.

      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).
      • پروژه کوچک یا کوتاه‌مدت است.
      • پیچیدگی اصلی فنی است، نه کسب‌وکار. مثلاً یک سرویس تبدیل فایل.
      راه میانه: لازم نیست همه سیستم DDD باشد. بخش اصلی کسب‌وکار (Core Domain) را با DDD بساز. بخش‌های ساده، مثل تنظیمات کاربر، را ساده نگه دار.

      خلاصه در شش خط

      1. کد را با کلمه‌های کارشناس کسب‌وکار بنویس.
      2. سیستم را به چند Bounded Context تقسیم کن. هر کدام مدل و زبان خودش را دارد.
      3. بخش‌ها با رویداد با هم حرف بزنند، نه با دیتابیس مشترک.
      4. چیزی که هویت دارد Entity است. چیزی که فقط مقدار دارد Value Object است.
      5. Aggregate را کوچک نگه دار. همه تغییرها فقط از Root عبور کنند.
      6. برای خبر دادن از یک اتفاق، Domain Event بفرست.

      سؤال‌های مرتبط: ۲۴. داده‌ای که در سرویس دیگر است · ۳۶. Aggregate و قانون‌های دامنه · ۳۷. مدل کم‌خون یا غنی · ۳۸. Bounded Context و زبان مشترک