Самое важное, чего не хватает вашей модели - это поведение. Ваши классы содержат только данные, иногда с общедоступными установщиками, когда они не должны (например, 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();
}
}
Некоторые важные вещи, на которые стоит обратить внимание:
Cart
и CartItem
- это одно. Они загружаются из базы данных как единое целое, а затем сохраняются как таковые в одной транзакции;
- Данные и операции (поведение) близки друг к другу. На самом деле это не правило или руководство DDD, а принцип объектно-ориентированного программирования. Это то, что ОО все о;
- Каждая операция, которую кто-либо может сделать с моделью, выражается в виде метода в совокупном корне, и сводный корень заботится обо всем, когда речь идет о работе со своими внутренними объектами. Он контролирует все, каждая операция должна проходить через корень;
- Для каждой операции, которая потенциально может пойти не так, есть метод проверки. Например, у вас есть методы
CanAdd
и Add
. Потребители этого класса должны сначала вызвать CanAdd
и сообщить о возможных ошибках пользователю. Если Add
вызывается без предварительной проверки, то Add
проверит с помощью CanAdd
и сгенерирует исключение, если какой-либо инвариант должен быть нарушен, и выбрасывание исключения - это правильная вещь, потому что получение Add
без первая проверка с CanAdd
представляет ошибку в программном обеспечении, ошибку, совершенную программистами;
Cart
- это объект, у него есть Id, но CartItem
- это ValueObject, у которого нет Id. Покупатель может повторить покупку с теми же предметами, и это все равно будет другая корзина, но CartItem с одинаковыми свойствами (количество, цена, наименование товара) всегда одинаковы - это комбинация его свойств, которые составляют его идентичность .
Итак, рассмотрим правила моего домена:
- Пользователь не может добавить более 10 единиц каждого продукта в корзину;
- Пользователь может перейти к оформлению заказа только в том случае, если в его корзине было не менее 50 долларов США.
Они применяются агрегатным корнем, и нет никакого способа неправильно использовать классы любым способом, который позволил бы нарушить инварианты.
Вы можете увидеть полную модель здесь: Корзина Модель
Вернуться к вашему вопросу
Обновление корзины (возможно, количество некоторых предметов было изменено)
В классе Basket
есть метод, который будет отвечать за оперативные изменения позиций корзины (добавление, удаление, изменение количества).
Добавление / настройка нового заказа
Похоже, Орден будет находиться в другом Ограниченном Контексте. В этом случае у вас будет такой метод, как Basket.ProceedToCheckout
, который будет отмечать себя как закрытый и распространять DomainEvent, который, в свою очередь, будет выбран в контексте ограниченного порядка, и будет добавлен / создан порядок.
Но если вы решите, что Заказ в вашем домене является частью той же BC, что и Корзина, вы можете иметь DomainService, который будет работать с двумя агрегатами одновременно: он вызовет Basket.ProceedToCheckout
и, если не будет выдано никакой ошибки было бы создать агрегат Order
из него. Обратите внимание, что это операция, охватывающая два агрегата, и поэтому она была перемещена из агрегата в DomainService.
Обратите внимание, что транзакция с базой данных здесь не требуется для обеспечения правильности состояния домена.
Вы можете вызвать Basket.ProceedToCheckout
, что изменит его внутреннее состояние, установив для свойства Closed
значение true
. Тогда создание Ордена может пойти не так, и вам не потребуется откатить Корзину.
Вы можете исправить ошибку в программном обеспечении, клиент может попытаться еще раз оформить заказ, и ваша логика просто проверит, закрыта ли корзина и имеется ли соответствующий ордер. Если нет, он будет выполнять только необходимые шаги, пропуская те, которые уже выполнены. Это то, что мы называем Идемпотентность .
Удаление корзины (или пометить как удаленную в БД)
Тебе стоит подумать об этом больше. Поговорите со специалистами по доменам, потому что мы ничего не удаляем из реального мира, и вам, вероятно, не следует удалять корзину в вашем домене. Потому что эта информация, скорее всего, имеет ценность для бизнеса, например, знание того, какие корзины были оставлены, а затем отдел маркетинга. может продвигать акции со скидками, чтобы вернуть этих клиентов, чтобы они могли покупать.
Я рекомендую вам прочитать эту статью: Не удалять - просто не , автор 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);
}
}
Надеюсь, это дало вам некоторое представление.