diff --git a/README.md b/README.md new file mode 100644 index 0000000..eca6e8f --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +

+ci +

+ + + +# What we are doing here ? + + - Hexagonal (Clean) Architecture -> Port & Adapter Style + + - Domain Driven Desing (DDD) + + - SAGA Pattern : process & rollback ( compensating transactions ) + + - Outbox Pattern : Pulling Outbox Table With Scheduler , Saga Status + + - Cover Failure Scerinarios : + + - Ensure idempotency using outbox table in each service + + - Prevent concurrency issues with optimistic looks & DB constaints + + - Kepp updating saga and order status for each operation + + - CQRS Pattern : Materialized view & Event Sourcing + + - Relational Database : for ACID and distributed transactional + + - Kafka Messaging Systems for CQRS desing and Microservices Communication + + - Kubernetes And GKE ( Google Kubernetes Engine ) \ No newline at end of file diff --git a/common/common-domain/src/main/java/com/food/order/domain/exception/DomainException.java b/common/common-domain/src/main/java/com/food/order/domain/exception/DomainException.java new file mode 100644 index 0000000..b7b34dc --- /dev/null +++ b/common/common-domain/src/main/java/com/food/order/domain/exception/DomainException.java @@ -0,0 +1,12 @@ +package com.food.order.domain.exception; + +public class DomainException extends RuntimeException { + + public DomainException(String message) { + super(message); + } + + public DomainException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/common/common-domain/src/main/java/com/food/order/domain/valueobject/Money.java b/common/common-domain/src/main/java/com/food/order/domain/valueobject/Money.java index 6da812d..a6b5a5d 100644 --- a/common/common-domain/src/main/java/com/food/order/domain/valueobject/Money.java +++ b/common/common-domain/src/main/java/com/food/order/domain/valueobject/Money.java @@ -8,6 +8,8 @@ public class Money { private final BigDecimal amount; + public static final Money ZERO = new Money(BigDecimal.ZERO); + public Money(BigDecimal amount) { this.amount = amount; } diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Customer.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Customer.java new file mode 100644 index 0000000..626e8cd --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Customer.java @@ -0,0 +1,7 @@ +package com.food.order.system.domain.entity; + +import com.food.order.domain.entity.AggregateRoot; +import com.food.order.domain.valueobject.CustomerId; + +public class Customer extends AggregateRoot { +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Order.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Order.java new file mode 100644 index 0000000..509eced --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Order.java @@ -0,0 +1,225 @@ +package com.food.order.system.domain.entity; + +import com.food.order.domain.entity.AggregateRoot; +import com.food.order.domain.valueobject.*; +import com.food.order.system.domain.exception.OrderDomainException; +import com.food.order.system.domain.valueobject.OrderItemId; +import com.food.order.system.domain.valueobject.StreetAddress; +import com.food.order.system.domain.valueobject.TrackingId; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public class Order extends AggregateRoot { + + private final CustomerId customerId; + private final RestaurantId restaurantId; + private final StreetAddress deliveryAddress; + private final Money price; + private final List items; + + private TrackingId trackingId; + private OrderStatus status; + private List failureMessages; + + public void initializeOrder(){ + setId(new OrderId(UUID.randomUUID())); + trackingId = new TrackingId(UUID.randomUUID()); + status = OrderStatus.PENDING; + initializeOrderItems(); + } + + public void pay(){ + if(!Objects.equals(status, OrderStatus.PENDING)) + throw new OrderDomainException("Order status is not pending !"); + status = OrderStatus.PAID; + + } + + public void approve(){ + if(!Objects.equals(status, OrderStatus.PAID)) + throw new OrderDomainException("Order status is not paid !"); + status = OrderStatus.APPROVED; + } + + public void initCancel(List failureMessages){ + if(!Objects.equals(status, OrderStatus.PAID)) + throw new OrderDomainException("Order status is not correct for initCancel operation !"); + status = OrderStatus.CANCELLED; + updateFailureMessages(failureMessages); + } + + public void cancel(List failureMessages){ + if(Objects.equals(status, OrderStatus.CANCELLING) || Objects.equals(status, OrderStatus.PENDING)) + throw new OrderDomainException("Order status is not correct for cancel operation !"); + status = OrderStatus.CANCELLED; + updateFailureMessages(failureMessages); + } + + private void updateFailureMessages(List failureMessages) { + if (Objects.nonNull(this.failureMessages) && Objects.nonNull(failureMessages)) { + this.failureMessages.addAll(failureMessages.stream().filter(Objects::nonNull).toList()); + } else { + this.failureMessages = failureMessages; + } + } + + public void validateOrder(){ + validateInitialOrder(); + validateTotalPrice(); + validateItemsPrice(); + } + + private void validateItemsPrice() { + Money orderItemsTotal = items + .stream() + .map(orderItem -> { + validateItemPrice(orderItem); + return orderItem.getSubTotal(); + }) + .reduce(Money.ZERO, Money::add); + + if(!Objects.equals(orderItemsTotal, price)){ + throw new OrderDomainException("Order total price is not equal to the sum of order items prices"); + } + } + + private void validateItemPrice(OrderItem orderItem) { + if(!orderItem.isPriceValid()){ + throw new OrderDomainException("Order item price is not valid"); + } + } + + private void validateTotalPrice() { + if(Objects.nonNull(price) && !price.isGreaterThanZero()){ + throw new OrderDomainException("Total price cannot be less than zero"); + } + } + + private void validateInitialOrder() { + if (Objects.nonNull(status) && Objects.nonNull(getId())) + throw new OrderDomainException("Order is not in correct state to be initialized"); + + } + + private void initializeOrderItems() { + long itemId = 1; + for (OrderItem item : items) { + item.initializeOrderItem(super.getId(), new OrderItemId(itemId++)); + } + } + + private Order(Builder builder) { + super.setId(builder.orderId); + customerId = builder.customerId; + restaurantId = builder.restaurantId; + deliveryAddress = builder.deliveryAddress; + price = builder.price; + items = builder.items; + trackingId = builder.trackingId; + status = builder.status; + failureMessages = builder.failureMessages; + } + + public static Builder builder() { + return new Builder(); + } + + public CustomerId getCustomerId() { + return customerId; + } + + public RestaurantId getRestaurantId() { + return restaurantId; + } + + public StreetAddress getDeliveryAddress() { + return deliveryAddress; + } + + public Money getPrice() { + return price; + } + + public List getItems() { + return items; + } + + public TrackingId getTrackingId() { + return trackingId; + } + + public OrderStatus getStatus() { + return status; + } + + public List getFailureMessages() { + return failureMessages; + } + + + public static final class Builder { + private OrderId orderId; + private CustomerId customerId; + private RestaurantId restaurantId; + private StreetAddress deliveryAddress; + private Money price; + private List items; + private TrackingId trackingId; + private OrderStatus status; + private List failureMessages; + + private Builder() { + } + + public Builder orderId(OrderId val) { + orderId = val; + return this; + } + + public Builder customerId(CustomerId val) { + customerId = val; + return this; + } + + public Builder restaurantId(RestaurantId val) { + restaurantId = val; + return this; + } + + public Builder deliveryAddress(StreetAddress val) { + deliveryAddress = val; + return this; + } + + public Builder price(Money val) { + price = val; + return this; + } + + public Builder items(List val) { + items = val; + return this; + } + + public Builder trackingId(TrackingId val) { + trackingId = val; + return this; + } + + public Builder status(OrderStatus val) { + status = val; + return this; + } + + public Builder failureMessages(List val) { + failureMessages = val; + return this; + } + + public Order build() { + return new Order(this); + } + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/OrderItem.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/OrderItem.java new file mode 100644 index 0000000..50dbd91 --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/OrderItem.java @@ -0,0 +1,101 @@ +package com.food.order.system.domain.entity; + +import com.food.order.domain.entity.BaseEntity; +import com.food.order.domain.valueobject.Money; +import com.food.order.domain.valueobject.OrderId; +import com.food.order.system.domain.valueobject.OrderItemId; + +public class OrderItem extends BaseEntity { + + private OrderId orderId; + private final Product product; + private final int quantity; + private final Money price; + private final Money subTotal; + + void initializeOrderItem(OrderId orderId, OrderItemId orderItemId) { + this.orderId = orderId; + super.setId(orderItemId); + } + + boolean isPriceValid() { + return price.isGreaterThanZero() && + price.equals(product.getPrice()) && + price.multiply(quantity).equals(subTotal); + } + + private OrderItem(Builder builder) { + super.setId(builder.orderItemId); + product = builder.product; + quantity = builder.quantity; + price = builder.price; + subTotal = builder.subTotal; + } + + public static Builder builder() { + return new Builder(); + } + + public OrderId getOrderId() { + return orderId; + } + + public Product getProduct() { + return product; + } + + public int getQuantity() { + return quantity; + } + + public Money getPrice() { + return price; + } + + public Money getSubTotal() { + return subTotal; + } + + + + + public static final class Builder { + private OrderItemId orderItemId; + private Product product; + private int quantity; + private Money price; + private Money subTotal; + + private Builder() { + } + + public Builder orderItemId(OrderItemId val) { + orderItemId = val; + return this; + } + + public Builder product(Product val) { + product = val; + return this; + } + + public Builder quantity(int val) { + quantity = val; + return this; + } + + public Builder price(Money val) { + price = val; + return this; + } + + public Builder subTotal(Money val) { + subTotal = val; + return this; + } + + public OrderItem build() { + return new OrderItem(this); + } + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Product.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Product.java new file mode 100644 index 0000000..027513c --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Product.java @@ -0,0 +1,31 @@ +package com.food.order.system.domain.entity; + +import com.food.order.domain.entity.BaseEntity; +import com.food.order.domain.valueobject.Money; +import com.food.order.domain.valueobject.ProductId; + +public class Product extends BaseEntity { + private String name; + private Money price; + + public Product(ProductId id, String name, Money price) { + super.setId(id); + this.name = name; + this.price = price; + } + + public String getName() { + return name; + } + + public Money getPrice() { + return price; + } + + public void updateWithConfirmedNameAndPrice(String name, Money price) { + + this.name = name; + this.price = price; + + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Restaurant.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Restaurant.java new file mode 100644 index 0000000..5757a9a --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/entity/Restaurant.java @@ -0,0 +1,58 @@ +package com.food.order.system.domain.entity; + +import com.food.order.domain.entity.AggregateRoot; +import com.food.order.domain.valueobject.RestaurantId; + +import java.util.List; + +public class Restaurant extends AggregateRoot { + private final List products; + private final boolean isActive; + + private Restaurant(Builder builder) { + super.setId(builder.restaurantId); + products = builder.products; + isActive = builder.isActive; + } + + public static Builder builder() { + return new Builder(); + } + + public List getProducts() { + return products; + } + + public boolean isActive() { + return isActive; + } + + + public static final class Builder { + private RestaurantId restaurantId; + private List products; + private boolean isActive; + + private Builder() { + } + + public Builder id(RestaurantId val) { + restaurantId = val; + return this; + } + + public Builder products(List val) { + products = val; + return this; + } + + public Builder isActive(boolean val) { + isActive = val; + return this; + } + + public Restaurant build() { + return new Restaurant(this); + } + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderCancelledEvent.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderCancelledEvent.java new file mode 100644 index 0000000..6e8f7b5 --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderCancelledEvent.java @@ -0,0 +1,12 @@ +package com.food.order.system.domain.event; + +import com.food.order.system.domain.entity.Order; + +import java.time.ZonedDateTime; + +public class OrderCancelledEvent extends OrderEvent { + + public OrderCancelledEvent(Order order, ZonedDateTime utc) { + super(order, utc); + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderCreatedEvent.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderCreatedEvent.java new file mode 100644 index 0000000..620f5c1 --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderCreatedEvent.java @@ -0,0 +1,11 @@ +package com.food.order.system.domain.event; + +import com.food.order.system.domain.entity.Order; + +import java.time.ZonedDateTime; + +public class OrderCreatedEvent extends OrderEvent { + public OrderCreatedEvent(Order order, ZonedDateTime createdAt) { + super(order, createdAt); + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderEvent.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderEvent.java new file mode 100644 index 0000000..fe26697 --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderEvent.java @@ -0,0 +1,24 @@ +package com.food.order.system.domain.event; + +import com.food.order.domain.event.DomainEvent; +import com.food.order.system.domain.entity.Order; + +import java.time.ZonedDateTime; + +public abstract class OrderEvent implements DomainEvent { + private final Order order; + private final ZonedDateTime createdAt; + + protected OrderEvent(Order order, ZonedDateTime createdAt) { + this.order = order; + this.createdAt = ZonedDateTime.now(); + } + + public Order getOrder() { + return order; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderPaidEvent.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderPaidEvent.java new file mode 100644 index 0000000..6467567 --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/event/OrderPaidEvent.java @@ -0,0 +1,13 @@ +package com.food.order.system.domain.event; + +import com.food.order.system.domain.entity.Order; + +import java.time.ZonedDateTime; + +public class OrderPaidEvent extends OrderEvent { + + public OrderPaidEvent(Order order, ZonedDateTime utc) { + super(order, utc); + } + +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/exception/OrderDomainException.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/exception/OrderDomainException.java new file mode 100644 index 0000000..42d05ba --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/exception/OrderDomainException.java @@ -0,0 +1,14 @@ +package com.food.order.system.domain.exception; + +import com.food.order.domain.exception.DomainException; + +public class OrderDomainException extends DomainException { + + public OrderDomainException(String message) { + super(message); + } + + public OrderDomainException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/service/OrderDomainService.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/service/OrderDomainService.java new file mode 100644 index 0000000..6de2853 --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/service/OrderDomainService.java @@ -0,0 +1,23 @@ +package com.food.order.system.domain.service; + +import com.food.order.system.domain.entity.Order; +import com.food.order.system.domain.entity.Restaurant; +import com.food.order.system.domain.event.OrderCancelledEvent; +import com.food.order.system.domain.event.OrderCreatedEvent; +import com.food.order.system.domain.event.OrderPaidEvent; + +import java.util.List; + +public interface OrderDomainService { + + OrderCreatedEvent validateAndInitiateOrder(Order order, Restaurant restaurant); + + OrderPaidEvent payOrder(Order order); + + void approve(Order order); + + OrderCancelledEvent cancelOrderPayment(Order order, List failureMessages); + + void cancelOrder(Order order, List failureMessages); + +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/service/impl/OrderDomainServiceImpl.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/service/impl/OrderDomainServiceImpl.java new file mode 100644 index 0000000..901e4fc --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/service/impl/OrderDomainServiceImpl.java @@ -0,0 +1,73 @@ +package com.food.order.system.domain.service.impl; + +import com.food.order.system.domain.entity.Order; +import com.food.order.system.domain.entity.Restaurant; +import com.food.order.system.domain.event.OrderCancelledEvent; +import com.food.order.system.domain.event.OrderCreatedEvent; +import com.food.order.system.domain.event.OrderPaidEvent; +import com.food.order.system.domain.exception.OrderDomainException; +import com.food.order.system.domain.service.OrderDomainService; +import lombok.extern.slf4j.Slf4j; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@Slf4j +public class OrderDomainServiceImpl implements OrderDomainService { + + private static final String UTC = "UTC"; + + @Override + public OrderCreatedEvent validateAndInitiateOrder(Order order, Restaurant restaurant) { + validateRestaurant(restaurant); + setOrderProductInformation(order,restaurant); + order.validateOrder(); + order.initializeOrder(); + log.info("Order with id {} initialize successfully", order.getId().getValue()); + return new OrderCreatedEvent(order, ZonedDateTime.now(ZoneId.of(UTC))); + } + + private void setOrderProductInformation(Order order, Restaurant restaurant) { + order.getItems() + .forEach(orderItem -> restaurant.getProducts().forEach(restaurantProduct -> { + var currentProduct = orderItem.getProduct(); + if(currentProduct.equals(restaurantProduct)){ + currentProduct.updateWithConfirmedNameAndPrice(restaurantProduct.getName(),restaurantProduct.getPrice()); + } + })); + } + + private void validateRestaurant(Restaurant restaurant) { + if (Boolean.FALSE.equals(restaurant.isActive())) { + throw new OrderDomainException("Restaurant is not active, please try again later. " + + "Restaurant id: {} " + restaurant.getId()); + } + } + + @Override + public OrderPaidEvent payOrder(Order order) { + order.pay(); + log.info("Order with id {} paid successfully", order.getId().getValue()); + return new OrderPaidEvent(order, ZonedDateTime.now(ZoneId.of(UTC))); + } + + @Override + public void approve(Order order) { + order.approve(); + log.info("Order with id {} approved successfully", order.getId().getValue()); + } + + @Override + public OrderCancelledEvent cancelOrderPayment(Order order, List failureMessages) { + order.initCancel(failureMessages); + log.info("Order with id {} cancelled successfully", order.getId().getValue()); + return new OrderCancelledEvent(order, ZonedDateTime.now(ZoneId.of(UTC))); + } + + @Override + public void cancelOrder(Order order, List failureMessages) { + order.cancel(failureMessages); + log.info("Order with id {} cancelled successfully", order.getId().getValue()); + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/valueobject/OrderItemId.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/valueobject/OrderItemId.java new file mode 100644 index 0000000..7a12043 --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/valueobject/OrderItemId.java @@ -0,0 +1,12 @@ +package com.food.order.system.domain.valueobject; + +import com.food.order.domain.valueobject.BaseId; + +import java.util.UUID; + +public class OrderItemId extends BaseId { + + public OrderItemId(Long id) { + super(id); + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/valueobject/StreetAddress.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/valueobject/StreetAddress.java new file mode 100644 index 0000000..6253260 --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/valueobject/StreetAddress.java @@ -0,0 +1,47 @@ +package com.food.order.system.domain.valueobject; + +import java.util.UUID; + + +public class StreetAddress { + + private final UUID id; + private final String street; + private final String city; + private final String postalCode; + + public StreetAddress(UUID id, String street, String city, String postalCode) { + this.id = id; + this.street = street; + this.city = city; + this.postalCode = postalCode; + } + + public UUID getId() { + return id; + } + + public String getStreet() { + return street; + } + + public String getCity() { + return city; + } + + public String getPostalCode() { + return postalCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof StreetAddress that)) return false; + return id.equals(that.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/valueobject/TrackingId.java b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/valueobject/TrackingId.java new file mode 100644 index 0000000..3c94c97 --- /dev/null +++ b/order-service/order-domain/order-core-domain/src/main/java/com/food/order/system/domain/valueobject/TrackingId.java @@ -0,0 +1,11 @@ +package com.food.order.system.domain.valueobject; + +import com.food.order.domain.valueobject.BaseId; + +import java.util.UUID; + +public class TrackingId extends BaseId { + public TrackingId(UUID id) { + super(id); + } +} diff --git a/pom.xml b/pom.xml index 9de0e1a..fa6d9b9 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,19 @@ + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-logging + + +