MidПрактика3 min

Шаг 6: Сущность Order -- ядро проекта

Создание Aggregate Root Order с бизнес-правилами, OrderItem как внутренняя сущность, фабричный метод Create и жизненный цикл заказа

Шаг 6: Сущность Order

Order -- Aggregate Root, самый важный класс проекта. Все изменения внутри агрегата проходят только через его методы.

OrderItem.cs -- позиция заказа

В папке Entities:

using OrderManagement.Domain.Common;
using OrderManagement.Domain.Exceptions;
using OrderManagement.Domain.ValueObjects;

namespace OrderManagement.Domain.Entities;

public class OrderItem : Entity
{
    public string ProductId { get; private set; }
    public string ProductName { get; private set; }
    public Money UnitPrice { get; private set; }
    public int Quantity { get; private set; }
    public Money TotalPrice => UnitPrice.Multiply(Quantity);

    private OrderItem() { }

    internal OrderItem(string productId, string productName,
        Money unitPrice, int quantity)
    {
        Id = Guid.NewGuid();
        ProductId = productId
            ?? throw new DomainException("ProductId is required");
        ProductName = productName
            ?? throw new DomainException("ProductName is required");
        UnitPrice = unitPrice
            ?? throw new DomainException("UnitPrice is required");
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive");
        if (quantity > 1000)
            throw new DomainException("Max quantity is 1000");
        Quantity = quantity;
    }

    internal void IncreaseQuantity(int additionalQuantity)
    {
        if (additionalQuantity <= 0)
            throw new DomainException("Quantity must be positive");
        Quantity += additionalQuantity;
    }
}

Конструктор internal -- создать OrderItem можно только из проекта Domain. TotalPrice -- вычисляемое свойство, не хранится в БД.

Order.cs -- Aggregate Root

using OrderManagement.Domain.Common;
using OrderManagement.Domain.Enums;
using OrderManagement.Domain.Events;
using OrderManagement.Domain.Exceptions;
using OrderManagement.Domain.ValueObjects;

namespace OrderManagement.Domain.Entities;

public class Order : Entity, IAggregateRoot
{
    private readonly List<OrderItem> _items = new();

    public string CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public ShippingAddress ShippingAddress { get; private set; }
    public Money TotalAmount { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public IReadOnlyCollection<OrderItem> Items =>
        _items.AsReadOnly();

    private Order() { }

    public static Order Create(
        string customerId, ShippingAddress address)
    {
        if (string.IsNullOrWhiteSpace(customerId))
            throw new DomainException("Customer ID is required");
        if (address is null)
            throw new DomainException("Address is required");

        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = customerId,
            ShippingAddress = address,
            Status = OrderStatus.Draft,
            TotalAmount = Money.Zero(),
            CreatedAt = DateTime.UtcNow
        };

        order.AddDomainEvent(
            new OrderCreatedEvent(order.Id, customerId));
        return order;
    }

    public void AddItem(string productId, string productName,
        Money unitPrice, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException(
                "Can only add items to draft orders");
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive");

        var existing = _items.FirstOrDefault(
            i => i.ProductId == productId);
        if (existing != null)
            existing.IncreaseQuantity(quantity);
        else
            _items.Add(new OrderItem(
                productId, productName, unitPrice, quantity));

        RecalculateTotal();
    }

    public void Confirm()
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException(
                "Only draft orders can be confirmed");
        if (!_items.Any())
            throw new DomainException(
                "Cannot confirm an empty order");

        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmedEvent(Id, TotalAmount));
    }

    public void Cancel(string reason)
    {
        if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
            throw new DomainException(
                "Cannot cancel shipped or delivered orders");
        if (string.IsNullOrWhiteSpace(reason))
            throw new DomainException("Reason is required");

        Status = OrderStatus.Cancelled;
        AddDomainEvent(new OrderCancelledEvent(Id, reason));
    }

    private void RecalculateTotal()
    {
        TotalAmount = _items
            .Select(i => i.TotalPrice)
            .Aggregate(Money.Zero(), (acc, m) => acc.Add(m));
    }
}

Фабричный метод Create вместо публичного конструктора: при создании генерируется событие. private set для всех свойств -- состояние меняется только через бизнес-методы.

Проверь себя

Почему Order использует фабричный метод Create вместо конструктора?

Почему RecalculateTotal -- приватный метод?

Что произойдёт при попытке добавить товар в подтверждённый заказ?