如何在基于DDD的应用程序中实现检出?

Sim*_*ode 5 c# domain-driven-design eventual-consistency aggregateroot

首先,让我们说一个电子商务网站上有两个独立的汇总购物订单

Basket聚合有两个实体Basket(这是聚合根)和BaskItem定义如下(为简单起见,我删除了工厂和其他聚合方法):

public class Basket : BaseEntity, IAggregateRoot
{
    public int Id { get; set; }

    public string BuyerId { get; private set; }

    private readonly List<BasketItem> items = new List<BasketItem>();

    public  IReadOnlyCollection<BasketItem> Items
    {
            get
            {
                return items.AsReadOnly();
            }
     }

}

public class BasketItem : BaseEntity
{
    public int Id { get; set; }

    public decimal UnitPrice { get; private set; }

    public int Quantity { get; private set; }

    public string CatalogItemId { get; private set; }

}
Run Code Online (Sandbox Code Playgroud)

第二个聚合是Order,其具有Order作为聚合根,OrderItem作为实体,AddressCatalogueItemOrdered作为值对象,定义如下:

public class Order : BaseEntity, IAggregateRoot
    {
        public int Id { get; set; }

        public string BuyerId { get; private set; }

        public readonly List<OrderItem> orderItems = new List<OrderItem>();

        public IReadOnlyCollection<OrderItem> OrderItems
        {
            get
            {
                return orderItems.AsReadOnly();
            }
        }

        public DateTimeOffset OrderDate { get; private set; } = DateTimeOffset.Now;

        public Address DeliverToAddress { get; private set; }

        public string Notes { get; private set; }

    }

    public class OrderItem : BaseEntity
    {
        public int Id { get; set; }
        public CatalogItemOrdered ItemOrdered { get; private set; }
        public decimal Price { get; private set; }
        public int Quantity { get; private set; }
    }

    public class CatalogItemOrdered
    {
        public int CatalogItemId { get; private set; }
        public string CatalogItemName { get; private set; }
        public string PictureUri { get; private set; }
    }

    public class Address
    {
        public string Street { get; private set; }

        public string City { get; private set; }

        public string State { get; private set; }

        public string Country { get; private set; }

        public string ZipCode { get; private set; }
    }
Run Code Online (Sandbox Code Playgroud)

现在,如果用户想在将多个项目添加到购物篮后结帐,则应执行几个操作:

  1. 更新购物篮(也许某些物品的数量已更改)

  2. 添加/设置新订单

  3. 删除购物篮(或在数据库中标记为已删除)

  4. 使用特定的付款网关通过信用卡付款。

如我所见,应该执行多个事务,因为根据每个事务中的DDD,只应更改一个聚合。

因此,能否请您指导我如何以不违反DDD原则的方式(可能通过使用最终一致性)实现该目标?

PS:

我感谢任何参考或资源

Phi*_*ana 6

您的模型缺少的最重要的事情是行为。您的类只保存数据,有时不应该使用公共设置器(例如Basket.Id)。域实体必须定义对其数据进行操作的方法。

您正确的是,您拥有包含其子项的聚合根(例如带有私人项目列表的篮子)。聚合应该被视为一个原子,因此每次您将篮子加载或保存到数据库时,您都会将篮子和项目视为一个整体。这甚至会让事情变得更容易。

这是我的一个非常相似领域的模型:

    public class Cart : AggregateRoot
    {
        private const int maxQuantityPerProduct = 10;
        private const decimal minCartAmountForCheckout = 50m;

        private readonly List<CartItem> items = new List<CartItem>();

        public Cart(EntityId customerId) : base(customerId)
        {
            CustomerId = customerId;
            IsClosed = false;
        }

        public EntityId CustomerId { get; }
        public bool IsClosed { get; private set; }

        public IReadOnlyList<CartItem> Items => items;
        public decimal TotalAmount => items.Sum(item => item.TotalAmount);

        public Result CanAdd(Product product, Quantity quantity)
        {
            var newQuantity = quantity;

            var existing = items.SingleOrDefault(item => item.Product == product);
            if (existing != null)
                newQuantity += existing.Quantity;

            if (newQuantity > maxQuantityPerProduct)
                return Result.Fail("Cannot add more than 10 units of each product.");

            return Result.Ok();
        }

        public void Add(Product product, Quantity quantity)
        {
            CanAdd(product, quantity)
                .OnFailure(error => throw new Exception(error));

            for (int i = 0; i < items.Count; i++)
            {
                if (items[i].Product == product)
                {
                    items[i] = items[i].Add(quantity);
                    return;
                }
            }

            items.Add(new CartItem(product, quantity));
        }

        public void Remove(Product product)
        {
            var existing = items.SingleOrDefault(item => item.Product == product);

            if (existing != null)
                items.Remove(existing);
        }

        public void Remove(Product product, Quantity quantity)
        {
            var existing = items.SingleOrDefault(item => item.Product == product);

            for (int i = 0; i < items.Count; i++)
            {
                if (items[i].Product == product)
                {
                    items[i] = items[i].Remove(quantity);
                    return;
                }
            }

            if (existing != null)
                existing = existing.Remove(quantity);
        }

        public Result CanCloseForCheckout()
        {
            if (IsClosed)
                return Result.Fail("The cart is already closed.");

            if (TotalAmount < minCartAmountForCheckout)
                return Result.Fail("The total amount should be at least 50 dollars in order to proceed to checkout.");

            return Result.Ok();
        }

        public void CloseForCheckout()
        {
            CanCloseForCheckout()
                .OnFailure(error => throw new Exception(error));

            IsClosed = true;
            AddDomainEvent(new CartClosedForCheckout(this));
        }

        public override string ToString()
        {
            return $"{CustomerId}, Items {items.Count}, Total {TotalAmount}";
        }
    }
Run Code Online (Sandbox Code Playgroud)

以及项目的类:

    public class CartItem : ValueObject<CartItem>
    {
        internal CartItem(Product product, Quantity quantity)
        {
            Product = product;
            Quantity = quantity;
        }

        public Product Product { get; }
        public Quantity Quantity { get; }
        public decimal TotalAmount => Product.UnitPrice * Quantity;

        public CartItem Add(Quantity quantity)
        {
            return new CartItem(Product, Quantity + quantity); 
        }

        public CartItem Remove(Quantity quantity)
        {
            return new CartItem(Product, Quantity - quantity);
        }

        public override string ToString()
        {
            return $"{Product}, Quantity {Quantity}";
        }

        protected override bool EqualsCore(CartItem other)
        {
            return Product == other.Product && Quantity == other.Quantity;
        }

        protected override int GetHashCodeCore()
        {
            return Product.GetHashCode() ^ Quantity.GetHashCode();
        }
    }
Run Code Online (Sandbox Code Playgroud)

需要注意的一些重要事项:

  1. Cart并且CartItem是一回事。它们作为一个单元从数据库中加载,然后在一个事务中保持原样;
  2. 数据和操作(行为)紧密相连。这实际上不是 DDD 规则或指南,而是面向对象的编程原则。这就是面向对象的全部内容;
  3. 某人可以对模型执行的每个操作都表示为聚合根中的一个方法,而聚合根在处理其内部对象时会处理所有这些。它控制着一切,每一个操作都要经过根;
  4. 对于每个可能出错的操作,都有一个验证方法。例如,您有CanAddAdd方法。此类的使用者应首先调用CanAdd并将可能的错误传播给用户。如果Add在没有事先验证的情况下调用,那么如果违反任何不变量,Add则将检查CanAdd并抛出异常,在这里抛出异常是正确的事情,因为在Add没有首先检查的情况下进入CanAdd代表软件中的错误,错误由承诺的程序员;
  5. Cart是一个实体,它有一个 Id,但它CartItem是一个 ValueObject 并且没有 Id。客户可以使用相同的商品重复购买,但它仍然是不同的购物车,但具有相同属性(数​​量、价格、商品名称)的 CartItem 始终相同 - 构成其身份的是其属性的组合.

因此,请考虑我的域的规则:

  • 用户在购物车中添加的每个产品不能超过 10 件;
  • 用户只有在购物车中有至少 50 美元的产品时才能继续结账。

这些由聚合根强制执行,并且无法以任何方式滥用类来破坏不变量。

您可以在此处查看完整模型:购物车模型


回到你的问题

更新篮子(可能有些物品的数量已更改)

类中有一个方法Basket将负责操作对购物篮项目的更改(添加、删除、更改数量)。

添加/设置新订单

似乎订单将驻留在另一个有界上下文中。在这种情况下,您将拥有一个类似的方法Basket.ProceedToCheckout,它将自身标记为已关闭并传播一个域事件,该域事件又将在订单有界上下文中被拾取,并且将添加/创建一个订单。

但是,如果您决定域中的 Order 与 Basket 属于同一 BC 的一部分,则您可以拥有一个 DomainService 来同时处理两个聚合:它会调用Basket.ProceedToCheckout,如果没有抛出错误,它会创建一个Order从中聚合。请注意,这是一个跨越两个聚合的操作,因此它已从聚合移动到 DomainService。

请注意,此处不需要数据库事务以确保域状态的正确性。

您可以调用Basket.ProceedToCheckout,这将通过将Closed属性设置为来更改其内部状态true。那么该命令的创建可以去错了,你会不会需要回滚篮。

您可以修复软件中的错误,客户可以再次尝试结帐,您的逻辑将简单地检查篮子是否已经关闭并具有相应的订单。如果没有,它将只执行必要的步骤,跳过那些已经完成的步骤。这就是我们所说的幂等性

删除篮子(或在 DB 中标记为已删除)

你真的应该多考虑一下。与领域专家交谈,因为我们不会删除现实世界中的任何内容,您可能不应该删除域中的篮子。因为这是最有可能对业务有价值的信息,比如知道哪些篮子被放弃了,然后营销部门。可以通过折扣促销活动来吸引这些客户,以便他们可以购买。

我建议您阅读这篇文章:不要删除 - 只是不要,由 Udi Dahan撰写。他深入研究这个主题。

使用特定的支付网关通过信用卡支付

支付网关是基础设施,您的域不应该对此一无所知(即使接口也应该在另一层中声明)。在软件架构方面,更具体地说,在 Onion Architecture 中,我建议您定义这些类:

    namespace Domain
    {
        public class PayOrderCommand : ICommand
        {
            public Guid OrderId { get; }
            public PaymentInformation PaymentInformation { get; }

            public PayOrderCommand(Guid orderId, PaymentInformation paymentInformation)
            {
                OrderId = orderId;
                PaymentInformation = paymentInformation;
            }
        }
    }

    namespace Application
    {
        public class PayOrderCommandHandler : ICommandHandler<PayOrderCommand>
        {
            private readonly IPaymentGateway paymentGateway;
            private readonly IOrderRepository orderRepository;

            public PayOrderCommandHandler(IPaymentGateway paymentGateway, IOrderRepository orderRepository)
            {
                this.paymentGateway = paymentGateway;
                this.orderRepository = orderRepository;
            }

            public Result Handle(PayOrderCommand command)
            {
                var order = orderRepository.Find(command.OrderId);
                var items = GetPaymentItems(order);

                var result = paymentGateway.Pay(command.PaymentInformation, items);

                if (result.IsFailure)
                    return result;

                order.MarkAsPaid();
                orderRepository.Save(order);

                return Result.Ok();
            }

            private List<PaymentItems> GetPaymentItems(Order order)
            {
                // TODO: convert order items to payment items.
            }
        }

        public interface IPaymentGateway
        {
            Result Pay(PaymentInformation paymentInformation, IEnumerable<PaymentItems> paymentItems);
        }
    }
Run Code Online (Sandbox Code Playgroud)

我希望这给了你一些见识。

  • 没问题:)您可以重新打开购物篮作为未创建或保存订单的补偿操作,这将作为回滚的一种形式,这在某些情况下是合适的,但这不是我的意思。我所说的幂等性是指您可以检查购物篮是否已被标记为关闭,如果是,只需从您停止的位置开始 - 尝试再次创建并保存订单。 (2认同)
  • 这将使您的系统更加强大,并为企业创造更多价值。未保存的订单是一个技术问题,不应阻止用户结账。您捕捉结账意图、修复错误并尽快恢复,这对企业是有利的。适当的业务行动可能是通过电子邮件向客户发送包含恢复结帐的链接,而不是回滚整个操作。我的意思是,这由业务专家决定,但通常从业务角度来看这是最有意义的。 (2认同)