【问题标题】:How to implement checkout in a DDD-based application?如何在基于 DDD 的应用程序中实现结账?
【发布时间】:2019-02-10 13:30:55
【问题描述】:

首先假设我在电子商务网站中有两个单独的聚合BasketOrder

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; }

}

第二个聚合 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; }
    }

现在,如果用户在将几件商品添加到购物篮后想要结帐,则应该应用几个操作:

  1. 更新购物篮(可能部分商品数量已更改)

  2. 添加/设置新订单

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

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

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

那么请您指导我如何以不违反 DDD 原则的方式实现它(也许通过使用最终一致性)?

PS:

感谢任何参考或资源

【问题讨论】:

    标签: c# domain-driven-design aggregateroot eventual-consistency


    【解决方案1】:

    您的模型缺少的最重要的东西是行为。你的类只保存数据,有时在不应该的时候使用公共设置器(如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}";
            }
        }
    

    以及项目的类:

        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();
            }
        }
    

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

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

    所以,考虑一下我的域的规则:

    • 用户不能将超过 10 个单位的每个产品添加到购物车中;
    • 用户只有在购物车中有至少 50 美元的产品时才能进行结帐。

    这些是由聚合根强制执行的,没有办法以任何可能破坏不变量的方式滥用类。

    您可以在此处查看完整模型:Shopping Cart Model


    回到你的问题

    更新购物篮(可能部分商品数量已更改)

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

    添加/设置新订单

    似乎一个订单将驻留在另一个限界上下文中。在这种情况下,您将拥有像 Basket.ProceedToCheckout 这样的方法,它将自己标记为已关闭并传播一个 DomainEvent,而这又会在 Order Bounded Context 中被拾取并添加/创建一个 Order。

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

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

    您可以调用Basket.ProceedToCheckout,通过将Closed 属性设置为true 来更改其内部状态。那么订单的创建可能会出错,您将不需要回滚篮子。

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

    删除购物篮(或在 DB 中删除标记)

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

    我建议您阅读这篇文章:Don't Delete - Just Don't,作者是 Udi Dahan。他深入研究了这个主题。

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

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

        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);
            }
        }
    

    我希望这能给你一些见解。

    【讨论】:

    • 另外,微软在 GitHub 上的 eShopOnContainers 项目也提供了类似的方法。
    • 终于有人敢回答这个问题了。非常感谢您非常详细的回答,但我不明白关于幂等性的部分。您的意思是如果订单创建未能重新结帐,我应该重新打开购物篮吗?
    • 没问题 :) 您可以重新打开篮子作为对未创建或保存订单的补偿操作,这将作为回滚的一种形式,这在某些情况下是合适的,但这不是我的意思。通过幂等性,我的意思是您可以检查篮子是否已被标记为已关闭,如果是,只需从您停止的地方拿起 - 再次尝试创建并保存订单。
    • 这将使您的系统更加健壮,并为业务创造更多价值。未保存的订单是一个技术问题,不应阻止用户结帐。捕获结帐意图、修复错误并尽快恢复对业务是有益的。适当的业务操作可能是通过电子邮件向客户发送恢复结帐的链接,而不是回滚整个事情。我的意思是,这是由业务专家来决定的,但从业务角度来看,这通常是最有意义的。
    猜你喜欢
    • 2021-05-10
    • 2011-06-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-01-04
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多