如何在基于 DDD 的应用程序中实现结帐?
How to implement checkout in a DDD-based application?
首先假设我在一个电子商务网站上有两个独立的集合 Basket 和 Order。
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 作为实体,Address 和 CatalogueItemOrdered 作为值对象定义如下:
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; }
}
现在如果用户想在将几件商品添加到购物车后结账,则应执行以下几个操作:
正在更新购物篮(可能部分商品的数量已更改)
Adding/Setting新订单
正在删除篮子(或在数据库中标记为已删除)
使用特定支付网关通过信用卡支付。
正如我所见,应该执行多个事务,因为根据 DDD,每个事务中只应更改一个聚合。
那么您能否指导我如何以不违反 DDD 原则的方式实现它(也许通过使用最终一致性)?
PS:
我感谢任何参考或资源
您的模型缺少的最重要的东西是行为。您的 classes 仅保存数据,有时在不应该使用 public setter 的情况下(例如 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}";
}
}
以及项目的 class:
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();
}
}
需要注意的一些重要事项:
Cart
和 CartItem
是一回事。它们作为一个单元从数据库中加载,然后在一个事务中如此持久化;
- 数据和操作(行为)紧密相连。这实际上不是 DDD 规则或指南,而是面向对象的编程原则。这就是 OO 的意义所在;
- 有人可以对模型执行的每个操作都表示为聚合根中的一个方法,聚合根在处理其内部对象时会处理所有这些操作。它控制着一切,每一个操作都必须经过根;
- 对于每个可能出错的操作,都有一种验证方法。例如,您有
CanAdd
和 Add
方法。此 class 的使用者应首先调用 CanAdd
并将可能的错误传播给用户。如果在没有事先验证的情况下调用 Add
,那么 Add
将检查 CanAdd
并在违反任何不变量时抛出异常,抛出异常是正确的做法,因为未经 CanAdd
检查就进入 Add
表示软件中存在错误,程序员犯了一个错误;
Cart
是一个实体,它有一个 Id,但是 CartItem
是一个没有 Id 的 ValueObject。客户可以重复购买相同的商品,它仍然是一个不同的购物车,但是具有相同属性(数量、价格、商品名称)的 CartItem 始终是相同的——它是构成其身份的属性的组合.
所以,请考虑我的域的规则:
- 用户不能将超过 10 件的每种产品添加到购物车;
- 用户只有在购物车中有至少 50 美元的产品时才能继续结帐。
这些由聚合根强制执行,无法以任何允许破坏不变量的方式滥用 classes。
您可以在此处查看完整模型:Shopping Cart Model
回到你的问题
Updating Basket (maybe some items' quantity has been changed)
在 Basket
class 中有一个方法负责对购物篮项目进行操作更改(添加、删除、更改数量)。
Adding/Setting new Order
似乎订单将驻留在另一个限界上下文中。在这种情况下,您将有一个像 Basket.ProceedToCheckout
这样的方法,它会将自己标记为已关闭并传播一个 DomainEvent,该事件又会在 Order 限界上下文中被拾取,并且 Order 将是 added/created。
但是如果您决定域中的订单与篮子属于同一个 BC,您可以拥有一个同时处理两个聚合的 DomainService:它会调用 Basket.ProceedToCheckout
并且,如果不会抛出任何错误,它会从中创建一个 Order
聚合。请注意,这是一个跨越两个聚合的操作,因此它已从聚合移动到 DomainService。
请注意,此处不需要数据库事务以确保域状态的正确性。
您可以调用 Basket.ProceedToCheckout
,这将通过将 Closed
属性 设置为 true
来更改其内部状态。那么订单的创建可能会出错,您 不需要 需要回滚篮子。
您可以修复软件中的错误,客户可以尝试再次结帐,您的逻辑将简单地检查购物篮是否已经关闭并有相应的订单。如果没有,它将只执行必要的步骤,跳过那些已经完成的步骤。这就是我们所说的幂等性.
Deleting the basket(or flag as deleted in DB)
你真的应该多考虑一下。与领域专家交谈,因为我们不会删除现实世界中的任何内容,您可能不应该删除您域中的篮子。因为这是最有可能对业务有价值的信息,比如知道哪些篮子被遗弃了,然后是营销部门。可以通过打折促销活动来吸引这些客户回头购买。
我建议您阅读这篇文章:Don't Delete - Just Don't,作者 Udi Dahan。他深入研究了这个主题。
Paying via CreditCard using specific Payment gateway
支付网关是基础设施,您的域不应该对此一无所知(甚至接口也应该在另一层声明)。在软件架构方面,更具体地说,在 Onion Architecture 中,我建议您定义这些 classes:
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);
}
}
我希望这能给你一些见识。
首先假设我在一个电子商务网站上有两个独立的集合 Basket 和 Order。
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 作为实体,Address 和 CatalogueItemOrdered 作为值对象定义如下:
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; }
}
现在如果用户想在将几件商品添加到购物车后结账,则应执行以下几个操作:
正在更新购物篮(可能部分商品的数量已更改)
Adding/Setting新订单
正在删除篮子(或在数据库中标记为已删除)
使用特定支付网关通过信用卡支付。
正如我所见,应该执行多个事务,因为根据 DDD,每个事务中只应更改一个聚合。
那么您能否指导我如何以不违反 DDD 原则的方式实现它(也许通过使用最终一致性)?
PS:
我感谢任何参考或资源
您的模型缺少的最重要的东西是行为。您的 classes 仅保存数据,有时在不应该使用 public setter 的情况下(例如 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}";
}
}
以及项目的 class:
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();
}
}
需要注意的一些重要事项:
Cart
和CartItem
是一回事。它们作为一个单元从数据库中加载,然后在一个事务中如此持久化;- 数据和操作(行为)紧密相连。这实际上不是 DDD 规则或指南,而是面向对象的编程原则。这就是 OO 的意义所在;
- 有人可以对模型执行的每个操作都表示为聚合根中的一个方法,聚合根在处理其内部对象时会处理所有这些操作。它控制着一切,每一个操作都必须经过根;
- 对于每个可能出错的操作,都有一种验证方法。例如,您有
CanAdd
和Add
方法。此 class 的使用者应首先调用CanAdd
并将可能的错误传播给用户。如果在没有事先验证的情况下调用Add
,那么Add
将检查CanAdd
并在违反任何不变量时抛出异常,抛出异常是正确的做法,因为未经CanAdd
检查就进入Add
表示软件中存在错误,程序员犯了一个错误; Cart
是一个实体,它有一个 Id,但是CartItem
是一个没有 Id 的 ValueObject。客户可以重复购买相同的商品,它仍然是一个不同的购物车,但是具有相同属性(数量、价格、商品名称)的 CartItem 始终是相同的——它是构成其身份的属性的组合.
所以,请考虑我的域的规则:
- 用户不能将超过 10 件的每种产品添加到购物车;
- 用户只有在购物车中有至少 50 美元的产品时才能继续结帐。
这些由聚合根强制执行,无法以任何允许破坏不变量的方式滥用 classes。
您可以在此处查看完整模型:Shopping Cart Model
回到你的问题
Updating Basket (maybe some items' quantity has been changed)
在 Basket
class 中有一个方法负责对购物篮项目进行操作更改(添加、删除、更改数量)。
Adding/Setting new Order
似乎订单将驻留在另一个限界上下文中。在这种情况下,您将有一个像 Basket.ProceedToCheckout
这样的方法,它会将自己标记为已关闭并传播一个 DomainEvent,该事件又会在 Order 限界上下文中被拾取,并且 Order 将是 added/created。
但是如果您决定域中的订单与篮子属于同一个 BC,您可以拥有一个同时处理两个聚合的 DomainService:它会调用 Basket.ProceedToCheckout
并且,如果不会抛出任何错误,它会从中创建一个 Order
聚合。请注意,这是一个跨越两个聚合的操作,因此它已从聚合移动到 DomainService。
请注意,此处不需要数据库事务以确保域状态的正确性。
您可以调用 Basket.ProceedToCheckout
,这将通过将 Closed
属性 设置为 true
来更改其内部状态。那么订单的创建可能会出错,您 不需要 需要回滚篮子。
您可以修复软件中的错误,客户可以尝试再次结帐,您的逻辑将简单地检查购物篮是否已经关闭并有相应的订单。如果没有,它将只执行必要的步骤,跳过那些已经完成的步骤。这就是我们所说的幂等性.
Deleting the basket(or flag as deleted in DB)
你真的应该多考虑一下。与领域专家交谈,因为我们不会删除现实世界中的任何内容,您可能不应该删除您域中的篮子。因为这是最有可能对业务有价值的信息,比如知道哪些篮子被遗弃了,然后是营销部门。可以通过打折促销活动来吸引这些客户回头购买。
我建议您阅读这篇文章:Don't Delete - Just Don't,作者 Udi Dahan。他深入研究了这个主题。
Paying via CreditCard using specific Payment gateway
支付网关是基础设施,您的域不应该对此一无所知(甚至接口也应该在另一层声明)。在软件架构方面,更具体地说,在 Onion Architecture 中,我建议您定义这些 classes:
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);
}
}
我希望这能给你一些见识。