1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 11:59:35 +01:00
effigenix/.claude/skills/ddd-implement/examples/java-example.md
Sebastian Frick ec9114aa0a feat: add Spring Boot ERP application with user management domain
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.
2026-02-17 19:33:24 +01:00

16 KiB

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

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

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

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

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