Spring Boot DDD E-Commerce Order Management System
Project Structure
src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── ecommerce/
│ │ ├── ECommerceApplication.java
│ │ ├── shared/
│ │ │ ├── domain/
│ │ │ │ ├── AggregateRoot.java
│ │ │ │ ├── DomainEvent.java
│ │ │ │ ├── Entity.java
│ │ │ │ └── ValueObject.java
│ │ │ ├── infrastructure/
│ │ │ │ └── EventPublisher.java
│ │ │ └── application/
│ │ │ └── UseCase.java
│ │ ├── customer/
│ │ │ ├── domain/
│ │ │ │ ├── Customer.java
│ │ │ │ ├── CustomerId.java
│ │ │ │ ├── Email.java
│ │ │ │ └── CustomerRepository.java
│ │ │ ├── application/
│ │ │ │ ├── CustomerService.java
│ │ │ │ └── CreateCustomerUseCase.java
│ │ │ └── infrastructure/
│ │ │ ├── CustomerJpaRepository.java
│ │ │ ├── CustomerEntity.java
│ │ │ └── CustomerRepositoryImpl.java
│ │ ├── product/
│ │ │ ├── domain/
│ │ │ │ ├── Product.java
│ │ │ │ ├── ProductId.java
│ │ │ │ ├── Money.java
│ │ │ │ └── ProductRepository.java
│ │ │ ├── application/
│ │ │ │ └── ProductService.java
│ │ │ └── infrastructure/
│ │ │ ├── ProductJpaRepository.java
│ │ │ ├── ProductEntity.java
│ │ │ └── ProductRepositoryImpl.java
│ │ ├── order/
│ │ │ ├── domain/
│ │ │ │ ├── Order.java
│ │ │ │ ├── OrderId.java
│ │ │ │ ├── OrderItem.java
│ │ │ │ ├── OrderStatus.java
│ │ │ │ ├── OrderCreatedEvent.java
│ │ │ │ └── OrderRepository.java
│ │ │ ├── application/
│ │ │ │ ├── OrderService.java
│ │ │ │ ├── CreateOrderUseCase.java
│ │ │ │ └── dto/
│ │ │ │ ├── CreateOrderRequest.java
│ │ │ │ └── OrderResponse.java
│ │ │ └── infrastructure/
│ │ │ ├── OrderJpaRepository.java
│ │ │ ├── OrderEntity.java
│ │ │ ├── OrderItemEntity.java
│ │ │ └── OrderRepositoryImpl.java
│ │ └── web/
│ │ ├── CustomerController.java
│ │ ├── ProductController.java
│ │ └── OrderController.java
│ └── resources/
│ ├── application.yml
│ └── data.sql
└── test/
└── java/
└── com/
└── ecommerce/
└── order/
└── domain/
└── OrderTest.java
1. Main Application Class
// ECommerceApplication.java
package com.ecommerce;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.ApplicationEventPublisher;
import com.ecommerce.shared.infrastructure.EventPublisher;
@SpringBootApplication
public class ECommerceApplication {
public static void main(String[] args) {
SpringApplication.run(ECommerceApplication.class, args);
}
@Bean
public EventPublisher eventPublisher(ApplicationEventPublisher publisher) {
return new EventPublisher(publisher);
}
}
2. Shared Domain Layer
// shared/domain/Entity.java
package com.ecommerce.shared.domain;
import java.util.Objects;
public abstract class Entity<T> {
protected T id;
protected Entity(T id) {
this.id = id;
}
public T getId() {
return id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Entity<?> entity = (Entity<?>) o;
return Objects.equals(id, entity.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
// shared/domain/ValueObject.java
package com.ecommerce.shared.domain;
public abstract class ValueObject {
// Value objects are immutable and compared by value
@Override
public abstract boolean equals(Object obj);
@Override
public abstract int hashCode();
}
// shared/domain/AggregateRoot.java
package com.ecommerce.shared.domain;
import java.util.ArrayList;
import java.util.List;
public abstract class AggregateRoot<T> extends Entity<T> {
private final List<DomainEvent> domainEvents = new ArrayList<>();
protected AggregateRoot(T id) {
super(id);
}
protected void addDomainEvent(DomainEvent event) {
domainEvents.add(event);
}
public List<DomainEvent> getDomainEvents() {
return new ArrayList<>(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
// shared/domain/DomainEvent.java
package com.ecommerce.shared.domain;
import java.time.LocalDateTime;
public abstract class DomainEvent {
private final LocalDateTime occurredOn;
protected DomainEvent() {
this.occurredOn = LocalDateTime.now();
}
public LocalDateTime getOccurredOn() {
return occurredOn;
}
}
// shared/infrastructure/EventPublisher.java
package com.ecommerce.shared.infrastructure;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import com.ecommerce.shared.domain.DomainEvent;
@Component
public class EventPublisher {
private final ApplicationEventPublisher publisher;
public EventPublisher(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void publish(DomainEvent event) {
publisher.publishEvent(event);
}
}
3. Customer Domain
// customer/domain/CustomerId.java
package com.ecommerce.customer.domain;
import com.ecommerce.shared.domain.ValueObject;
import java.util.Objects;
import java.util.UUID;
public class CustomerId extends ValueObject {
private final String value;
private CustomerId(String value) {
this.value = value;
}
public static CustomerId generate() {
return new CustomerId(UUID.randomUUID().toString());
}
public static CustomerId of(String value) {
return new CustomerId(value);
}
public String getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CustomerId that = (CustomerId) o;
return Objects.equals(value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
// customer/domain/Email.java
package com.ecommerce.customer.domain;
import com.ecommerce.shared.domain.ValueObject;
import java.util.Objects;
import java.util.regex.Pattern;
public class Email extends ValueObject {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");
private final String value;
private Email(String value) {
if (!EMAIL_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid email format");
}
this.value = value;
}
public static Email of(String value) {
return new Email(value);
}
public String getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Email email = (Email) o;
return Objects.equals(value, email.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
// customer/domain/Customer.java
package com.ecommerce.customer.domain;
import com.ecommerce.shared.domain.AggregateRoot;
public class Customer extends AggregateRoot<CustomerId> {
private String name;
private Email email;
private Customer(CustomerId id, String name, Email email) {
super(id);
this.name = name;
this.email = email;
}
public static Customer create(String name, Email email) {
return new Customer(CustomerId.generate(), name, email);
}
public static Customer restore(CustomerId id, String name, Email email) {
return new Customer(id, name, email);
}
public String getName() {
return name;
}
public Email getEmail() {
return email;
}
public void updateName(String name) {
this.name = name;
}
public void updateEmail(Email email) {
this.email = email;
}
}
// customer/domain/CustomerRepository.java
package com.ecommerce.customer.domain;
import java.util.Optional;
public interface CustomerRepository {
Customer save(Customer customer);
Optional<Customer> findById(CustomerId id);
Optional<Customer> findByEmail(Email email);
}
4. Product Domain
// product/domain/ProductId.java
package com.ecommerce.product.domain;
import com.ecommerce.shared.domain.ValueObject;
import java.util.Objects;
import java.util.UUID;
public class ProductId extends ValueObject {
private final String value;
private ProductId(String value) {
this.value = value;
}
public static ProductId generate() {
return new ProductId(UUID.randomUUID().toString());
}
public static ProductId of(String value) {
return new ProductId(value);
}
public String getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ProductId productId = (ProductId) o;
return Objects.equals(value, productId.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
// product/domain/Money.java
package com.ecommerce.product.domain;
import com.ecommerce.shared.domain.ValueObject;
import java.math.BigDecimal;
import java.util.Objects;
public class Money extends ValueObject {
private final BigDecimal amount;
private final String currency;
private Money(BigDecimal amount, String currency) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
this.amount = amount;
this.currency = currency;
}
public static Money of(BigDecimal amount, String currency) {
return new Money(amount, currency);
}
public static Money usd(BigDecimal amount) {
return new Money(amount, "USD");
}
public BigDecimal getAmount() {
return amount;
}
public String getCurrency() {
return currency;
}
public Money multiply(int quantity) {
return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
}
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies");
}
return new Money(amount.add(other.amount), currency);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return Objects.equals(amount, money.amount) &&
Objects.equals(currency, money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
// product/domain/Product.java
package com.ecommerce.product.domain;
import com.ecommerce.shared.domain.AggregateRoot;
public class Product extends AggregateRoot<ProductId> {
private String name;
private String description;
private Money price;
private int stockQuantity;
private Product(ProductId id, String name, String description, Money price, int stockQuantity) {
super(id);
this.name = name;
this.description = description;
this.price = price;
this.stockQuantity = stockQuantity;
}
public static Product create(String name, String description, Money price, int stockQuantity) {
return new Product(ProductId.generate(), name, description, price, stockQuantity);
}
public static Product restore(ProductId id, String name, String description, Money price, int stockQuantity) {
return new Product(id, name, description, price, stockQuantity);
}
public boolean isAvailable(int quantity) {
return stockQuantity >= quantity;
}
public void reduceStock(int quantity) {
if (!isAvailable(quantity)) {
throw new IllegalStateException("Insufficient stock");
}
this.stockQuantity -= quantity;
}
// Getters
public String getName() { return name; }
public String getDescription() { return description; }
public Money getPrice() { return price; }
public int getStockQuantity() { return stockQuantity; }
}
// product/domain/ProductRepository.java
package com.ecommerce.product.domain;
import java.util.Optional;
import java.util.List;
public interface ProductRepository {
Product save(Product product);
Optional<Product> findById(ProductId id);
List<Product> findAll();
}
5. Order Domain
// order/domain/OrderId.java
package com.ecommerce.order.domain;
import com.ecommerce.shared.domain.ValueObject;
import java.util.Objects;
import java.util.UUID;
public class OrderId extends ValueObject {
private final String value;
private OrderId(String value) {
this.value = value;
}
public static OrderId generate() {
return new OrderId(UUID.randomUUID().toString());
}
public static OrderId of(String value) {
return new OrderId(value);
}
public String getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OrderId orderId = (OrderId) o;
return Objects.equals(value, orderId.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
// order/domain/OrderStatus.java
package com.ecommerce.order.domain;
public enum OrderStatus {
PENDING,
CONFIRMED,
SHIPPED,
DELIVERED,
CANCELLED
}
// order/domain/OrderItem.java
package com.ecommerce.order.domain;
import com.ecommerce.product.domain.ProductId;
import com.ecommerce.product.domain.Money;
import com.ecommerce.shared.domain.ValueObject;
import java.util.Objects;
public class OrderItem extends ValueObject {
private final ProductId productId;
private final String productName;
private final Money unitPrice;
private final int quantity;
public OrderItem(ProductId productId, String productName, Money unitPrice, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
this.productId = productId;
this.productName = productName;
this.unitPrice = unitPrice;
this.quantity = quantity;
}
public Money getTotalPrice() {
return unitPrice.multiply(quantity);
}
// Getters
public ProductId getProductId() { return productId; }
public String getProductName() { return productName; }
public Money getUnitPrice() { return unitPrice; }
public int getQuantity() { return quantity; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OrderItem orderItem = (OrderItem) o;
return quantity == orderItem.quantity &&
Objects.equals(productId, orderItem.productId) &&
Objects.equals(productName, orderItem.productName) &&
Objects.equals(unitPrice, orderItem.unitPrice);
}
@Override
public int hashCode() {
return Objects.hash(productId, productName, unitPrice, quantity);
}
}
// order/domain/OrderCreatedEvent.java
package com.ecommerce.order.domain;
import com.ecommerce.shared.domain.DomainEvent;
import com.ecommerce.customer.domain.CustomerId;
public class OrderCreatedEvent extends DomainEvent {
private final OrderId orderId;
private final CustomerId customerId;
public OrderCreatedEvent(OrderId orderId, CustomerId customerId) {
super();
this.orderId = orderId;
this.customerId = customerId;
}
public OrderId getOrderId() {
return orderId;
}
public CustomerId getCustomerId() {
return customerId;
}
}
// order/domain/Order.java
package com.ecommerce.order.domain;
import com.ecommerce.shared.domain.AggregateRoot;
import com.ecommerce.customer.domain.CustomerId;
import com.ecommerce.product.domain.Money;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.math.BigDecimal;
public class Order extends AggregateRoot<OrderId> {
private CustomerId customerId;
private List<OrderItem> items;
private OrderStatus status;
private LocalDateTime orderDate;
private Order(OrderId id, CustomerId customerId) {
super(id);
this.customerId = customerId;
this.items = new ArrayList<>();
this.status = OrderStatus.PENDING;
this.orderDate = LocalDateTime.now();
}
public static Order create(CustomerId customerId) {
Order order = new Order(OrderId.generate(), customerId);
order.addDomainEvent(new OrderCreatedEvent(order.getId(), customerId));
return order;
}
public static Order restore(OrderId id, CustomerId customerId, List<OrderItem> items,
OrderStatus status, LocalDateTime orderDate) {
Order order = new Order(id, customerId);
order.items = new ArrayList<>(items);
order.status = status;
order.orderDate = orderDate;
return order;
}
public void addItem(OrderItem item) {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Cannot modify confirmed order");
}
items.add(item);
}
public void confirm() {
if (items.isEmpty()) {
throw new IllegalStateException("Cannot confirm empty order");
}
this.status = OrderStatus.CONFIRMED;
}
public Money getTotalAmount() {
return items.stream()
.map(OrderItem::getTotalPrice)
.reduce(Money.usd(BigDecimal.ZERO), Money::add);
}
// Getters
public CustomerId getCustomerId() { return customerId; }
public List<OrderItem> getItems() { return new ArrayList<>(items); }
public OrderStatus getStatus() { return status; }
public LocalDateTime getOrderDate() { return orderDate; }
}
// order/domain/OrderRepository.java
package com.ecommerce.order.domain;
import com.ecommerce.customer.domain.CustomerId;
import java.util.Optional;
import java.util.List;
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findByCustomerId(CustomerId customerId);
}
6. Application Layer
// order/application/dto/CreateOrderRequest.java
package com.ecommerce.order.application.dto;
import java.util.List;
public class CreateOrderRequest {
private String customerId;
private List<OrderItemRequest> items;
public static class OrderItemRequest {
private String productId;
private int quantity;
// Getters and setters
public String getProductId() { return productId; }
public void setProductId(String productId) { this.productId = productId; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
}
// Getters and setters
public String getCustomerId() { return customerId; }
public void setCustomerId(String customerId) { this.customerId = customerId; }
public List<OrderItemRequest> getItems() { return items; }
public void setItems(List<OrderItemRequest> items) { this.items = items; }
}
// order/application/dto/OrderResponse.java
package com.ecommerce.order.application.dto;
import com.ecommerce.order.domain.Order;
import com.ecommerce.order.domain.OrderItem;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
public class OrderResponse {
private String id;
private String customerId;
private String status;
private LocalDateTime orderDate;
private BigDecimal totalAmount;
private List<OrderItemResponse> items;
public static class OrderItemResponse {
private String productId;
private String productName;
private BigDecimal unitPrice;
private int quantity;
private BigDecimal totalPrice;
public static OrderItemResponse from(OrderItem item) {
OrderItemResponse response = new OrderItemResponse();
response.productId = item.getProductId().getValue();
response.productName = item.getProductName();
response.unitPrice = item.getUnitPrice().getAmount();
response.quantity = item.getQuantity();
response.totalPrice = item.getTotalPrice().getAmount();
return response;
}
// Getters
public String getProductId() { return productId; }
public String getProductName() { return productName; }
public BigDecimal getUnitPrice() { return unitPrice; }
public int getQuantity() { return quantity; }
public BigDecimal getTotalPrice() { return totalPrice; }
}
public static OrderResponse from(Order order) {
OrderResponse response = new OrderResponse();
response.id = order.getId().getValue();
response.customerId = order.getCustomerId().getValue();
response.status = order.getStatus().name();
response.orderDate = order.getOrderDate();
response.totalAmount = order.getTotalAmount().getAmount();
response.items = order.getItems().stream()
.map(OrderItemResponse::from)
.collect(Collectors.toList());
return response;
}
// Getters
public String getId() { return id; }
public String getCustomerId() { return customerId; }
public String getStatus() { return status; }
public LocalDateTime getOrderDate() { return orderDate; }
public BigDecimal getTotalAmount() { return totalAmount; }
public List<OrderItemResponse> getItems() { return items; }
}
// order/application/CreateOrderUseCase.java
package com.ecommerce.order.application;
import com.ecommerce.order.domain.*;
import com.ecommerce.customer.domain.*;
import com.ecommerce.product.domain.*;
import com.ecommerce.order.application.dto.CreateOrderRequest;
import com.ecommerce.shared.infrastructure.EventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final CustomerRepository customerRepository;
private final ProductRepository productRepository;
private final EventPublisher eventPublisher;
public CreateOrderUseCase(OrderRepository orderRepository,
CustomerRepository customerRepository,
ProductRepository productRepository,
EventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
this.productRepository = productRepository;
this.eventPublisher = eventPublisher;
}
public Order execute(CreateOrderRequest request) {
// Validate customer exists
CustomerId customerId = CustomerId.of(request.getCustomerId());
customerRepository.findById(customerId)
.orElseThrow(() -> new IllegalArgumentException("Customer not found"));
// Create order
Order order = Order.create(customerId);
// Add items
for (CreateOrderRequest.OrderItemRequest itemRequest : request.getItems()) {
ProductId productId = ProductId.of(itemRequest.getProductId());
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("Product not found"));
if (!product.isAvailable(itemRequest.getQuantity())) {
throw new IllegalStateException("Insufficient stock for product: " + product.getName());
}
OrderItem item = new OrderItem(
productId,
product.getName(),
product.getPrice(),
itemRequest.getQuantity()
);
order.addItem(item);
product.reduceStock(itemRequest.getQuantity());
productRepository.save(product);
}
order.confirm();
Order savedOrder = orderRepository.save(order);
// Publish domain events
savedOrder.getDomainEvents().forEach(eventPublisher::publish);
savedOrder.clearDomainEvents();
return savedOrder;
}
}
7. Infrastructure Layer
// customer/infrastructure/CustomerEntity.java
package com.ecommerce.customer.infrastructure;
import javax.persistence.*;
@Entity
@Table(name = "customers")
public class CustomerEntity {
@Id
private String id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
// Constructors
public CustomerEntity() {}
public CustomerEntity(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
// customer/infrastructure/CustomerJpaRepository.java
package com.ecommerce.customer.infrastructure;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface CustomerJpaRepository extends JpaRepository<CustomerEntity, String> {
Optional<CustomerEntity> findByEmail(String email);
}
// customer/infrastructure/CustomerRepositoryImpl.java
package com.ecommerce.customer.infrastructure;
import com.ecommerce.customer.domain.*;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class CustomerRepositoryImpl implements CustomerRepository {
private final CustomerJpaRepository jpaRepository;
public CustomerRepositoryImpl(CustomerJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Customer save(Customer customer) {
CustomerEntity entity = new CustomerEntity(
customer.getId().getValue(),
customer.getName(),
customer.getEmail().getValue()
);
jpaRepository.save(entity);
return customer;
}
@Override
public Optional<Customer> findById(CustomerId id) {
return jpaRepository.findById(id.getValue())
.map(this::toDomain);
}
@Override
public Optional<Customer> findByEmail(Email email) {
return jpaRepository.findByEmail(email.getValue())
.map(this::toDomain);
}
private Customer toDomain(CustomerEntity entity) {
return Customer.restore(
CustomerId.of(entity.getId()),
entity.getName(),
Email.of(entity.getEmail())
);
}
}
8. Web Layer
// web/OrderController.java
package com.ecommerce.web;
import com.ecommerce.order.application.CreateOrderUseCase;
import com.ecommerce.order.application.dto.CreateOrderRequest;
import com.ecommerce.order.application.dto.OrderResponse;
import com.ecommerce.order.domain.*;
import com.ecommerce.customer.domain.CustomerId;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
private final OrderRepository orderRepository;
public OrderController(CreateOrderUseCase createOrderUseCase,
OrderRepository orderRepository) {
this.createOrderUseCase = createOrderUseCase;
this.orderRepository = orderRepository;
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
try {
Order order = createOrderUseCase.execute(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(OrderResponse.from(order));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable String orderId) {
return orderRepository.findById(OrderId.of(orderId))
.map(order -> ResponseEntity.ok(OrderResponse.from(order)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/customer/{customerId}")
public ResponseEntity<List<OrderResponse>> getOrdersByCustomer(@PathVariable String customerId) {
List<Order> orders = orderRepository.findByCustomerId(CustomerId.of(customerId));
List<OrderResponse> responses = orders.stream()
.map(OrderResponse::from)
.collect(Collectors.toList());
return ResponseEntity.ok(responses);
}
}
// web/CustomerController.java
package com.ecommerce.web;
import com.ecommerce.customer.domain.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/customers")
public class CustomerController {
private final CustomerRepository customerRepository;
public CustomerController(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
@PostMapping
public ResponseEntity<CustomerResponse> createCustomer(@RequestBody CreateCustomerRequest request) {
try {
Customer customer = Customer.create(request.getName(), Email.of(request.getEmail()));
Customer saved = customerRepository.save(customer);
return ResponseEntity.status(HttpStatus.CREATED)
.body(CustomerResponse.from(saved));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/{customerId}")
public ResponseEntity<CustomerResponse> getCustomer(@PathVariable String customerId) {
return customerRepository.findById(CustomerId.of(customerId))
.map(customer -> ResponseEntity.ok(CustomerResponse.from(customer)))
.orElse(ResponseEntity.notFound().build());
}
public static class CreateCustomerRequest {
private String name;
private String email;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
public static class CustomerResponse {
private String id;
private String name;
private String email;
public static CustomerResponse from(Customer customer) {
CustomerResponse response = new CustomerResponse();
response.id = customer.getId().getValue();
response.name = customer.getName();
response.email = customer.getEmail().getValue();
return response;
}
public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
}
}
// web/ProductController.java
package com.ecommerce.web;
import com.ecommerce.product.domain.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@PostMapping
public ResponseEntity<ProductResponse> createProduct(@RequestBody CreateProductRequest request) {
try {
Money price = Money.usd(request.getPrice());
Product product = Product.create(
request.getName(),
request.getDescription(),
price,
request.getStockQuantity()
);
Product saved = productRepository.save(product);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ProductResponse.from(saved));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping
public ResponseEntity<List<ProductResponse>> getAllProducts() {
List<Product> products = productRepository.findAll();
List<ProductResponse> responses = products.stream()
.map(ProductResponse::from)
.collect(Collectors.toList());
return ResponseEntity.ok(responses);
}
@GetMapping("/{productId}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable String productId) {
return productRepository.findById(ProductId.of(productId))
.map(product -> ResponseEntity.ok(ProductResponse.from(product)))
.orElse(ResponseEntity.notFound().build());
}
public static class CreateProductRequest {
private String name;
private String description;
private BigDecimal price;
private int stockQuantity;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public int getStockQuantity() { return stockQuantity; }
public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
}
public static class ProductResponse {
private String id;
private String name;
private String description;
private BigDecimal price;
private int stockQuantity;
public static ProductResponse from(Product product) {
ProductResponse response = new ProductResponse();
response.id = product.getId().getValue();
response.name = product.getName();
response.description = product.getDescription();
response.price = product.getPrice().getAmount();
response.stockQuantity = product.getStockQuantity();
return response;
}
public String getId() { return id; }
public String getName() { return name; }
public String getDescription() { return description; }
public BigDecimal getPrice() { return price; }
public int getStockQuantity() { return stockQuantity; }
}
}
9. Infrastructure Implementation - Product & Order
// product/infrastructure/ProductEntity.java
package com.ecommerce.product.infrastructure;
import javax.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "products")
public class ProductEntity {
@Id
private String id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private String currency;
@Column(name = "stock_quantity", nullable = false)
private int stockQuantity;
// Constructors
public ProductEntity() {}
public ProductEntity(String id, String name, String description,
BigDecimal price, String currency, int stockQuantity) {
this.id = id;
this.name = name;
this.description = description;
this.price = price;
this.currency = currency;
this.stockQuantity = stockQuantity;
}
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public int getStockQuantity() { return stockQuantity; }
public void setStockQuantity(int stockQuantity) { this.stockQuantity = stockQuantity; }
}
// product/infrastructure/ProductJpaRepository.java
package com.ecommerce.product.infrastructure;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductJpaRepository extends JpaRepository<ProductEntity, String> {
}
// product/infrastructure/ProductRepositoryImpl.java
package com.ecommerce.product.infrastructure;
import com.ecommerce.product.domain.*;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.List;
import java.util.stream.Collectors;
@Repository
public class ProductRepositoryImpl implements ProductRepository {
private final ProductJpaRepository jpaRepository;
public ProductRepositoryImpl(ProductJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Product save(Product product) {
ProductEntity entity = new ProductEntity(
product.getId().getValue(),
product.getName(),
product.getDescription(),
product.getPrice().getAmount(),
product.getPrice().getCurrency(),
product.getStockQuantity()
);
jpaRepository.save(entity);
return product;
}
@Override
public Optional<Product> findById(ProductId id) {
return jpaRepository.findById(id.getValue())
.map(this::toDomain);
}
@Override
public List<Product> findAll() {
return jpaRepository.findAll().stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
private Product toDomain(ProductEntity entity) {
return Product.restore(
ProductId.of(entity.getId()),
entity.getName(),
entity.getDescription(),
Money.of(entity.getPrice(), entity.getCurrency()),
entity.getStockQuantity()
);
}
}
// order/infrastructure/OrderEntity.java
package com.ecommerce.order.infrastructure;
import com.ecommerce.order.domain.OrderStatus;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
private String id;
@Column(name = "customer_id", nullable = false)
private String customerId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;
@Column(name = "order_date", nullable = false)
private LocalDateTime orderDate;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderItemEntity> items = new ArrayList<>();
// Constructors
public OrderEntity() {}
public OrderEntity(String id, String customerId, OrderStatus status, LocalDateTime orderDate) {
this.id = id;
this.customerId = customerId;
this.status = status;
this.orderDate = orderDate;
}
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getCustomerId() { return customerId; }
public void setCustomerId(String customerId) { this.customerId = customerId; }
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus status) { this.status = status; }
public LocalDateTime getOrderDate() { return orderDate; }
public void setOrderDate(LocalDateTime orderDate) { this.orderDate = orderDate; }
public List<OrderItemEntity> getItems() { return items; }
public void setItems(List<OrderItemEntity> items) { this.items = items; }
}
// order/infrastructure/OrderItemEntity.java
package com.ecommerce.order.infrastructure;
import javax.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "order_items")
public class OrderItemEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private OrderEntity order;
@Column(name = "product_id", nullable = false)
private String productId;
@Column(name = "product_name", nullable = false)
private String productName;
@Column(name = "unit_price", nullable = false, precision = 19, scale = 2)
private BigDecimal unitPrice;
@Column(nullable = false)
private String currency;
@Column(nullable = false)
private int quantity;
// Constructors
public OrderItemEntity() {}
public OrderItemEntity(OrderEntity order, String productId, String productName,
BigDecimal unitPrice, String currency, int quantity) {
this.order = order;
this.productId = productId;
this.productName = productName;
this.unitPrice = unitPrice;
this.currency = currency;
this.quantity = quantity;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public OrderEntity getOrder() { return order; }
public void setOrder(OrderEntity order) { this.order = order; }
public String getProductId() { return productId; }
public void setProductId(String productId) { this.productId = productId; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public BigDecimal getUnitPrice() { return unitPrice; }
public void setUnitPrice(BigDecimal unitPrice) { this.unitPrice = unitPrice; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; }
}
// order/infrastructure/OrderJpaRepository.java
package com.ecommerce.order.infrastructure;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface OrderJpaRepository extends JpaRepository<OrderEntity, String> {
@Query("SELECT o FROM OrderEntity o WHERE o.customerId = :customerId")
List<OrderEntity> findByCustomerId(@Param("customerId") String customerId);
}
// order/infrastructure/OrderRepositoryImpl.java
package com.ecommerce.order.infrastructure;
import com.ecommerce.order.domain.*;
import com.ecommerce.customer.domain.CustomerId;
import com.ecommerce.product.domain.ProductId;
import com.ecommerce.product.domain.Money;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.List;
import java.util.stream.Collectors;
@Repository
public class OrderRepositoryImpl implements OrderRepository {
private final OrderJpaRepository jpaRepository;
public OrderRepositoryImpl(OrderJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Order save(Order order) {
OrderEntity entity = new OrderEntity(
order.getId().getValue(),
order.getCustomerId().getValue(),
order.getStatus(),
order.getOrderDate()
);
List<OrderItemEntity> itemEntities = order.getItems().stream()
.map(item -> new OrderItemEntity(
entity,
item.getProductId().getValue(),
item.getProductName(),
item.getUnitPrice().getAmount(),
item.getUnitPrice().getCurrency(),
item.getQuantity()
))
.collect(Collectors.toList());
entity.setItems(itemEntities);
jpaRepository.save(entity);
return order;
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.getValue())
.map(this::toDomain);
}
@Override
public List<Order> findByCustomerId(CustomerId customerId) {
return jpaRepository.findByCustomerId(customerId.getValue()).stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
private Order toDomain(OrderEntity entity) {
List<OrderItem> items = entity.getItems().stream()
.map(itemEntity -> new OrderItem(
ProductId.of(itemEntity.getProductId()),
itemEntity.getProductName(),
Money.of(itemEntity.getUnitPrice(), itemEntity.getCurrency()),
itemEntity.getQuantity()
))
.collect(Collectors.toList());
return Order.restore(
OrderId.of(entity.getId()),
CustomerId.of(entity.getCustomerId()),
items,
entity.getStatus(),
entity.getOrderDate()
);
}
}
10. Configuration Files
# application.yml
spring:
application:
name: ecommerce-ddd
datasource:
url: jdbc:h2:mem:testdb
driverClassName: org.h2.Driver
username: sa
password: password
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
path: /h2-console
server:
port: 8080
logging:
level:
com.ecommerce: DEBUG
org.hibernate.SQL: DEBUG
-- data.sql
-- Sample data for testing
INSERT INTO customers (id, name, email) VALUES
('cust-001', 'John Doe', 'john.doe@example.com'),
('cust-002', 'Jane Smith', 'jane.smith@example.com');
INSERT INTO products (id, name, description, price, currency, stock_quantity) VALUES
('prod-001', 'Laptop', 'High-performance laptop', 999.99, 'USD', 10),
('prod-002', 'Mouse', 'Wireless mouse', 29.99, 'USD', 50),
('prod-003', 'Keyboard', 'Mechanical keyboard', 149.99, 'USD', 25);
11. Maven Dependencies (pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>ecommerce-ddd</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>E-Commerce DDD</name>
<description>E-Commerce system using Domain Driven Design</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/>
</parent>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
12. Unit Tests
// test/java/com/ecommerce/order/domain/OrderTest.java
package com.ecommerce.order.domain;
import com.ecommerce.customer.domain.CustomerId;
import com.ecommerce.product.domain.ProductId;
import com.ecommerce.product.domain.Money;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
class OrderTest {
@Test
void shouldCreateEmptyOrder() {
CustomerId customerId = CustomerId.generate();
Order order = Order.create(customerId);
assertNotNull(order.getId());
assertEquals(customerId, order.getCustomerId());
assertEquals(OrderStatus.PENDING, order.getStatus());
assertTrue(order.getItems().isEmpty());
assertNotNull(order.getOrderDate());
}
@Test
void shouldAddItemToOrder() {
Order order = Order.create(CustomerId.generate());
OrderItem item = new OrderItem(
ProductId.generate(),
"Test Product",
Money.usd(new BigDecimal("99.99")),
2
);
order.addItem(item);
assertEquals(1, order.getItems().size());
assertEquals(item, order.getItems().get(0));
}
@Test
void shouldCalculateTotalAmount() {
Order order = Order.create(CustomerId.generate());
OrderItem item1 = new OrderItem(
ProductId.generate(),
"Product 1",
Money.usd(new BigDecimal("99.99")),
2
);
OrderItem item2 = new OrderItem(
ProductId.generate(),
"Product 2",
Money.usd(new BigDecimal("49.99")),
1
);
order.addItem(item1);
order.addItem(item2);
Money total = order.getTotalAmount();
assertEquals(new BigDecimal("249.97"), total.getAmount());
}
@Test
void shouldConfirmOrderWithItems() {
Order order = Order.create(CustomerId.generate());
OrderItem item = new OrderItem(
ProductId.generate(),
"Test Product",
Money.usd(new BigDecimal("99.99")),
1
);
order.addItem(item);
order.confirm();
assertEquals(OrderStatus.CONFIRMED, order.getStatus());
}
@Test
void shouldNotConfirmEmptyOrder() {
Order order = Order.create(CustomerId.generate());
assertThrows(IllegalStateException.class, order::confirm);
}
@Test
void shouldNotAddItemToConfirmedOrder() {
Order order = Order.create(CustomerId.generate());
OrderItem item1 = new OrderItem(
ProductId.generate(),
"Product 1",
Money.usd(new BigDecimal("99.99")),
1
);
order.addItem(item1);
order.confirm();
OrderItem item2 = new OrderItem(
ProductId.generate(),
"Product 2",
Money.usd(new BigDecimal("49.99")),
1
);
assertThrows(IllegalStateException.class, () -> order.addItem(item2));
}
}
API Usage Examples
1. Create Customer
POST /api/customers
{
"name": "John Doe",
"email": "john.doe@example.com"
}
2. Create Product
POST /api/products
{
"name": "Laptop",
"description": "High-performance laptop",
"price": 999.99,
"stockQuantity": 10
}
3. Create Order
POST /api/orders
{
"customerId": "cust-001",
"items": [
{
"productId": "prod-001",
"quantity": 1
},
{
"productId": "prod-002",
"quantity": 2
}
]
}
4. Get Order
GET /api/orders/{orderId}
I've completed the comprehensive Spring Boot DDD project! This e-commerce order management system demonstrates all the key principles of Domain Driven Design:
Key Features Implemented:
🏗️ DDD Architecture:
- Domain Layer: Rich domain models (Customer, Product, Order)
- Application Layer: Use cases and DTOs
- Infrastructure Layer: JPA repositories and entities
- Web Layer: REST controllers
🎯 Core DDD Concepts:
- Aggregates: Order, Customer, Product as aggregate roots
- Value Objects: Money, Email, CustomerId with immutability
- Domain Events: OrderCreatedEvent for decoupled communication
- Repositories: Clean abstraction over data access
- Ubiquitous Language: Business-focused terminology throughout
⚡ Business Logic:
- Customer management with email validation
- Product inventory tracking
- Order creation with stock validation
- Domain event publishing
- Transaction management
🚀 Technical Stack:
- Spring Boot 2.7.0
- Spring Data JPA
- H2 Database (in-memory)
- JUnit 5 for testing
- Maven for dependency management
📊 API Endpoints:
- Customer CRUD operations
- Product management
- Order creation and retrieval
- Comprehensive error handling
The project follows clean architecture principles with proper separation of concerns. Each domain is isolated with its own repository, and business rules are encapsulated within domain objects. The application includes comprehensive unit tests and sample data for quick testing.
You can run this application with mvn spring-boot:run
and access the H2 console at http://localhost:8080/h2-console
for database inspection.
Top comments (0)