From 3710d0c855852133905e701b4e42950a8de671a9 Mon Sep 17 00:00:00 2001 From: Fredi Machado Date: Thu, 23 May 2024 22:18:41 +1000 Subject: [PATCH] Improved the Cashier domain. TODO: update UI Added CreateOrder, AddItemToOrder, PlaceOrder commands and its related events/handlers. --- Directory.Packages.props | 1 + src/Cashier/NCafe.Cashier.Api/Program.cs | 24 +++- .../Commands/AddItemTests.cs | 112 ++++++++++++++++++ .../Commands/CreateOrderTests.cs | 40 +++++++ .../Commands/PayForOrderTests.cs | 66 ----------- .../Commands/PlaceOrderTests.cs | 71 ++++++----- .../Entities/OrderTests.cs | 31 ++--- .../NCafe.Cashier.Domain.Tests.csproj | 1 + .../Commands/AddItemToOrder.cs | 29 +++++ .../Commands/CreateOrder.cs | 22 ++++ .../Commands/PayForOrder.cs | 28 ----- .../Commands/PlaceOrder.cs | 33 ++++-- .../NCafe.Cashier.Domain/Entities/Order.cs | 77 ++++++++---- .../Events/OrderCreated.cs | 16 +++ .../Events/OrderItemAdded.cs | 19 +++ .../Events/OrderPaidFor.cs | 11 -- .../Events/OrderPlaced.cs | 13 +- .../CannotAddItemToOrderException.cs | 9 ++ .../CannotPlaceEmptyOrderException.cs | 9 ++ .../Exceptions/CannotPlaceOrderException.cs | 9 ++ .../Messages/OrderPlaced.cs | 26 +++- .../ValueObjects/Customer.cs | 19 +++ .../ValueObjects/OrderItem.cs | 24 ++++ .../Architecture/InfrastructureTests.cs | 2 +- src/Common/NCafe.Core/NCafe.Core.csproj | 4 + .../DependencyRegistration.cs | 2 +- .../RabbitMQ/RabbitMqPublisher.cs | 6 +- 27 files changed, 498 insertions(+), 206 deletions(-) create mode 100644 src/Cashier/NCafe.Cashier.Domain.Tests/Commands/AddItemTests.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain.Tests/Commands/CreateOrderTests.cs delete mode 100644 src/Cashier/NCafe.Cashier.Domain.Tests/Commands/PayForOrderTests.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Commands/AddItemToOrder.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Commands/CreateOrder.cs delete mode 100644 src/Cashier/NCafe.Cashier.Domain/Commands/PayForOrder.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Events/OrderCreated.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Events/OrderItemAdded.cs delete mode 100644 src/Cashier/NCafe.Cashier.Domain/Events/OrderPaidFor.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotAddItemToOrderException.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotPlaceEmptyOrderException.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotPlaceOrderException.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/ValueObjects/Customer.cs create mode 100644 src/Cashier/NCafe.Cashier.Domain/ValueObjects/OrderItem.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ecf694a..bf3ea1f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/src/Cashier/NCafe.Cashier.Api/Program.cs b/src/Cashier/NCafe.Cashier.Api/Program.cs index d7651ac..f19ee99 100644 --- a/src/Cashier/NCafe.Cashier.Api/Program.cs +++ b/src/Cashier/NCafe.Cashier.Api/Program.cs @@ -1,10 +1,10 @@ -using NCafe.Cashier.Domain.Queries; +using NCafe.Cashier.Api.Projections; using NCafe.Cashier.Domain.Commands; +using NCafe.Cashier.Domain.Queries; using NCafe.Cashier.Domain.ReadModels; -using NCafe.Infrastructure; -using NCafe.Cashier.Api.Projections; -using NCafe.Core.Queries; using NCafe.Core.Commands; +using NCafe.Core.Queries; +using NCafe.Infrastructure; var builder = WebApplication.CreateBuilder(args); @@ -59,11 +59,25 @@ }) .WithName("GetProducts"); -app.MapPost("/orders", async (ICommandDispatcher commandDispatcher, PlaceOrder command) => +app.MapPost("/orders", async (ICommandDispatcher commandDispatcher, CreateOrder command) => { await commandDispatcher.DispatchAsync(command); return Results.Created("/orders", null); }) +.WithName("CreateOrder"); + +app.MapPost("/orders/{id:guid}/item", async (ICommandDispatcher commandDispatcher, Guid id, AddItemToOrder command) => +{ + await commandDispatcher.DispatchAsync(command); + return Results.Accepted(); +}) +.WithName("AddItemToOrder"); + +app.MapPost("/orders/{id:guid}/place", async (ICommandDispatcher commandDispatcher, Guid id, PlaceOrder command) => +{ + await commandDispatcher.DispatchAsync(command); + return Results.Accepted(); +}) .WithName("PlaceOrder"); app.MapGet("/healthz", () => "OK"); diff --git a/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/AddItemTests.cs b/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/AddItemTests.cs new file mode 100644 index 0000000..6ca4f65 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/AddItemTests.cs @@ -0,0 +1,112 @@ +using NCafe.Cashier.Domain.Commands; +using NCafe.Cashier.Domain.Entities; +using NCafe.Cashier.Domain.Exceptions; +using NCafe.Cashier.Domain.ReadModels; +using NCafe.Cashier.Domain.ValueObjects; +using NCafe.Core.ReadModels; +using NCafe.Core.Repositories; + +namespace NCafe.Cashier.Domain.Tests.Commands; + +public class AddItemTests +{ + private readonly AddItemToOrderHandler _sut; + private readonly IRepository _repository; + private readonly IReadModelRepository _productRepository; + + public AddItemTests() + { + _repository = A.Fake(); + _productRepository = A.Fake>(); + _sut = new AddItemToOrderHandler(_repository, _productRepository); + } + + [Fact] + public async Task ShouldSaveOrder() + { + // Arrange + var orderId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + var quantity = 1; + var command = new AddItemToOrder(orderId, productId, quantity); + + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(new Order(orderId, "cashier-1", DateTimeOffset.UtcNow))); + + A.CallTo(() => _productRepository.GetById(command.ProductId)) + .Returns(new Product { Name = "Latte", Price = 5 }); + + // Act + var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); + + // Assert + exception.ShouldBeNull(); + A.CallTo(() => _repository.Save(A.That.Matches(o => o.Id == orderId && + o.Items.Any(i => i.ProductId == productId && i.Quantity == quantity)))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GivenOrderNotFound_ShouldThrow() + { + // Arrange + var orderId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + var quantity = 1; + var command = new AddItemToOrder(orderId, productId, quantity); + + // Act + var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); + + // Assert + exception.ShouldBeOfType(); + } + + [Fact] + public async Task GivenProductNotFound_ShouldThrow() + { + // Arrange + var orderId = Guid.NewGuid(); + var productId = Guid.NewGuid(); + var quantity = 1; + var command = new AddItemToOrder(orderId, productId, quantity); + + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(new Order(orderId, "cashier-1", DateTimeOffset.UtcNow))); + + A.CallTo(() => _productRepository.GetById(command.ProductId)) + .Returns(null); + + // Act + var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); + + // Assert + exception.ShouldBeOfType(); + } + + [Fact] + public async Task GivenNotNewOrder_ShouldThrow() + { + // Arrange + var orderId = Guid.NewGuid(); + var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow); + var productId = Guid.NewGuid(); + var quantity = 1; + var command = new AddItemToOrder(orderId, productId, quantity); + + order.AddItem(new OrderItem(Guid.NewGuid(), 1, "Latte", 5)); + order.PlaceOrder(new Customer("John Doe"), DateTimeOffset.UtcNow); + + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(order)); + + A.CallTo(() => _productRepository.GetById(command.ProductId)) + .Returns(new Product { Name = "Latte", Price = 5 }); + + // Act + var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); + + // Assert + exception.ShouldBeOfType(); + } +} diff --git a/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/CreateOrderTests.cs b/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/CreateOrderTests.cs new file mode 100644 index 0000000..3a5e92c --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/CreateOrderTests.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Time.Testing; +using NCafe.Cashier.Domain.Commands; +using NCafe.Cashier.Domain.Entities; +using NCafe.Core.Repositories; + +namespace NCafe.Cashier.Domain.Tests.Commands; + +public class CreateOrderTests +{ + private readonly CreateOrderHandler _sut; + private readonly IRepository _repository; + private readonly FakeTimeProvider _timeProvider; + + public CreateOrderTests() + { + _repository = A.Fake(); + _timeProvider = new FakeTimeProvider(); + _sut = new CreateOrderHandler(_repository, _timeProvider); + } + + [Fact] + public async Task ShouldSaveOrder() + { + // Arrange + var createdAt = DateTimeOffset.UtcNow; + _timeProvider.SetUtcNow(createdAt); + var createdBy = "cashier-1"; + var command = new CreateOrder(createdBy); + + // Act + var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); + + // Assert + exception.ShouldBeNull(); + A.CallTo(() => _repository.Save(A.That.Matches(o => o.Status == OrderStatus.New && + o.CreatedBy == createdBy && + o.CreatedAt == createdAt))) + .MustHaveHappenedOnceExactly(); + } +} diff --git a/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/PayForOrderTests.cs b/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/PayForOrderTests.cs deleted file mode 100644 index 04111b0..0000000 --- a/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/PayForOrderTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using NCafe.Cashier.Domain.Commands; -using NCafe.Cashier.Domain.Entities; -using NCafe.Cashier.Domain.Exceptions; -using NCafe.Core.Exceptions; -using NCafe.Core.Repositories; - -namespace NCafe.Cashier.Domain.Tests.Commands; - -public class PayForOrderTests -{ - private readonly PayForOrderHandler _sut; - private readonly IRepository _repository; - - public PayForOrderTests() - { - _repository = A.Fake(); - _sut = new PayForOrderHandler(_repository); - } - - [Fact] - public async Task GivenInvalidOrderId_ShouldThrowException() - { - // Arrange - var command = new PayForOrder(Guid.Empty); - - // Act - var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); - - // Assert - exception.ShouldBeOfType(); - } - - [Fact] - public async Task GivenOrderNotFound_ShouldThrowException() - { - // Arrange - A.CallTo(() => _repository.GetById(A._)) - .Returns((Order)null); - var command = new PayForOrder(Guid.NewGuid()); - - // Act - var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); - - // Assert - exception.ShouldBeOfType(); - } - - [Fact] - public async Task GivenPlacedOrder_WhenPayingForOrder_ShouldUpdateOrder() - { - // Arrange - var orderId = Guid.NewGuid(); - A.CallTo(() => _repository.GetById(orderId)) - .Returns(new Order(orderId, Guid.NewGuid(), 1)); - - var command = new PayForOrder(orderId); - - // Act - var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); - - // Assert - exception.ShouldBeNull(); - A.CallTo(() => _repository.Save(A.That.Matches(o => o.Id == orderId && o.HasBeenPaid == true))) - .MustHaveHappenedOnceExactly(); - } -} diff --git a/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/PlaceOrderTests.cs b/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/PlaceOrderTests.cs index 909f069..11a0db2 100644 --- a/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/PlaceOrderTests.cs +++ b/src/Cashier/NCafe.Cashier.Domain.Tests/Commands/PlaceOrderTests.cs @@ -1,10 +1,9 @@ -using NCafe.Cashier.Domain.Commands; +using Microsoft.Extensions.Time.Testing; +using NCafe.Cashier.Domain.Commands; using NCafe.Cashier.Domain.Entities; using NCafe.Cashier.Domain.Exceptions; -using NCafe.Cashier.Domain.Messages; -using NCafe.Cashier.Domain.ReadModels; +using NCafe.Cashier.Domain.ValueObjects; using NCafe.Core.MessageBus; -using NCafe.Core.ReadModels; using NCafe.Core.Repositories; namespace NCafe.Cashier.Domain.Tests.Commands; @@ -13,69 +12,83 @@ public class PlaceOrderTests { private readonly PlaceOrderHandler _sut; private readonly IRepository _repository; - private readonly IReadModelRepository _productRepository; private readonly IPublisher _publisher; + private readonly FakeTimeProvider _timeProvider; public PlaceOrderTests() { _repository = A.Fake(); - _productRepository = A.Fake>(); _publisher = A.Fake(); - _sut = new PlaceOrderHandler(_repository, _productRepository, _publisher); + _timeProvider = new FakeTimeProvider(); + _sut = new PlaceOrderHandler(_repository, _publisher, _timeProvider); } [Fact] - public async Task GivenProductNotFound_ShouldThrowException() + public async Task PlaceOrder_ShouldSaveOrder() { // Arrange - var productId = Guid.NewGuid(); - A.CallTo(() => _productRepository.GetById(productId)) - .Returns(null); + var orderId = Guid.NewGuid(); + var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow); + var placedAt = DateTimeOffset.UtcNow; + var customer = new Customer("John Doe"); + var command = new PlaceOrder(orderId, customer); - var command = new PlaceOrder(productId, 1); + _timeProvider.SetUtcNow(placedAt); + + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(order)); + + order.AddItem(new OrderItem(Guid.NewGuid(), 1, "Latte", 5)); // Act var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); // Assert - exception.ShouldBeOfType(); + exception.ShouldBeNull(); + A.CallTo(() => _repository.Save(A.That.Matches(o => o.Status == OrderStatus.Placed && + o.Customer == customer && + o.PlacedAt == placedAt))) + .MustHaveHappenedOnceExactly(); } [Fact] - public async Task GivenProductExists_ShouldSaveOrder() + public async Task GivenNotNewOrder_ShouldThrow() { // Arrange - var productId = Guid.NewGuid(); - A.CallTo(() => _productRepository.GetById(productId)) - .Returns(new Product { Id = productId }); + var orderId = Guid.NewGuid(); + var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow); + var customer = new Customer("John Doe"); + var command = new PlaceOrder(orderId, customer); + + order.AddItem(new OrderItem(Guid.NewGuid(), 1, "Latte", 5)); + order.PlaceOrder(new Customer("John Doe"), DateTimeOffset.UtcNow); - var command = new PlaceOrder(productId, 1); + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(order)); // Act var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); // Assert - exception.ShouldBeNull(); - A.CallTo(() => _repository.Save(A.That.Matches(o => o.ProductId == productId && o.Quantity == 1))) - .MustHaveHappenedOnceExactly(); + exception.ShouldBeOfType(); } [Fact] - public async Task GivenOrderSaved_ShouldPublishToMessageBus() + public async Task GivenNoItemAdded_ShouldThrow() { // Arrange - var productId = Guid.NewGuid(); - A.CallTo(() => _productRepository.GetById(productId)) - .Returns(new Product { Id = productId }); + var orderId = Guid.NewGuid(); + var order = new Order(orderId, "cashier-1", DateTimeOffset.UtcNow); + var customer = new Customer("John Doe"); + var command = new PlaceOrder(orderId, customer); - var command = new PlaceOrder(productId, 1); + A.CallTo(() => _repository.GetById(orderId)) + .Returns(Task.FromResult(order)); // Act var exception = await Record.ExceptionAsync(() => _sut.HandleAsync(command)); // Assert - exception.ShouldBeNull(); - A.CallTo(() => _publisher.Publish(A.That.Matches(o => o.ProductId == productId && o.Quantity == 1))) - .MustHaveHappenedOnceExactly(); + exception.ShouldBeOfType(); } } diff --git a/src/Cashier/NCafe.Cashier.Domain.Tests/Entities/OrderTests.cs b/src/Cashier/NCafe.Cashier.Domain.Tests/Entities/OrderTests.cs index 2c0f38b..3d780e3 100644 --- a/src/Cashier/NCafe.Cashier.Domain.Tests/Entities/OrderTests.cs +++ b/src/Cashier/NCafe.Cashier.Domain.Tests/Entities/OrderTests.cs @@ -6,39 +6,22 @@ namespace NCafe.Cashier.Domain.Tests.Entities; public class OrderTests { [Fact] - public void GivenNewOrder_ShouldHaveAppliedOrderPlacedEvent() + public void GivenNewOrder_ShouldHaveAppliedOrderCreatedEvent() { // Arrange var id = Guid.NewGuid(); - var productId = Guid.NewGuid(); - var quantity = 1; + var createdBy = "cashier-1"; + var createdAt = DateTimeOffset.UtcNow; // Act - var order = new Order(id, productId, quantity); + var order = new Order(id, createdBy, createdAt); // Assert var @event = order.GetPendingEvents().ShouldHaveSingleItem(); - @event.ShouldBeOfType(); + @event.ShouldBeOfType(); order.Id.ShouldBe(id); - order.ProductId.ShouldBe(productId); - order.Quantity.ShouldBe(quantity); - } - - [Fact] - public void GivenOrder_WhenPaidFor_ShouldHaveAppliedOrderPaidForEvent() - { - // Arrange - var id = Guid.NewGuid(); - var order = new Order(id, Guid.NewGuid(), 1); - - // Act - order.PayForOrder(); - - // Assert - var @event = order.GetPendingEvents().Last(); - @event.ShouldBeOfType(); - - order.HasBeenPaid.ShouldBeTrue(); + order.CreatedBy.ShouldBe(createdBy); + order.CreatedAt.ShouldBe(createdAt); } } diff --git a/src/Cashier/NCafe.Cashier.Domain.Tests/NCafe.Cashier.Domain.Tests.csproj b/src/Cashier/NCafe.Cashier.Domain.Tests/NCafe.Cashier.Domain.Tests.csproj index 6ec94fc..f7973c1 100644 --- a/src/Cashier/NCafe.Cashier.Domain.Tests/NCafe.Cashier.Domain.Tests.csproj +++ b/src/Cashier/NCafe.Cashier.Domain.Tests/NCafe.Cashier.Domain.Tests.csproj @@ -10,6 +10,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Cashier/NCafe.Cashier.Domain/Commands/AddItemToOrder.cs b/src/Cashier/NCafe.Cashier.Domain/Commands/AddItemToOrder.cs new file mode 100644 index 0000000..2d2e57c --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Commands/AddItemToOrder.cs @@ -0,0 +1,29 @@ +using NCafe.Cashier.Domain.Entities; +using NCafe.Cashier.Domain.Exceptions; +using NCafe.Cashier.Domain.ReadModels; +using NCafe.Cashier.Domain.ValueObjects; +using NCafe.Core.Commands; +using NCafe.Core.ReadModels; +using NCafe.Core.Repositories; + +namespace NCafe.Cashier.Domain.Commands; + +public record AddItemToOrder(Guid OrderId, Guid ProductId, int Quantity) : ICommand; + +internal sealed class AddItemToOrderHandler( + IRepository repository, + IReadModelRepository productReadRepository) : ICommandHandler +{ + private readonly IRepository _repository = repository; + private readonly IReadModelRepository _productReadRepository = productReadRepository; + + public async Task HandleAsync(AddItemToOrder command) + { + var order = await _repository.GetById(command.OrderId) ?? throw new OrderNotFoundException(command.OrderId); + var product = _productReadRepository.GetById(command.ProductId) ?? throw new ProductNotFoundException(command.ProductId); + + order.AddItem(new OrderItem(command.ProductId, command.Quantity, product.Name, product.Price)); + + await _repository.Save(order); + } +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Commands/CreateOrder.cs b/src/Cashier/NCafe.Cashier.Domain/Commands/CreateOrder.cs new file mode 100644 index 0000000..846d743 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Commands/CreateOrder.cs @@ -0,0 +1,22 @@ +using NCafe.Cashier.Domain.Entities; +using NCafe.Core.Commands; +using NCafe.Core.Repositories; + +namespace NCafe.Cashier.Domain.Commands; + +public record CreateOrder(string CreatedBy) : ICommand; + +internal sealed class CreateOrderHandler( + IRepository repository, + TimeProvider timeProvider) : ICommandHandler +{ + private readonly IRepository _repository = repository; + private readonly TimeProvider _timeProvider = timeProvider; + + public async Task HandleAsync(CreateOrder command) + { + var order = new Order(Guid.NewGuid(), command.CreatedBy, _timeProvider.GetUtcNow()); + + await _repository.Save(order); + } +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Commands/PayForOrder.cs b/src/Cashier/NCafe.Cashier.Domain/Commands/PayForOrder.cs deleted file mode 100644 index a2ddbf5..0000000 --- a/src/Cashier/NCafe.Cashier.Domain/Commands/PayForOrder.cs +++ /dev/null @@ -1,28 +0,0 @@ -using NCafe.Core.Exceptions; -using NCafe.Cashier.Domain.Entities; -using NCafe.Cashier.Domain.Exceptions; -using NCafe.Core.Commands; -using NCafe.Core.Repositories; - -namespace NCafe.Cashier.Domain.Commands; - -public record PayForOrder(Guid Id) : ICommand; - -internal sealed class PayForOrderHandler(IRepository repository) : ICommandHandler -{ - private readonly IRepository _repository = repository; - - public async Task HandleAsync(PayForOrder command) - { - if (command.Id == Guid.Empty) - { - throw new InvalidIdException(); - } - - var order = await _repository.GetById(command.Id) ?? throw new OrderNotFoundException(command.Id); - - order.PayForOrder(); - - await _repository.Save(order); - } -} \ No newline at end of file diff --git a/src/Cashier/NCafe.Cashier.Domain/Commands/PlaceOrder.cs b/src/Cashier/NCafe.Cashier.Domain/Commands/PlaceOrder.cs index ead3794..b84e2af 100644 --- a/src/Cashier/NCafe.Cashier.Domain/Commands/PlaceOrder.cs +++ b/src/Cashier/NCafe.Cashier.Domain/Commands/PlaceOrder.cs @@ -1,32 +1,43 @@ -using NCafe.Cashier.Domain.Exceptions; -using NCafe.Cashier.Domain.Entities; +using NCafe.Cashier.Domain.Entities; +using NCafe.Cashier.Domain.Exceptions; using NCafe.Cashier.Domain.Messages; -using NCafe.Cashier.Domain.ReadModels; +using NCafe.Cashier.Domain.ValueObjects; using NCafe.Core.Commands; using NCafe.Core.MessageBus; -using NCafe.Core.ReadModels; using NCafe.Core.Repositories; namespace NCafe.Cashier.Domain.Commands; -public record PlaceOrder(Guid ProductId, int Quantity) : ICommand; +public record PlaceOrder(Guid Id, Customer Customer) : ICommand; internal sealed class PlaceOrderHandler( IRepository repository, - IReadModelRepository productReadRepository, - IPublisher publisher) : ICommandHandler + IPublisher publisher, + TimeProvider timeProvider) : ICommandHandler { private readonly IRepository _repository = repository; - private readonly IReadModelRepository _productReadRepository = productReadRepository; private readonly IPublisher _publisher = publisher; + private readonly TimeProvider _timeProvider = timeProvider; public async Task HandleAsync(PlaceOrder command) { - var product = _productReadRepository.GetById(command.ProductId) ?? throw new ProductNotFoundException(command.ProductId); - var order = new Order(Guid.NewGuid(), product.Id, command.Quantity); + var order = await _repository.GetById(command.Id) ?? throw new OrderNotFoundException(command.Id); ; + + if (order.Status != OrderStatus.New) + { + throw new CannotPlaceOrderException(order.Id); + } + + if (order.Items.Count == 0) + { + throw new CannotPlaceEmptyOrderException(order.Id); + } + + order.PlaceOrder(command.Customer, _timeProvider.GetUtcNow()); await _repository.Save(order); - await _publisher.Publish(new OrderPlaced(order.Id, order.ProductId, order.Quantity)); + await _publisher.Publish( + new OrderPlaced(order.Id, order.Items.Select(x => (OrderPlaced.OrderItem)x).ToArray(), order.Customer)); } } diff --git a/src/Cashier/NCafe.Cashier.Domain/Entities/Order.cs b/src/Cashier/NCafe.Cashier.Domain/Entities/Order.cs index 02e86d4..a2eb6f2 100644 --- a/src/Cashier/NCafe.Cashier.Domain/Entities/Order.cs +++ b/src/Cashier/NCafe.Cashier.Domain/Entities/Order.cs @@ -1,47 +1,82 @@ using Ardalis.GuardClauses; using NCafe.Cashier.Domain.Events; +using NCafe.Cashier.Domain.Exceptions; +using NCafe.Cashier.Domain.ValueObjects; using NCafe.Core.Domain; namespace NCafe.Cashier.Domain.Entities; -public sealed class Order : AggregateRoot +internal enum OrderStatus +{ + New, + Canceled, + Placed, + Preparing, + Completed +} + +internal sealed class Order : AggregateRoot { private Order() { } - public Order(Guid id, Guid productId, int quantity) + private List _items = []; + + public Order(Guid id, string createdBy, DateTimeOffset createdAt) { - Guard.Against.Default(id, nameof(id)); - Guard.Against.Default(productId, nameof(productId)); - Guard.Against.NegativeOrZero(quantity, nameof(quantity)); + Guard.Against.Default(id); + Guard.Against.NullOrEmpty(createdBy); + Guard.Against.Default(createdAt); - RaiseEvent(new OrderPlaced(id) - { - ProductId = productId, - Quantity = quantity - }); + RaiseEvent(new OrderCreated(id, createdBy, createdAt)); } - public Guid ProductId { get; private set; } - public int Quantity { get; private set; } - public bool HasBeenPaid { get; private set; } + public OrderStatus Status { get; private set; } + public string CreatedBy { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset PlacedAt { get; private set; } + public IReadOnlyCollection Items => _items.AsReadOnly(); + public Customer Customer { get; private set; } + public decimal Total { get; private set; } - public void PayForOrder() + public void AddItem(OrderItem orderItem) { - RaiseEvent(new OrderPaidFor(Id)); + Guard.Against.Null(orderItem); + + if (Status != OrderStatus.New) + { + throw new CannotAddItemToOrderException(Id, orderItem.ProductId); + } + + RaiseEvent(new OrderItemAdded(Id, orderItem.ProductId, orderItem.Quantity, orderItem.Name, orderItem.Price)); } - public void Apply(OrderPlaced @event) + public void PlaceOrder(Customer customer, DateTimeOffset placedAt) + { + Guard.Against.Null(customer); + + RaiseEvent(new OrderPlaced(Id, customer, placedAt)); + } + + private void Apply(OrderCreated @event) { Id = @event.Id; - ProductId = @event.ProductId; - Quantity = @event.Quantity; - HasBeenPaid = false; + Status = OrderStatus.New; + CreatedBy = @event.CreatedBy; + CreatedAt = @event.CreatedAt; + } + + private void Apply(OrderItemAdded @event) + { + _items.Add(new OrderItem(@event.ProductId, @event.Quantity, @event.Name, @event.Price)); + Total = Items.Sum(i => i.Price); } - public void Apply(OrderPaidFor @event) + private void Apply(OrderPlaced @event) { - HasBeenPaid = true; + Status = OrderStatus.Placed; + Customer = @event.Customer; + PlacedAt = @event.PlacedAt; } } diff --git a/src/Cashier/NCafe.Cashier.Domain/Events/OrderCreated.cs b/src/Cashier/NCafe.Cashier.Domain/Events/OrderCreated.cs new file mode 100644 index 0000000..8d50b48 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Events/OrderCreated.cs @@ -0,0 +1,16 @@ +using NCafe.Core.Domain; + +namespace NCafe.Cashier.Domain.Events; + +internal sealed record OrderCreated : Event +{ + public OrderCreated(Guid id, string createdBy, DateTimeOffset createdAt) + { + Id = id; + CreatedBy = createdBy; + CreatedAt = createdAt; + } + + public string CreatedBy { get; } + public DateTimeOffset CreatedAt { get; } +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Events/OrderItemAdded.cs b/src/Cashier/NCafe.Cashier.Domain/Events/OrderItemAdded.cs new file mode 100644 index 0000000..2d99f68 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Events/OrderItemAdded.cs @@ -0,0 +1,19 @@ +using NCafe.Core.Domain; + +namespace NCafe.Cashier.Domain.Events; +internal sealed record OrderItemAdded : Event +{ + public OrderItemAdded(Guid id, Guid productId, int quantity, string name, decimal price) + { + Id = id; + ProductId = productId; + Quantity = quantity; + Name = name; + Price = price; + } + + public Guid ProductId { get; } + public int Quantity { get; } + public string Name { get; } + public decimal Price { get; } +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Events/OrderPaidFor.cs b/src/Cashier/NCafe.Cashier.Domain/Events/OrderPaidFor.cs deleted file mode 100644 index b0cff54..0000000 --- a/src/Cashier/NCafe.Cashier.Domain/Events/OrderPaidFor.cs +++ /dev/null @@ -1,11 +0,0 @@ -using NCafe.Core.Domain; - -namespace NCafe.Cashier.Domain.Events; - -public sealed record OrderPaidFor : Event -{ - public OrderPaidFor(Guid id) - { - Id = id; - } -} diff --git a/src/Cashier/NCafe.Cashier.Domain/Events/OrderPlaced.cs b/src/Cashier/NCafe.Cashier.Domain/Events/OrderPlaced.cs index b94413d..05baa31 100644 --- a/src/Cashier/NCafe.Cashier.Domain/Events/OrderPlaced.cs +++ b/src/Cashier/NCafe.Cashier.Domain/Events/OrderPlaced.cs @@ -1,14 +1,17 @@ -using NCafe.Core.Domain; +using NCafe.Cashier.Domain.ValueObjects; +using NCafe.Core.Domain; namespace NCafe.Cashier.Domain.Events; -public sealed record OrderPlaced : Event +internal sealed record OrderPlaced : Event { - public OrderPlaced(Guid id) + public OrderPlaced(Guid id, Customer customer, DateTimeOffset placedAt) { Id = id; + Customer = customer; + PlacedAt = placedAt; } - public Guid ProductId { get; init; } - public int Quantity { get; init; } + public Customer Customer { get; } + public DateTimeOffset PlacedAt { get; } } diff --git a/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotAddItemToOrderException.cs b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotAddItemToOrderException.cs new file mode 100644 index 0000000..798a70b --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotAddItemToOrderException.cs @@ -0,0 +1,9 @@ +using NCafe.Core.Exceptions; + +namespace NCafe.Cashier.Domain.Exceptions; +public class CannotAddItemToOrderException(Guid orderId, Guid productId) + : DomainException($"Cannot add item '{productId}' to order '{orderId}' when status is not New.") +{ + public Guid OrderId { get; } = orderId; + public Guid ProductId { get; } = productId; +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotPlaceEmptyOrderException.cs b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotPlaceEmptyOrderException.cs new file mode 100644 index 0000000..e0a1be4 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotPlaceEmptyOrderException.cs @@ -0,0 +1,9 @@ +using NCafe.Core.Exceptions; + +namespace NCafe.Cashier.Domain.Exceptions; + +public class CannotPlaceEmptyOrderException(Guid orderId) + : DomainException($"Cannot place order '{orderId}' when item list is empty.") +{ + public Guid OrderId { get; } = orderId; +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotPlaceOrderException.cs b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotPlaceOrderException.cs new file mode 100644 index 0000000..985b3ac --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/Exceptions/CannotPlaceOrderException.cs @@ -0,0 +1,9 @@ +using NCafe.Core.Exceptions; + +namespace NCafe.Cashier.Domain.Exceptions; + +public class CannotPlaceOrderException(Guid orderId) + : DomainException($"Cannot place order '{orderId}' when status is not New.") +{ + public Guid OrderId { get; } = orderId; +} diff --git a/src/Cashier/NCafe.Cashier.Domain/Messages/OrderPlaced.cs b/src/Cashier/NCafe.Cashier.Domain/Messages/OrderPlaced.cs index 0a522f3..d07cc26 100644 --- a/src/Cashier/NCafe.Cashier.Domain/Messages/OrderPlaced.cs +++ b/src/Cashier/NCafe.Cashier.Domain/Messages/OrderPlaced.cs @@ -2,4 +2,28 @@ namespace NCafe.Cashier.Domain.Messages; -public sealed record OrderPlaced(Guid Id, Guid ProductId, int Quantity) : IBusMessage; +public sealed class OrderPlaced(Guid id, OrderPlaced.OrderItem[] orderItems, string customerName) : IBusMessage +{ + public Guid Id { get; } = id; + public OrderItem[] OrderItems { get; } = orderItems; + public string CustomerName { get; } = customerName; + + public sealed class OrderItem + { + public Guid ProductId { get; init; } + public string Name { get; init; } + public int Quantity { get; init; } + public decimal Price { get; set; } + + public static implicit operator OrderItem(ValueObjects.OrderItem orderItem) + { + return new OrderItem + { + ProductId = orderItem.ProductId, + Name = orderItem.Name, + Quantity = orderItem.Quantity, + Price = orderItem.Price + }; + } + } +} diff --git a/src/Cashier/NCafe.Cashier.Domain/ValueObjects/Customer.cs b/src/Cashier/NCafe.Cashier.Domain/ValueObjects/Customer.cs new file mode 100644 index 0000000..65e0fd0 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/ValueObjects/Customer.cs @@ -0,0 +1,19 @@ +using Ardalis.GuardClauses; + +namespace NCafe.Cashier.Domain.ValueObjects; + +public record Customer +{ + public Customer(string name) + { + Guard.Against.NullOrEmpty(name, nameof(name)); + Guard.Against.StringTooShort(name, 2, nameof(name)); + + Name = name; + } + + public string Name { get; } + + public static implicit operator Customer(string name) => new(name); + public static implicit operator string(Customer customer) => customer.Name; +} diff --git a/src/Cashier/NCafe.Cashier.Domain/ValueObjects/OrderItem.cs b/src/Cashier/NCafe.Cashier.Domain/ValueObjects/OrderItem.cs new file mode 100644 index 0000000..257f3c1 --- /dev/null +++ b/src/Cashier/NCafe.Cashier.Domain/ValueObjects/OrderItem.cs @@ -0,0 +1,24 @@ +using Ardalis.GuardClauses; + +namespace NCafe.Cashier.Domain.ValueObjects; + +public record OrderItem +{ + public OrderItem(Guid productId, int quantity, string name, decimal price) + { + Guard.Against.Default(productId, nameof(productId)); + Guard.Against.NegativeOrZero(quantity, nameof(quantity)); + Guard.Against.NullOrWhiteSpace(name, nameof(name)); + Guard.Against.NegativeOrZero(price, nameof(price)); + + ProductId = productId; + Quantity = quantity; + Name = name; + Price = price; + } + + public Guid ProductId { get; } + public int Quantity { get; } + public string Name { get; } + public decimal Price { get; } +} diff --git a/src/Common/NCafe.Common.Tests/Architecture/InfrastructureTests.cs b/src/Common/NCafe.Common.Tests/Architecture/InfrastructureTests.cs index b00e2d9..fe35cc2 100644 --- a/src/Common/NCafe.Common.Tests/Architecture/InfrastructureTests.cs +++ b/src/Common/NCafe.Common.Tests/Architecture/InfrastructureTests.cs @@ -1,7 +1,7 @@ using ArchUnitNET.Domain; +using ArchUnitNET.Fluent; using ArchUnitNET.Loader; using ArchUnitNET.xUnit; -using ArchUnitNET.Fluent; using static ArchUnitNET.Fluent.ArchRuleDefinition; namespace NCafe.Common.Tests.Architecture; diff --git a/src/Common/NCafe.Core/NCafe.Core.csproj b/src/Common/NCafe.Core/NCafe.Core.csproj index 2363ac1..2ecd609 100644 --- a/src/Common/NCafe.Core/NCafe.Core.csproj +++ b/src/Common/NCafe.Core/NCafe.Core.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/src/Common/NCafe.Infrastructure/DependencyRegistration.cs b/src/Common/NCafe.Infrastructure/DependencyRegistration.cs index 0f71a64..8c43dff 100644 --- a/src/Common/NCafe.Infrastructure/DependencyRegistration.cs +++ b/src/Common/NCafe.Infrastructure/DependencyRegistration.cs @@ -116,7 +116,7 @@ private static void InitializeRabbitMqExchange(IServiceCollection services) var scope = services.BuildServiceProvider().CreateScope(); var connection = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService>(); - + var channel = connection.CreateModel(); channel.ExchangeDeclare(exchange: ExchangeNameProvider.Get(settings.Value.ExchangeName), type: ExchangeType.Topic, diff --git a/src/Common/NCafe.Infrastructure/MessageBrokers/RabbitMQ/RabbitMqPublisher.cs b/src/Common/NCafe.Infrastructure/MessageBrokers/RabbitMQ/RabbitMqPublisher.cs index ef8272e..c38358e 100644 --- a/src/Common/NCafe.Infrastructure/MessageBrokers/RabbitMQ/RabbitMqPublisher.cs +++ b/src/Common/NCafe.Infrastructure/MessageBrokers/RabbitMQ/RabbitMqPublisher.cs @@ -1,9 +1,9 @@ -using NCafe.Core.MessageBus; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NCafe.Core.MessageBus; using RabbitMQ.Client; using System.Diagnostics; using System.Text.Json; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace NCafe.Infrastructure.MessageBrokers.RabbitMQ;