Application to build Link to heading
In the following article you will learn how to avoid building Anemic Domain Model and Transaction Script code when you have business logic in your application. We have
Order
has severalOrderLine
- Each
OrderLine
contains theArticle
and the quantity bought - Apply a discount if the client is good
And we want to create a new Order through endpoint API POST /orders
{
"clientId": 1,
"linesOrder": [
{
"articleId": 1,
"quantity": 2
},
...
]
}
Then is the second part of the article we will add two use-cases :
- remove an
OrderLine
of anOrder
- add the notion of tracking Article Movements
Aggregate Link to heading
Aggregate is a pattern in Domain-Driven Design. A DDD aggregate is a cluster of domain objects that can be treated as a single unit. 1
In Domain-Driven Design (DDD), Aggregates are a core tactical building block. They help manage consistency boundaries and enforce invariants within a domain model. Based on your description, let’s walk through how Aggregates could be modeled for your case.
Order Aggregate Link to heading
Order aggregate contains both Order
and OrderLine
object
- All LineOrder instances are owned by the Order.
- You likely want to enforce rules like: “An Order cannot exceed 10 LineOrders”, “An Order’s total must not exceed a limit”, etc.
- You always manipulate LineOrders through the Order.
=> Aggregate root Order
Article Aggregate Link to heading
- Articles are referenced by LineOrders but not owned by them.
- Articles are likely updated independently (e.g. inventory, name changes).
=> Aggregate Root Article
Guiding Principles Link to heading
- Only access aggregate roots directly from repositories.
- Cross-aggregate consistency must be handled eventually or via domain events.
- Keep aggregates small enough to fit in memory and be saved atomically.
1. Create a new Order Link to heading
Now, we want to handle the creation of an Order aggregate based on input JSON. Here’s a full breakdown of how to structure this in line with DDD principles.
Domain Model Entities Link to heading
class Order {
private Long id;
private Long clientId;
private List<LineOrder> lineOrders = new ArrayList<>();
private BigDecimal total;
public Order(Long clientId) {
this.clientId = clientId;
this.total = BigDecimal.ZERO;
}
public void addLineOrder(LineOrder lineOrder) {
lineOrders.add(lineOrder);
total = total.add(lineOrder.getLineTotal());
}
public void applyDiscount(BigDecimal discountRate) {
total = total.subtract(total.multiply(discountRate));
}
}
class LineOrder {
private Long articleId;
private int quantity;
private BigDecimal unitPrice;
public LineOrder(Long articleId, int quantity, BigDecimal unitPrice) {
this.articleId = articleId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
public BigDecimal getLineTotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}
Service Layer (Application Service) Link to heading
The Application Service is orchestrating the use case, not containing domain logic.
@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final ArticleRepository articleRepository;
private final ClientRepository clientRepository;
public OrderApplicationService(OrderRepository orderRepository,
ArticleRepository articleRepository,
ClientRepository clientRepository) {
this.orderRepository = orderRepository;
this.articleRepository = articleRepository;
this.clientRepository = clientRepository;
}
@Transactional
public Long createOrder(CreateOrderRequest request) {
// Step 1: Create Order
Order order = new Order(request.getClientId());
// Step 2: Add LineOrders
for (CreateLineOrderDTO lineDTO : request.getLineOrders()) {
Article article = articleRepository.findById(lineDTO.getArticleId())
.orElseThrow(() -> new RuntimeException("Article not found"));
LineOrder lineOrder = new LineOrder(
article.getId(),
lineDTO.getQuantity(),
article.getPrice()
);
order.addLineOrder(lineOrder);
}
// Step 3: Apply discount if client is good
Client client = clientRepository.findById(request.getClientId())
.orElseThrow(() -> new RuntimeException("Client not found"));
if (client.isGoodClient()) {
order.applyDiscount(BigDecimal.valueOf(0.10)); // 10%
}
// Step 4: Save Order, only Aggregate root persisted !
orderRepository.save(order);
return order.getId();
}
}
The application service layer
- have access to the repository, only aggregate roots are saved
- orchestre application logic : get data, create object, persist data
- but not handle domain logic like calculate the total (managed by
Order
)
- but not handle domain logic like calculate the total (managed by
2. Delete lineOrder Link to heading
How develop the new feature to remove a lineOrder to an Order ? DELETE orders/{orderId}/lines/{lineOrderId}
Application Service Link to heading
@Transactional
public void deleteLineOrder(Long orderId, Long lineOrderId) {
// Step 1: Load Order Aggregate
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
// Step 2: Modify Aggregate
order.removeLineOrder(lineOrderId);
// Step 3: Save Order, only access to Aggregate Root
orderRepository.save(order); // assumes aggregate pattern
}
No change is needed in your repositories unless you’re persisting LineOrder separately. In typical aggregate design, it’s embedded and saved via Order
.
Like Order
is the aggregate root we have to go through it to modify the order (add or remove line).
Domain Logic in Aggregate Root (Order class) Link to heading
Add this method in your Order class
public class Order {
public void removeLineOrder(Long lineOrderId) {
LineOrder toRemove = lineOrders.stream()
.filter(line -> line.getId().equals(lineOrderId))
.findFirst()
.orElseThrow(() -> new RuntimeException("LineOrder not found"));
total = total.subtract(toRemove.getLineTotal());
lineOrders.remove(toRemove);
}
If needed, enforce additional logic after removal (e.g., cannot remove last item, or Order total must stay positive).
3. Tracking Article Movements Link to heading
We want track (make an history) of all sell on an article.
- when an article is add to an order, create a new Movement with the articleId and the quantity
- when an article is remove from an order, just remove the Movement
Domain Layer Link to heading
Add a new Domain Entity
public class Mvt {
private Long id;
private Long articleId;
private int quantity;
// getter and setter
}
Application Service (createOrder + Mvt tracking) Link to heading
The Application Service Layer orchestrate the business logic
@Transactional
public Long createOrder(CreateOrderRequest request) {
Order order = new Order(request.getClientId());
for (CreateLineOrderDTO lineDTO : request.getLineOrders()) {
Article article = articleRepository.findById(lineDTO.getArticleId())
.orElseThrow(() -> new RuntimeException("Article not found"));
LineOrder lineOrder = new LineOrder(
article.getId(),
lineDTO.getQuantity(),
article.getPrice()
);
order.addLineOrder(lineOrder);
// Track movement
Mvt mvt = new Mvt(article.getId(), lineDTO.getQuantity());
mvtRepository.save(mvt);
}
Client client = clientRepository.findById(request.getClientId())
.orElseThrow(() -> new RuntimeException("Client not found"));
if (client.isGoodClient()) {
order.applyDiscount(BigDecimal.valueOf(0.10));
}
orderRepository.save(order);
return order.getId();
}
Application Service (deleteLineOrder + Mvt cleanup) Link to heading
@Transactional
public void deleteLineOrder(Long orderId, Long lineOrderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
LineOrder toRemove = order.getLineOrderById(lineOrderId); // helper method
if (toRemove == null) {
throw new RuntimeException("LineOrder not found");
}
// Remove LineOrder from Order
order.removeLineOrder(lineOrderId);
// Remove associated movement
mvtRepository.deleteByArticleIdAndQuantity(toRemove.getArticleId(), toRemove.getQuantity());
orderRepository.save(order);
}
class Order {
public LineOrder getLineOrderById(Long lineOrderId) {
return lineOrders.stream()
.filter(l -> l.getId().equals(lineOrderId))
.findFirst()
.orElse(null);
}
}
Alternative: Use Domain Events Link to heading
If you want to decouple OrderApplicationService
from MvtRepository
, raise a LineOrderCreatedEvent
and handle it in a separate component.
On creation Link to heading
@Transactional
public Long createOrder(CreateOrderRequest request) {
Order order = new Order(request.getClientId());
for (CreateLineOrderDTO lineDTO : request.getLineOrders()) {
Article article = articleRepository.findById(lineDTO.getArticleId())
.orElseThrow(() -> new RuntimeException("Article not found"));
LineOrder lineOrder = new LineOrder(article.getId(), lineDTO.getQuantity(), article.getPrice());
order.addLineOrder(lineOrder);
// Publish creation event
eventPublisher.publish(new LineOrderCreatedEvent(article.getId(), lineDTO.getQuantity()));
}
Client client = clientRepository.findById(request.getClientId())
.orElseThrow(() -> new RuntimeException("Client not found"));
if (client.isGoodClient()) {
order.applyDiscount(BigDecimal.valueOf(0.10));
}
orderRepository.save(order);
return order.getId();
}
We could also raise the event in the Domain, in the addLineOrder()
method
Event Listeners Link to heading
@Component
public class MovementEventHandler {
private final MvtRepository mvtRepository;
public MovementEventHandler(MvtRepository mvtRepository) {
this.mvtRepository = mvtRepository;
}
@EventListener
public void onLineOrderCreated(LineOrderCreatedEvent event) {
Mvt mvt = new Mvt(event.getArticleId(), event.getQuantity());
mvtRepository.save(mvt);
}
@EventListener
public void onLineOrderDeleted(LineOrderDeletedEvent event) {
mvtRepository.deleteByArticleIdAndQuantity(event.getArticleId(), event.getQuantity());
}
}