mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 17:49:57 +01:00
Implement DDD-based architecture with domain, application, infrastructure, and API layers. Includes user/role management with authentication, RBAC permissions, audit logging, Liquibase migrations, and test suite.
637 lines
16 KiB
Markdown
637 lines
16 KiB
Markdown
# Java Implementation Example Session
|
|
|
|
This is an example session showing how the `/ddd-implement` skill works in a Java project.
|
|
|
|
## Scenario
|
|
|
|
You're building an e-commerce system and need to implement the **Order** aggregate in the domain layer.
|
|
|
|
---
|
|
|
|
## User Request
|
|
|
|
```
|
|
/ddd-implement --lang=java "Implement Order aggregate with these requirements:
|
|
- Order has order number, customer ID, list of line items, and status
|
|
- Can add/remove line items (only when status is DRAFT)
|
|
- Can place order (transitions to PLACED)
|
|
- Can cancel order (transitions to CANCELLED)
|
|
- Cannot modify after PLACED or CANCELLED
|
|
- Total amount is calculated from line items"
|
|
```
|
|
|
|
---
|
|
|
|
## Agent Response
|
|
|
|
I'll implement the **Order aggregate** in the domain layer following DDD patterns.
|
|
|
|
### Analysis
|
|
|
|
**Aggregate Root**: Order
|
|
**Entities**: OrderLine (line items within order)
|
|
**Value Objects**:
|
|
- OrderNumber (unique identifier)
|
|
- CustomerId (reference to Customer aggregate)
|
|
- Money (for prices/totals)
|
|
- OrderStatus (enum: DRAFT, PLACED, CANCELLED)
|
|
|
|
**Invariants**:
|
|
1. Cannot modify order after PLACED or CANCELLED
|
|
2. Cannot place order with zero line items
|
|
3. Line items must have positive quantity
|
|
4. Order total = sum of line item totals
|
|
|
|
**Layer**: Domain (`com.example.domain.order`)
|
|
|
|
Let me implement this:
|
|
|
|
### Error Types
|
|
|
|
```java
|
|
package com.example.domain.order;
|
|
|
|
/**
|
|
* Order domain errors.
|
|
*/
|
|
public sealed interface OrderError permits
|
|
OrderAlreadyPlacedError,
|
|
OrderCancelledError,
|
|
EmptyOrderError,
|
|
LineItemNotFoundError,
|
|
InvalidLineItemError {
|
|
String message();
|
|
}
|
|
|
|
public record OrderAlreadyPlacedError(
|
|
OrderNumber orderNumber
|
|
) implements OrderError {
|
|
@Override
|
|
public String message() {
|
|
return "Order already placed: " + orderNumber;
|
|
}
|
|
}
|
|
|
|
public record OrderCancelledError(
|
|
OrderNumber orderNumber
|
|
) implements OrderError {
|
|
@Override
|
|
public String message() {
|
|
return "Order is cancelled: " + orderNumber;
|
|
}
|
|
}
|
|
|
|
public record EmptyOrderError() implements OrderError {
|
|
@Override
|
|
public String message() {
|
|
return "Cannot place order with no line items";
|
|
}
|
|
}
|
|
|
|
public record LineItemNotFoundError(
|
|
OrderLineId lineItemId
|
|
) implements OrderError {
|
|
@Override
|
|
public String message() {
|
|
return "Line item not found: " + lineItemId;
|
|
}
|
|
}
|
|
|
|
public record InvalidLineItemError(
|
|
String reason
|
|
) implements OrderError {
|
|
@Override
|
|
public String message() {
|
|
return "Invalid line item: " + reason;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Order Aggregate
|
|
|
|
```java
|
|
package com.example.domain.order;
|
|
|
|
import com.example.shared.result.Result;
|
|
import static com.example.shared.result.Result.Failure;
|
|
import static com.example.shared.result.Result.Success;
|
|
|
|
import java.time.Instant;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Objects;
|
|
|
|
/**
|
|
* Order aggregate root.
|
|
*
|
|
* Invariant: Cannot modify order after PLACED or CANCELLED
|
|
* Invariant: Cannot place order with zero line items
|
|
* Invariant: Order total = sum of line item totals
|
|
*/
|
|
public class Order {
|
|
private final OrderNumber orderNumber;
|
|
private final CustomerId customerId;
|
|
private final List<OrderLine> lineItems;
|
|
private OrderStatus status;
|
|
private final Instant createdAt;
|
|
private Instant updatedAt;
|
|
|
|
/**
|
|
* Private constructor - use factory method.
|
|
*/
|
|
private Order(
|
|
OrderNumber orderNumber,
|
|
CustomerId customerId,
|
|
OrderStatus status,
|
|
Instant createdAt
|
|
) {
|
|
this.orderNumber = orderNumber;
|
|
this.customerId = customerId;
|
|
this.lineItems = new ArrayList<>();
|
|
this.status = status;
|
|
this.createdAt = createdAt;
|
|
this.updatedAt = createdAt;
|
|
}
|
|
|
|
/**
|
|
* Creates a new Order in DRAFT status.
|
|
*/
|
|
public static Result<OrderError, Order> create(
|
|
OrderNumber orderNumber,
|
|
CustomerId customerId
|
|
) {
|
|
Order order = new Order(
|
|
orderNumber,
|
|
customerId,
|
|
OrderStatus.DRAFT,
|
|
Instant.now()
|
|
);
|
|
|
|
return Result.success(order);
|
|
}
|
|
|
|
// Getters
|
|
public OrderNumber orderNumber() { return orderNumber; }
|
|
public CustomerId customerId() { return customerId; }
|
|
public OrderStatus status() { return status; }
|
|
public Instant createdAt() { return createdAt; }
|
|
public Instant updatedAt() { return updatedAt; }
|
|
|
|
/**
|
|
* Returns defensive copy of line items.
|
|
*/
|
|
public List<OrderLine> lineItems() {
|
|
return List.copyOf(lineItems);
|
|
}
|
|
|
|
/**
|
|
* Calculates total order amount.
|
|
*
|
|
* Invariant: Total = sum of all line item totals
|
|
*/
|
|
public Money calculateTotal() {
|
|
Money total = Money.usd(0);
|
|
|
|
for (OrderLine line : lineItems) {
|
|
Money lineTotal = line.calculateTotal();
|
|
total = switch (total.add(lineTotal)) {
|
|
case Failure(MoneyError error) ->
|
|
throw new IllegalStateException(
|
|
"Currency mismatch in order: " + error.message()
|
|
);
|
|
case Success(Money sum) -> sum;
|
|
};
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Adds a line item to the order.
|
|
*
|
|
* Invariant: Cannot modify after PLACED or CANCELLED
|
|
*/
|
|
public Result<OrderError, Void> addLineItem(
|
|
ProductId productId,
|
|
String productName,
|
|
Money unitPrice,
|
|
int quantity
|
|
) {
|
|
// Guard: Check order status
|
|
if (status == OrderStatus.PLACED) {
|
|
return Result.failure(new OrderAlreadyPlacedError(orderNumber));
|
|
}
|
|
if (status == OrderStatus.CANCELLED) {
|
|
return Result.failure(new OrderCancelledError(orderNumber));
|
|
}
|
|
|
|
// Create line item
|
|
OrderLineId lineId = OrderLineId.generate();
|
|
|
|
return switch (OrderLine.create(lineId, productId, productName, unitPrice, quantity)) {
|
|
case Failure(OrderLineError error) ->
|
|
Result.failure(new InvalidLineItemError(error.message()));
|
|
case Success(OrderLine line) -> {
|
|
lineItems.add(line);
|
|
this.updatedAt = Instant.now();
|
|
yield Result.success(null);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Removes a line item from the order.
|
|
*
|
|
* Invariant: Cannot modify after PLACED or CANCELLED
|
|
*/
|
|
public Result<OrderError, Void> removeLineItem(OrderLineId lineItemId) {
|
|
// Guard: Check order status
|
|
if (status == OrderStatus.PLACED) {
|
|
return Result.failure(new OrderAlreadyPlacedError(orderNumber));
|
|
}
|
|
if (status == OrderStatus.CANCELLED) {
|
|
return Result.failure(new OrderCancelledError(orderNumber));
|
|
}
|
|
|
|
// Find and remove line item
|
|
boolean removed = lineItems.removeIf(line -> line.id().equals(lineItemId));
|
|
|
|
if (!removed) {
|
|
return Result.failure(new LineItemNotFoundError(lineItemId));
|
|
}
|
|
|
|
this.updatedAt = Instant.now();
|
|
return Result.success(null);
|
|
}
|
|
|
|
/**
|
|
* Updates quantity of an existing line item.
|
|
*
|
|
* Invariant: Cannot modify after PLACED or CANCELLED
|
|
*/
|
|
public Result<OrderError, Void> updateLineItemQuantity(
|
|
OrderLineId lineItemId,
|
|
int newQuantity
|
|
) {
|
|
// Guard: Check order status
|
|
if (status == OrderStatus.PLACED) {
|
|
return Result.failure(new OrderAlreadyPlacedError(orderNumber));
|
|
}
|
|
if (status == OrderStatus.CANCELLED) {
|
|
return Result.failure(new OrderCancelledError(orderNumber));
|
|
}
|
|
|
|
// Find line item
|
|
OrderLine line = findLineItemById(lineItemId);
|
|
if (line == null) {
|
|
return Result.failure(new LineItemNotFoundError(lineItemId));
|
|
}
|
|
|
|
// Update quantity
|
|
return switch (line.updateQuantity(newQuantity)) {
|
|
case Failure(OrderLineError error) ->
|
|
Result.failure(new InvalidLineItemError(error.message()));
|
|
case Success(Void ignored) -> {
|
|
this.updatedAt = Instant.now();
|
|
yield Result.success(null);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Places the order (transitions from DRAFT to PLACED).
|
|
*
|
|
* Invariant: Cannot place empty order
|
|
* Invariant: Can only place order in DRAFT status
|
|
*/
|
|
public Result<OrderError, Void> place() {
|
|
// Guard: Check status
|
|
if (status == OrderStatus.PLACED) {
|
|
return Result.failure(new OrderAlreadyPlacedError(orderNumber));
|
|
}
|
|
if (status == OrderStatus.CANCELLED) {
|
|
return Result.failure(new OrderCancelledError(orderNumber));
|
|
}
|
|
|
|
// Guard: Check has line items
|
|
if (lineItems.isEmpty()) {
|
|
return Result.failure(new EmptyOrderError());
|
|
}
|
|
|
|
// Transition to PLACED
|
|
this.status = OrderStatus.PLACED;
|
|
this.updatedAt = Instant.now();
|
|
|
|
return Result.success(null);
|
|
}
|
|
|
|
/**
|
|
* Cancels the order.
|
|
*
|
|
* Invariant: Can cancel from DRAFT or PLACED, not from CANCELLED
|
|
*/
|
|
public Result<OrderError, Void> cancel() {
|
|
// Guard: Already cancelled
|
|
if (status == OrderStatus.CANCELLED) {
|
|
return Result.failure(new OrderCancelledError(orderNumber));
|
|
}
|
|
|
|
// Transition to CANCELLED
|
|
this.status = OrderStatus.CANCELLED;
|
|
this.updatedAt = Instant.now();
|
|
|
|
return Result.success(null);
|
|
}
|
|
|
|
private OrderLine findLineItemById(OrderLineId id) {
|
|
return lineItems.stream()
|
|
.filter(line -> line.id().equals(id))
|
|
.findFirst()
|
|
.orElse(null);
|
|
}
|
|
|
|
@Override
|
|
public boolean equals(Object o) {
|
|
if (this == o) return true;
|
|
if (!(o instanceof Order order)) return false;
|
|
return Objects.equals(orderNumber, order.orderNumber);
|
|
}
|
|
|
|
@Override
|
|
public int hashCode() {
|
|
return Objects.hash(orderNumber);
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return "Order{" +
|
|
"orderNumber=" + orderNumber +
|
|
", customerId=" + customerId +
|
|
", status=" + status +
|
|
", lineItems=" + lineItems.size() +
|
|
", total=" + calculateTotal() +
|
|
'}';
|
|
}
|
|
}
|
|
```
|
|
|
|
### OrderLine Entity
|
|
|
|
```java
|
|
package com.example.domain.order;
|
|
|
|
import com.example.shared.result.Result;
|
|
import java.util.Objects;
|
|
|
|
/**
|
|
* OrderLine errors.
|
|
*/
|
|
public sealed interface OrderLineError permits
|
|
InvalidQuantityError,
|
|
InvalidPriceError {
|
|
String message();
|
|
}
|
|
|
|
public record InvalidQuantityError(int quantity) implements OrderLineError {
|
|
@Override
|
|
public String message() {
|
|
return "Quantity must be positive: " + quantity;
|
|
}
|
|
}
|
|
|
|
public record InvalidPriceError(Money price) implements OrderLineError {
|
|
@Override
|
|
public String message() {
|
|
return "Price must be positive: " + price;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* OrderLine entity (child entity within Order aggregate).
|
|
*
|
|
* Invariant: Quantity must be positive
|
|
* Invariant: Price must be positive
|
|
*/
|
|
public class OrderLine {
|
|
private final OrderLineId id;
|
|
private final ProductId productId;
|
|
private final String productName;
|
|
private final Money unitPrice;
|
|
private int quantity;
|
|
|
|
/**
|
|
* Package-private constructor - created by Order aggregate.
|
|
*/
|
|
OrderLine(
|
|
OrderLineId id,
|
|
ProductId productId,
|
|
String productName,
|
|
Money unitPrice,
|
|
int quantity
|
|
) {
|
|
this.id = id;
|
|
this.productId = productId;
|
|
this.productName = productName;
|
|
this.unitPrice = unitPrice;
|
|
this.quantity = quantity;
|
|
}
|
|
|
|
/**
|
|
* Creates a new OrderLine.
|
|
*/
|
|
static Result<OrderLineError, OrderLine> create(
|
|
OrderLineId id,
|
|
ProductId productId,
|
|
String productName,
|
|
Money unitPrice,
|
|
int quantity
|
|
) {
|
|
// Invariant: Quantity must be positive
|
|
if (quantity <= 0) {
|
|
return Result.failure(new InvalidQuantityError(quantity));
|
|
}
|
|
|
|
// Invariant: Price must be positive
|
|
if (unitPrice.isNegativeOrZero()) {
|
|
return Result.failure(new InvalidPriceError(unitPrice));
|
|
}
|
|
|
|
return Result.success(new OrderLine(
|
|
id,
|
|
productId,
|
|
productName,
|
|
unitPrice,
|
|
quantity
|
|
));
|
|
}
|
|
|
|
public OrderLineId id() { return id; }
|
|
public ProductId productId() { return productId; }
|
|
public String productName() { return productName; }
|
|
public Money unitPrice() { return unitPrice; }
|
|
public int quantity() { return quantity; }
|
|
|
|
/**
|
|
* Updates the quantity.
|
|
*
|
|
* Invariant: Quantity must be positive
|
|
*/
|
|
Result<OrderLineError, Void> updateQuantity(int newQuantity) {
|
|
if (newQuantity <= 0) {
|
|
return Result.failure(new InvalidQuantityError(newQuantity));
|
|
}
|
|
|
|
this.quantity = newQuantity;
|
|
return Result.success(null);
|
|
}
|
|
|
|
/**
|
|
* Calculates total price for this line.
|
|
*/
|
|
public Money calculateTotal() {
|
|
return unitPrice.multiply(quantity);
|
|
}
|
|
|
|
/**
|
|
* Equality based on ID only (entity identity).
|
|
*/
|
|
@Override
|
|
public boolean equals(Object o) {
|
|
if (this == o) return true;
|
|
if (!(o instanceof OrderLine that)) return false;
|
|
return Objects.equals(id, that.id);
|
|
}
|
|
|
|
@Override
|
|
public int hashCode() {
|
|
return Objects.hash(id);
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return "OrderLine{" +
|
|
"id=" + id +
|
|
", product=" + productName +
|
|
", quantity=" + quantity +
|
|
", unitPrice=" + unitPrice +
|
|
", total=" + calculateTotal() +
|
|
'}';
|
|
}
|
|
}
|
|
```
|
|
|
|
### Supporting Types
|
|
|
|
```java
|
|
package com.example.domain.order;
|
|
|
|
/**
|
|
* Order status enumeration.
|
|
*/
|
|
public enum OrderStatus {
|
|
DRAFT("Order is being created"),
|
|
PLACED("Order has been placed"),
|
|
CANCELLED("Order was cancelled");
|
|
|
|
private final String description;
|
|
|
|
OrderStatus(String description) {
|
|
this.description = description;
|
|
}
|
|
|
|
public String description() {
|
|
return description;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* OrderNumber value object.
|
|
*/
|
|
public record OrderNumber(String value) {
|
|
public OrderNumber {
|
|
if (value == null || value.isBlank()) {
|
|
throw new IllegalArgumentException("OrderNumber cannot be empty");
|
|
}
|
|
}
|
|
|
|
public static OrderNumber of(String value) {
|
|
return new OrderNumber(value);
|
|
}
|
|
|
|
public static OrderNumber generate() {
|
|
return new OrderNumber("ORD-" + java.util.UUID.randomUUID());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* OrderLineId value object.
|
|
*/
|
|
public record OrderLineId(String value) {
|
|
public OrderLineId {
|
|
if (value == null || value.isBlank()) {
|
|
throw new IllegalArgumentException("OrderLineId cannot be empty");
|
|
}
|
|
}
|
|
|
|
public static OrderLineId generate() {
|
|
return new OrderLineId(java.util.UUID.randomUUID().toString());
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
✅ **Implemented**:
|
|
- Order aggregate root with all required operations
|
|
- OrderLine entity (child entity, package-private constructor)
|
|
- Sealed interfaces for error types
|
|
- All invariants documented and enforced
|
|
- Result types for all operations (no exceptions)
|
|
- Pattern matching with switch expressions
|
|
- Defensive copy for line items collection
|
|
|
|
✅ **DDD Rules Followed**:
|
|
- Aggregate is only entry point (line items accessed via Order methods)
|
|
- Entities have ID-based equality
|
|
- All invariants checked in constructor and mutation methods
|
|
- No direct references to other aggregates (ProductId, CustomerId are IDs only)
|
|
- One aggregate = one transaction boundary
|
|
|
|
✅ **Layer**: Domain (no external dependencies)
|
|
|
|
✅ **Files Created**:
|
|
- `com/example/domain/order/Order.java`
|
|
- `com/example/domain/order/OrderLine.java`
|
|
- `com/example/domain/order/OrderError.java`
|
|
- `com/example/domain/order/OrderLineError.java`
|
|
- `com/example/domain/order/OrderStatus.java`
|
|
- `com/example/domain/order/OrderNumber.java`
|
|
- `com/example/domain/order/OrderLineId.java`
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
You can now:
|
|
|
|
1. **Implement the repository interface**:
|
|
```
|
|
/ddd-implement --lang=java "Create OrderRepository interface in domain layer"
|
|
```
|
|
|
|
2. **Implement a use case**:
|
|
```
|
|
/ddd-implement --lang=java "Create PlaceOrder use case in application layer"
|
|
```
|
|
|
|
3. **Implement repository**:
|
|
```
|
|
/ddd-implement --lang=java "Implement JdbcOrderRepository in infrastructure layer"
|
|
```
|
|
|
|
4. **Add tests**:
|
|
```
|
|
Write unit tests for Order aggregate
|
|
```
|