← All tutorials

آموزش DDD با پروژه Order

در این آموزش یاد می‌گیرید Domain-Driven Design و CQRS در سرویس سفارش چگونه کار می‌کنند.

بنر DDD و CQRS: دایره‌های Domain و Application و API، با مسیر جدا برای Command و Query.
نوشتن سفارش و خواندن سفارش دو مسیر جدا هستند و قانون کسب‌وکار داخل Domain می‌ماند.

DDD چیست؟

Domain-Driven Design (طراحی مبتنی بر دامنه) رویکردی برای ساخت نرم‌افزار است که در آن، منطق واقعی کسب‌وکار در مرکز طراحی قرار می‌گیرد — نه دیتابیس، نه فریم‌ورک، نه UI. «دامنه» (Domain) همان حوزه‌ی مسئله‌ای است که نرم‌افزار برای آن ساخته شده؛ مثلاً در یک فروشگاه آنلاین: سفارش، کالا، مشتری، موجودی انبار و قوانینی مثل «سفارش باید حداقل یک قلم داشته باشد» یا «سفارش لغوشده قابل ویرایش نیست».

این ایده را Eric Evans در کتاب «Domain-Driven Design» (۲۰۰۳) مطرح کرد. مشکل رایج در پروژه‌های بزرگ این است که کد به‌مرور به یک مجموعه‌ی Controller/Service/Repository تبدیل می‌شود که منطق کسب‌وکار پراکنده و تکراری است و فهم آن برای تیم سخت می‌شود. DDD می‌گوید: اول دامنه را بفهمید، بعد کد را حول آن بسازید.

چند اصل مهم DDD

  • زبان مشترک (Ubiquitous Language): تیم فنی و کسب‌وکار از یک واژگان یکسان استفاده می‌کنند. اگر در جلسه می‌گوییم «سفارش لغو شد»، در کد هم Cancel() داریم — نه UpdateStatus(3).
  • لایه‌بندی (Layered Architecture): Domain (قوانین خالص)، Application (هماهنگی سناریوها)، Infrastructure (دیتابیس و API) و Api (ورودی HTTP) از هم جدا می‌شوند.
  • Entity و Value Object: Entity با Id شناخته می‌شود (مثل خط سفارش). Value Object با مقدارش (مثل پول یا آدرس).
  • Aggregate و Aggregate Root: گروهی از اشیاء مرتبط که با هم یک واحد هستند. فقط از طریق «ریشه» (Root) قابل تغییرند — مثل سفارش و خطوط داخلش.
  • Domain Event: وقتی اتفاق مهمی در کسب‌وکار می‌افتد (مثلاً «سفارش ثبت شد») آن را به‌صورت رویداد ثبت می‌کنیم تا بخش‌های دیگر بدون وابستگی مستقیم واکنش نشان دهند.
مثال ساده: بدون DDD ممکن است در Controller بنویسید: «اگر تعداد صفر بود خطا بدهید، جمع را محاسبه کنید، در دیتابیس ذخیره کنید». با DDD همان قوانین داخل OrderAggregate هستند و Controller فقط می‌گوید «یک سفارش بسازید» — کد نزدیک‌تر به زبان کسب‌وکار و قابل‌تست‌تر می‌شود.

در این آموزش همین مفاهیم را قدم‌به‌قدم با یک سرویس واقعی Order در .NET 8 می‌بینید: چهار لایه، Aggregate، CQRS، MediatR و ارتباط با میکروسرویس‌های User و Product. اگر تازه‌کار هستید، از بخش «سرویس Order چه کاری می‌کند؟» شروع کنید.

۱. سرویس Order چه کاری می‌کند؟

سرویس Order مسئول مدیریت سفارش‌هاست. کارهای اصلی‌اش:

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

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

  • آیا این کاربر وجود دارد و فعال است؟ (از سرویس User می‌پرسد)
  • آیا این کالا موجود است و موجودی کافی دارد؟ (از سرویس Product می‌پرسد)
در معماری میکروسرویس، هر سرویس دیتابیس خودش را دارد و برای گرفتن اطلاعات سرویس دیگر، از طریق HTTP با آن حرف می‌زند.

۲. چهار لایه پروژه

پروژه Order به چهار پروژه (لایه) تقسیم شده. هر لایه فقط یک مسئولیت دارد:

قلب پروژه

🟢 Order.Domain — دامنه

قوانین کسب‌وکار اینجاست. هیچ وابستگی به دیتابیس یا فریم‌ورک ندارد.

شامل: OrderAggregate، OrderLine، OrderStatus، Eventها، کلاس‌های پایه (Entity، AggregateRoot)

هماهنگ‌کننده

🔵 Order.Application — اپلیکیشن

سناریوها را هماهنگ می‌کند: «وقتی سفارش می‌آید، چه اتفاقاتی به چه ترتیبی بیفتد؟»

شامل: Commandها، Queryها، Handlerها، Validatorها، DTOها، Interfaceها

دنیای بیرون

🟣 Order.Infrastructure — زیرساخت

پیاده‌سازی واقعی دیتابیس و ارتباط با سرویس‌های دیگر.

شامل: OrderDbContext، Repository، Configurationها، HttpClientها

درِ ورودی

🟡 Order.Api — رابط HTTP

درخواست HTTP می‌گیرد و به Application تحویل می‌دهد.

شامل: OrdersController، Request DTOها، Program.cs

قانون طلایی وابستگی‌ها:
Api → به Application و Infrastructure وابسته است
Application → فقط به Domain وابسته است
Infrastructure → به Application و Domain وابسته است
Domain → به هیچ‌چیز وابسته نیست (مستقل و خالص)

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

۳. مفاهیم DDD با مثال واقعی

۳.۱ Entity (موجودیت)

Entity چیزی است که با یک شناسه (Id) شناخته می‌شود، نه با محتوایش. حتی اگر تمام مشخصاتش عوض شود، تا وقتی Id یکی باشد، همان موجودیت است.

مثال: یک «خط سفارش» (OrderLine). دو خط سفارش حتی اگر هر دو «۲ عدد لپ‌تاپ» باشند، باز هم دو موجودیت متفاوت‌اند چون Id جدا دارند.

// کلاس پایه: هر موجودیت یک Id دارد
public abstract class Entity
{
    public Guid Id { get; protected set; }
    protected Entity() { }
}
// OrderLine یک Entity است
public sealed class OrderLine : Entity
{
    public Guid ProductId { get; private set; }
    public string ProductName { get; private set; }
    public int Quantity { get; private set; }
    public decimal UnitPrice { get; private set; }
    public decimal LineTotal => UnitPrice * Quantity;  // محاسباتی

    // فقط از داخل همین assembly قابل ساخت است (internal)
    internal static OrderLine Create(Guid productId, string name, int qty, decimal price)
    {
        if (qty <= 0) throw new ArgumentException("Quantity must be > 0");
        if (price < 0) throw new ArgumentException("Price cannot be negative");
        return new OrderLine { Id = Guid.NewGuid(), /* ... */ };
    }
}
توجه کنید همه setterها private هستند. یعنی از بیرون نمی‌توان مقادیر را عوض کرد — فقط از طریق متدهای کنترل‌شده. این یعنی کپسوله‌سازی (Encapsulation).

۳.۲ Value Object (شیء ارزشی)

Value Object برعکس Entity است: با محتوایش شناخته می‌شود، نه با Id. دو Value Object با مقادیر یکسان، کاملاً برابرند.

مثال کلاسیک: پول. «۱۰۰۰ تومان» همیشه برابر «۱۰۰۰ تومان» است. در سرویس Product این را داریم:

public sealed class Money : ValueObject
{
    public decimal Amount { get; }
    public string Currency { get; }
    // دو Money با Amount و Currency یکسان، برابرند
}
در سرویس Order برای سادگی، قیمت به‌صورت decimal ساده نگه داشته شده. اما در Product مفهوم Money به‌عنوان Value Object پیاده شده تا تفاوت را ببینید.
ویژگیEntityValue Object
شناسایی باIdمحتوا
قابل تغییر؟بله (کنترل‌شده)خیر (تغییرناپذیر)
مثالOrderLine، OrderAggregateMoney، Email

۳.۳ Aggregate و Aggregate Root

Aggregate یک گروه از اشیاء مرتبط است که با هم یک واحد را می‌سازند. در سرویس ما:

Aggregate سفارش = یک OrderAggregate + چند OrderLine داخلش
📦 OrderAggregate (ریشه / Root)
📄 OrderLine ۱
📄 OrderLine ۲

Aggregate Root «رئیس» این گروه است. قانون مهم:

همه تغییرات فقط باید از طریق ریشه انجام شوند. هیچ‌کس از بیرون حق ندارد مستقیماً یک OrderLine را دستکاری کند. باید از OrderAggregate بخواهد.

پیاده‌سازی را مشاهده کنید:

public sealed class OrderAggregate : AggregateRoot
{
    // لیست خطوط: private است! از بیرون دیده نمی‌شود
    private readonly List<OrderLine> _lines = [];

    public Guid UserId { get; private set; }
    public OrderStatus Status { get; private set; }
    public decimal TotalAmount { get; private set; }

    // بیرون فقط یک کپی فقط-خواندنی می‌بیند
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    private OrderAggregate() { }  // سازنده خصوصی

    // تنها راه ساخت سفارش: این متد کارخانه‌ای (Factory Method)
    public static OrderAggregate Create(Guid userId, IEnumerable<...> items)
    {
        var lineItems = items.ToList();
        if (lineItems.Count == 0)
            throw new ArgumentException("Order must contain at least one line item.");

        var order = new OrderAggregate
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            Status = OrderStatus.Confirmed,
            CreatedAt = DateTime.UtcNow
        };

        // خطوط فقط از داخل ریشه ساخته می‌شوند
        foreach (var item in lineItems)
            order._lines.Add(OrderLine.Create(item.ProductId, item.ProductName,
                                              item.Quantity, item.UnitPrice));

        order.TotalAmount = order._lines.Sum(l => l.LineTotal);  // محاسبه جمع
        order.RaiseDomainEvent(new OrderPlacedEvent(order.Id, userId, order.TotalAmount));
        return order;
    }

    public void Cancel()
    {
        if (Status == OrderStatus.Cancelled)
            throw new InvalidOperationException("Order is already cancelled.");
        Status = OrderStatus.Cancelled;
        UpdatedAt = DateTime.UtcNow;
        RaiseDomainEvent(new OrderCancelledEvent(Id));
    }
}
چرا این‌قدر سخت‌گیری؟ چون اگر هرکسی بتواند مستقیم خطوط را عوض کند، ممکن است TotalAmount با مجموع خطوط نخواند و دیتای ناسالم به‌وجود بیاید. ریشه تضمین می‌کند سفارش همیشه در وضعیت معتبر بماند.

کلاس پایه AggregateRoot

public abstract class AggregateRoot : Entity
{
    private readonly List<IDomainEvent> _domainEvents = [];
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
    protected void RaiseDomainEvent(IDomainEvent e) => _domainEvents.Add(e);
    public void ClearDomainEvents() => _domainEvents.Clear();
}

تفاوت AggregateRoot با Entity: علاوه بر Id، می‌تواند رویداد دامنه (Domain Event) ثبت کند. به همین خاطر OrderAggregate از AggregateRoot ارث می‌برد ولی OrderLine فقط از Entity.

۳.۴ Domain Event (رویداد دامنه)

Domain Event یعنی «یک اتفاق مهم در کسب‌وکار افتاد». مثلاً «یک سفارش ثبت شد» یا «یک سفارش لغو شد». این رویدادها واقعیت‌های گذشته‌اند، پس اسمشان همیشه ماضی است.

public interface IDomainEvent
{
    DateTime OccurredOn { get; }  // چه زمانی اتفاق افتاد
}

public sealed class OrderPlacedEvent : IDomainEvent
{
    public Guid OrderId { get; }
    public Guid UserId { get; }
    public decimal TotalAmount { get; }
    public DateTime OccurredOn { get; }
    // ...
}

وقتی سفارش ساخته می‌شود، داخل Create این خط اجرا می‌شود:

order.RaiseDomainEvent(new OrderPlacedEvent(order.Id, userId, order.TotalAmount));
این رویداد فعلاً فقط ثبت می‌شود (در لیست _domainEvents). در پروژه‌های واقعی می‌توان بعد از ذخیره‌سازی، این رویدادها را منتشر کرد تا مثلاً ایمیل تأیید بفرستد یا به سرویس انبار خبر بدهد. این کار اتصال سست (Loose Coupling) بین بخش‌ها می‌سازد.

۴. CQRS با مثال واقعی

CQRS یعنی جدا کردن نوشتن از خواندن:

Command (فرمان)Query (پرس‌وجو)
کارتغییر می‌دهد (نوشتن)فقط می‌خواند
مثالساخت سفارش، لغو سفارشدیدن سفارش
خروجیمعمولاً فقط Id یا هیچداده (DTO)

برای پیاده‌سازی، از کتابخانه‌ی MediatR استفاده می‌کنیم. کنترلر مستقیماً Handler را صدا نمی‌زند؛ یک پیام (Command/Query) به MediatR می‌دهد و MediatR خودش Handler مناسب را پیدا می‌کند.

۴.۱ Command (فرمان)

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

// ۱) خود فرمان: فقط داده‌ها (یک record ساده)
public sealed record CreateOrderCommand(
    Guid UserId,
    IReadOnlyList<CreateOrderLineRequest> Lines) : IRequest<Guid>;
//                                                  ^^^^^^^^^^^^^ خروجی: Id سفارش

// ۲) قوانین اعتبارسنجی
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.UserId).NotEmpty();
        RuleFor(x => x.Lines).NotEmpty();
        RuleForEach(x => x.Lines).ChildRules(line =>
        {
            line.RuleFor(l => l.ProductId).NotEmpty();
            line.RuleFor(l => l.Quantity).GreaterThan(0);
        });
    }
}

۴.۲ Handler (پردازشگر)

Handler جایی است که منطق سناریو اجرا می‌شود. این مهم‌ترین قسمت سرویس Order است:

public sealed class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUserServiceClient _userServiceClient;
    private readonly IProductServiceClient _productServiceClient;
    // (تزریق وابستگی از طریق سازنده)

    public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        // ۱) آیا کاربر وجود دارد؟ (تماس با سرویس User)
        var user = await _userServiceClient.GetUserAsync(request.UserId, ct)
            ?? throw new KeyNotFoundException($"User '{request.UserId}' not found.");

        // ۲) آیا کاربر فعال است؟
        if (!user.IsActive)
            throw new InvalidOperationException("Cannot create order for inactive user.");

        // ۳) برای هر قلم، کالا را از سرویس Product دریافت کنید و موجودی را بررسی کنید
        var orderItems = new List<(Guid, string, int, decimal)>();
        foreach (var line in request.Lines)
        {
            var product = await _productServiceClient.GetProductAsync(line.ProductId, ct)
                ?? throw new KeyNotFoundException($"Product '{line.ProductId}' not found.");

            if (product.StockQuantity < line.Quantity)
                throw new InvalidOperationException(
                    $"Insufficient stock for '{product.Name}'.");

            orderItems.Add((product.Id, product.Name, line.Quantity, product.Price));
        }

        // ۴) حالا که همه‌چیز معتبر است، سفارش را بساز (منطق در خود aggregate)
        var order = OrderAggregate.Create(request.UserId, orderItems);

        // ۵) ذخیره در دیتابیس
        await _orderRepository.AddAsync(order, ct);
        return order.Id;
    }
}
توجه به تقسیم مسئولیت: Handler «هماهنگ‌کننده» است (چه سرویسی را صدا بزند، به چه ترتیبی). اما تصمیم‌های کسب‌وکار مثل «جمع سفارش چقدر است» یا «سفارش باید حداقل یک قلم داشته باشد» داخل OrderAggregate هستند. Handler منطق دامنه را تکرار نمی‌کند.

۴.۳ Query (پرس‌وجو)

Query فقط می‌خواند و یک DTO برمی‌گرداند (نه خود aggregate):

public sealed record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

public sealed class GetOrderByIdQueryHandler
    : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrderRepository _repository;

    public async Task<OrderDto?> Handle(GetOrderByIdQuery request, CancellationToken ct)
    {
        var order = await _repository.GetByIdAsync(request.OrderId, ct);
        return order?.ToDto();   // تبدیل aggregate به DTO
    }
}
چرا DTO؟ چون نمی‌خواهیم aggregate دامنه را مستقیم به بیرون بفرستیم. DTO یک «نمای ساده و امن» برای ارسال به کلاینت است و وابستگی بیرون را از ساختار داخلی دامنه جدا می‌کند.
public sealed record OrderDto(
    Guid Id, Guid UserId, OrderStatus Status, decimal TotalAmount,
    DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<OrderLineDto> Lines);

۴.۴ FluentValidation و ValidationBehavior

سؤال: Validator را نوشتیم، اما چه کسی آن را اجرا می‌کند؟ جواب: ValidationBehavior.

این یک «حلقه میانی» (Pipeline Behavior) در MediatR است. هر Command/Query قبل از رسیدن به Handler، از این حلقه عبور می‌کند:

public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public async Task<TResponse> Handle(TRequest request,
        RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        if (!_validators.Any()) return await next();  // ولیدیتوری نیست؟ رد شو

        var context = new ValidationContext<TRequest>(request);
        var results = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, ct)));
        var failures = results.SelectMany(r => r.Errors).ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);  // خطا؟ به Handler نرو

        return await next();  // همه‌چیز اوکی؟ برو به Handler بعدی
    }
}
درخواست از کنترلر می‌آید
↓
🔍 ValidationBehavior — ولیدیتورها را اجرا می‌کند
↓ (اگر معتبر بود)
⚙️ Handler — منطق اصلی اجرا می‌شود

این رفتار در DI ثبت می‌شود تا برای همه درخواست‌ها فعال باشد:

services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddValidatorsFromAssembly(assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
مزیت: Handlerها هرگز کد اعتبارسنجی ندارند. تمیز می‌مانند و فقط روی منطق تمرکز می‌کنند. اعتبارسنجی به‌صورت خودکار و یک‌جا انجام می‌شود.

۵. جریان کامل ساخت یک سفارش

حالا همه را کنار هم بگذاریم. وقتی کلاینت یک POST /api/orders می‌فرستد:

۱. OrdersController — درخواست HTTP را می‌گیرد، به CreateOrderCommand تبدیل می‌کند و _mediator.Send(...) صدا می‌زند
↓
۲. ValidationBehavior — چک می‌کند UserId خالی نباشد، حداقل یک خط باشد، تعدادها مثبت باشند
↓
۳. CreateOrderCommandHandler — کاربر را از سرویس User و کالاها را از سرویس Product می‌گیرد و چک می‌کند
↓
۴. OrderAggregate.Create() — سفارش ساخته می‌شود، جمع محاسبه و رویداد OrderPlacedEvent ثبت می‌شود
↓
۵. OrderRepository.AddAsync() — سفارش (و خطوطش) در دیتابیس ذخیره می‌شوند
↓
۶. Controller — پاسخ 201 Created با Id سفارش برمی‌گرداند

و کنترلر این‌طور خطاها را به کد HTTP درست تبدیل می‌کند:

[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request, CancellationToken ct)
{
    try
    {
        var id = await _mediator.Send(new CreateOrderCommand(
            request.UserId,
            request.Lines.Select(l => new CreateOrderLineRequest(l.ProductId, l.Quantity)).ToList()), ct);
        return CreatedAtAction(nameof(GetById), new { id }, new { id });
    }
    catch (KeyNotFoundException ex) { return NotFound(new { title = ex.Message }); }    // 404
    catch (InvalidOperationException ex) { return Conflict(new { title = ex.Message }); } // 409
}

۶. ذخیره‌سازی در دیتابیس

در OrderDbContext فقط یک DbSet داریم — برای ریشه:

public sealed class OrderDbContext : DbContext
{
    public DbSet<OrderAggregate> Orders => Set<OrderAggregate>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderDbContext).Assembly);
        base.OnModelCreating(modelBuilder);
    }
}

۶.۱ EF Core چطور کلاس Configuration را پیدا می‌کند؟

شاید بپرسی: ما کلاس OrderAggregateConfiguration را نوشتیم، اما هیچ‌جا new نکردیم و در DI هم ثبتش نکردیم. پس EF Core از کجا آن را می‌شناسد؟ جواب همین یک خط است:

modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderDbContext).Assembly);

این خط به EF Core می‌گوید:

«در کل این Assembly (پروژه Order.Infrastructure) بگردید و هر کلاسی که IEntityTypeConfiguration<T> را پیاده کرده پیدا کنید، یک نمونه از آن بسازید و متد Configure را فراخوانی کنید.»

قرارداد یک کلاس Configuration

برای اینکه EF Core یک کلاس را به‌صورت خودکار پیدا کند، فقط سه شرط لازم است:

  • کلاس public باشد
  • اینترفیس IEntityTypeConfiguration<T> را پیاده کند
  • در همان Assembly باشد که ApplyConfigurationsFromAssembly روی آن صدا زده شده
//          نام دلخواه                          قرارداد ↓        برای کدام entity ↓
public sealed class OrderAggregateConfiguration : IEntityTypeConfiguration<OrderAggregate>
{
    public void Configure(EntityTypeBuilder<OrderAggregate> builder)
    {
        // EF Core این متد را خودش صدا می‌زند
    }
}

EF Core پشت پرده چه می‌کند؟

به‌صورت ساده‌شده، چیزی شبیه این اجرا می‌شود:

// شبه‌کد داخل EF Core
foreach (var type in assembly.GetTypes())
{
    if (type implements IEntityTypeConfiguration<SomeEntity>)
    {
        var instance = Activator.CreateInstance(type);   // ساخت نمونه
        instance.Configure(modelBuilder.Entity<SomeEntity>());  // اجرای Configure
    }
}

در پروژه ما دو کلاس پیدا و اجرا می‌شوند:

کلاس Configurationبرای کدام entityجدول
OrderAggregateConfigurationOrderAggregateOrders
OrderLineConfigurationOrderLineOrderLines

کل جریان از ابتدا

برنامه بالا می‌آید و اولین بار OrderDbContext استفاده می‌شود
↓
EF Core متد OnModelCreating را صدا می‌زند
↓
ApplyConfigurationsFromAssembly همه کلاس‌های IEntityTypeConfiguration را اسکن می‌کند
↓
برای هرکدام، متد Configure اجرا می‌شود (نگاشت جدول‌ها، کلیدها، رابطه‌ها)
↓
مدل EF Core ساخته و کش می‌شود — آماده برای Migration و Query
مزیت: لازم نیست هر configuration را دستی ثبت کنی. هر وقت کلاس جدیدی با IEntityTypeConfiguration بسازی، خودکار شناسایی می‌شود. به همین خاطر هم OrderLineConfiguration بدون اینکه DbSet داشته باشد، باز هم اعمال می‌شود.
اگر این خط نبود چه می‌شد؟ EF Core فقط از قراردادهای پیش‌فرض استفاده می‌کرد: اسم جدول‌ها اشتباه می‌شد، رابطه _lines درست map نمی‌شد، و DomainEvents و Lines ممکن بود اشتباهاً ستون دیتابیس در نظر گرفته شوند.
سؤال مهم: چرا برای OrderLine یک DbSet نگذاشتیم؟

چون OrderLine یک aggregate مستقل نیست — بخشی از داخل aggregate سفارش است. در DDD فقط ریشه‌ها DbSet می‌گیرند. این کار جلوی دستکاری مستقیم خطوط را می‌گیرد و مرز aggregate را حفظ می‌کند.

اما OrderLine همچنان جدول خودش (OrderLines) را دارد. EF Core از طریق رابطه آن را می‌شناسد:

public void Configure(EntityTypeBuilder<OrderAggregate> builder)
{
    builder.ToTable("Orders");
    builder.HasKey(o => o.Id);
    builder.Property(o => o.TotalAmount).HasPrecision(18, 2);

    // رابطه یک-به-چند با خطوط؛ از فیلد خصوصی _lines استفاده کنید
    builder.HasMany<OrderLine>("_lines")
        .WithOne()
        .HasForeignKey("OrderId")
        .OnDelete(DeleteBehavior.Cascade);      // سفارش حذف شد؟ خطوطش هم برود
    builder.Navigation("_lines").UsePropertyAccessMode(PropertyAccessMode.Field);

    builder.Ignore(o => o.Lines);          // پراپرتی فقط-خواندنی، نگاشت نمی‌شود
    builder.Ignore(o => o.DomainEvents);   // رویدادها در دیتابیس ذخیره نمی‌شوند
}

پس بدون DbSet چطور ذخیره می‌شود؟ وقتی order را اضافه می‌کنید، EF Core کل گراف متصل را دنبال می‌کند:

await _context.Orders.AddAsync(order, ct);   // فقط ریشه را اضافه کردیم
await _context.SaveChangesAsync(ct);          // EF خودش خطوط داخلش را هم INSERT می‌کند

نتیجه SQL تولیدشده:

INSERT INTO Orders (Id, UserId, Status, TotalAmount, CreatedAt) VALUES (...);
INSERT INTO OrderLines (Id, OrderId, ProductId, ProductName, Quantity, UnitPrice) VALUES (...);
INSERT INTO OrderLines (...) VALUES (...);

و موقع خواندن، با Include خطوط را هم می‌آوریم:

return await _context.Orders
    .Include("_lines")          // خطوط را هم بارگذاری کنید
    .FirstOrDefaultAsync(o => o.Id == id, ct);

۷. ارتباط با سرویس‌های دیگر

سرویس Order برای گرفتن اطلاعات User و Product، از طریق HTTP با آن‌ها حرف می‌زند. اما لایه Application نباید بداند HTTP چیست — پس یک Interface تعریف می‌کنیم:

// در لایه Application: فقط قرارداد
public interface IUserServiceClient
{
    Task<UserInfo?> GetUserAsync(Guid userId, CancellationToken ct = default);
}
public sealed record UserInfo(Guid Id, string Email, string FullName, bool IsActive);

و پیاده‌سازی واقعی HTTP در لایه Infrastructure است:

// در لایه Infrastructure: پیاده‌سازی واقعی
public sealed class UserServiceClient : IUserServiceClient
{
    private readonly HttpClient _httpClient;

    public async Task<UserInfo?> GetUserAsync(Guid userId, CancellationToken ct = default)
    {
        var response = await _httpClient.GetAsync($"/api/users/{userId}", ct);
        if (response.StatusCode == HttpStatusCode.NotFound) return null;
        response.EnsureSuccessStatusCode();
        var user = await response.Content.ReadFromJsonAsync<UserApiResponse>(ct);
        return user is null ? null : new UserInfo(user.Id, user.Email, user.FullName, user.IsActive);
    }
}

و در DI به هم وصل می‌شوند:

services.AddHttpClient<IUserServiceClient, UserServiceClient>(client =>
{
    client.BaseAddress = new Uri(userServiceUrl);
});
این الگوی «وارونگی وابستگی» است: لایه Application فقط IUserServiceClient را می‌شناسد. اینکه پشتش HTTP باشد یا gRPC یا حتی یک Mock برای تست، مهم نیست. این باعث می‌شود کد قابل تست و قابل تغییر بماند.

۸. اجرا و تست

چون Order به User و Product وابسته است، بهتر است هر سه را اجرا کنی.

راه ۱: با Docker Compose (ساده‌تر)

docker compose up --build

راه ۲: دستی

dotnet run --project src/User/User.Api
dotnet run --project src/Product/Product.Api
dotnet run --project src/Order/Order.Api

سپس Swagger سرویس Order را باز کنید و یک سفارش بسازید:

POST /api/orders
{
  "userId": "GUID یک کاربر فعال",
  "lines": [
    { "productId": "GUID یک کالای موجود", "quantity": 2 }
  ]
}
متدمسیرکار
POST/api/ordersساخت سفارش
GET/api/orders/{id}دیدن یک سفارش
GET/api/orders/user/{userId}سفارش‌های یک کاربر
PUT/api/orders/{id}/cancelلغو سفارش

جمع‌بندی نهایی

  • Domain = قلب و قوانین (OrderAggregate، OrderLine، Eventها) — مستقل از همه‌چیز
  • Application = هماهنگی سناریوها (Command/Query/Handler) با MediatR و FluentValidation
  • Infrastructure = دیتابیس و ارتباط HTTP با سرویس‌های دیگر
  • Api = درِ ورودی HTTP
  • Aggregate Root تضمین می‌کند سفارش همیشه معتبر بماند
  • CQRS نوشتن و خواندن را جدا می‌کند
  • فقط ریشه DbSet می‌گیرد؛ خطوط از طریق رابطه ذخیره می‌شوند

این فایل برای یادگیری DDD و CQRS با پروژه Order ساخته شده — موفق باشی! 🚀