1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 20:59:56 +01:00
effigenix/bin/.claude/skills/ddd-model/languages/java/templates/Entity.java.md
2026-02-18 23:25:12 +01:00

16 KiB
Raw Blame History

Entity Template (Java)

Template for creating child entities within aggregates in Java 21+.

Pattern

An entity is:

  • A mutable object that exists only within an aggregate boundary
  • Package-private constructor (not accessible from other packages)
  • Static package-private factory method for validation
  • Equality based on its ID (entity identity)
  • Only accessible through aggregate root methods
  • Cannot be directly instantiated outside its aggregate

Example 1: AccountHolder Entity

package com.example.domain.account;

import java.util.Objects;

/**
 * AccountHolder entity - a child of Account aggregate.
 *
 * Represents a person with access rights to an account.
 * Only Account aggregate can create and modify holders.
 *
 * Package-private - not accessible outside this package.
 * Equality based on holder ID.
 */
public class AccountHolder {
    private final AccountHolderId id;
    private final String name;
    private final AccountRole role;
    private final String emailAddress;

    /**
     * Package-private constructor - only Account can use.
     * Validation done in static factory.
     */
    AccountHolder(
        AccountHolderId id,
        String name,
        AccountRole role,
        String emailAddress
    ) {
        this.id = id;
        this.name = name;
        this.role = role;
        this.emailAddress = emailAddress;
    }

    /**
     * Static package-private factory - validates data.
     * Used by Account aggregate when creating/adding holders.
     *
     * @param id unique holder identifier
     * @param name holder's full name
     * @param role access role (OWNER, OPERATOR, VIEWER)
     * @param emailAddress contact email
     * @return holder if valid, exception if invalid
     */
    static AccountHolder create(
        AccountHolderId id,
        String name,
        AccountRole role,
        String emailAddress
    ) {
        // Guard: Validate ID
        if (id == null) {
            throw new IllegalArgumentException("Holder ID cannot be null");
        }

        // Guard: Validate name
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Holder name cannot be empty");
        }
        if (name.length() > 100) {
            throw new IllegalArgumentException("Holder name too long (max 100 chars)");
        }

        // Guard: Validate role
        if (role == null) {
            throw new IllegalArgumentException("Role cannot be null");
        }

        // Guard: Validate email
        if (emailAddress == null || emailAddress.isBlank()) {
            throw new IllegalArgumentException("Email cannot be empty");
        }
        if (!isValidEmail(emailAddress)) {
            throw new IllegalArgumentException("Invalid email format: " + emailAddress);
        }

        return new AccountHolder(id, name, role, emailAddress);
    }

    // ================== Getters ==================

    public AccountHolderId id() {
        return id;
    }

    public String name() {
        return name;
    }

    public AccountRole role() {
        return role;
    }

    public String emailAddress() {
        return emailAddress;
    }

    // ================== Package-Private Mutations ==================

    /**
     * Package-private - only Account aggregate can change role.
     * Used when promoting/demoting holders.
     */
    void changeRole(AccountRole newRole) {
        if (newRole == null) {
            throw new IllegalArgumentException("Role cannot be null");
        }
        // In reality, this would be: this.role = newRole;
        // But role should be immutable, so prefer creating new instance
        // or use a separate RoleChange method that returns Result
    }

    // ================== Equality & Hash Code ==================

    /**
     * Equality based on holder ID (entity identity).
     * Two holders are equal if they have the same ID,
     * even if other attributes differ.
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof AccountHolder that)) return false;
        return id.equals(that.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    @Override
    public String toString() {
        return "AccountHolder{" +
            "id=" + id +
            ", name='" + name + '\'' +
            ", role=" + role +
            '}';
    }

    // ================== Private Helper Methods ==================

    private static boolean isValidEmail(String email) {
        // Simple email validation
        return email.matches("^[A-Za-z0-9+_.-]+@(.+)$");
    }
}

Example 2: OrderLine Entity

package com.example.domain.order;

import java.util.Objects;

/**
 * OrderLine entity - a child of Order aggregate.
 *
 * Represents one line item in an order.
 * Multiple OrderLines compose an Order.
 *
 * Package-private - only Order aggregate can create/access.
 * Equality based on line item ID.
 *
 * Invariant: Quantity must be positive
 * Invariant: Unit price must be non-negative
 */
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.
     * Used by OrderLine.create() factory and Order.reconstruct().
     */
    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;
    }

    /**
     * Static package-private factory with validation.
     * Called by Order when adding line items.
     */
    static OrderLine create(
        OrderLineId id,
        ProductId productId,
        String productName,
        Money unitPrice,
        int quantity
    ) {
        // Guard: Validate ID
        if (id == null || productId == null) {
            throw new IllegalArgumentException("IDs cannot be null");
        }

        // Guard: Validate name
        if (productName == null || productName.isBlank()) {
            throw new IllegalArgumentException("Product name cannot be empty");
        }

        // Guard: Validate unit price
        if (unitPrice == null || unitPrice.isNegative()) {
            throw new IllegalArgumentException("Unit price cannot be negative");
        }

        // Invariant: Quantity must be positive
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }

        return new OrderLine(id, productId, productName, unitPrice, quantity);
    }

    // ================== Getters ==================

    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;
    }

    /**
     * Calculate total for this line (unitPrice × quantity).
     */
    public Money total() {
        return unitPrice.multiply(quantity);
    }

    // ================== Package-Private Mutations ==================

    /**
     * Package-private - only Order aggregate can call.
     * Updates quantity after validation.
     *
     * Invariant: New quantity must be positive
     */
    void updateQuantity(int newQuantity) {
        if (newQuantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        this.quantity = newQuantity;
    }

    /**
     * Package-private - only Order can remove entire line.
     */
    void remove() {
        // Mark as removed or actually remove from parent
        // Implementation depends on how Order tracks removals
    }

    // ================== Equality & Hash Code ==================

    /**
     * Equality based on OrderLineId (entity identity).
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof OrderLine that)) return false;
        return id.equals(that.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    @Override
    public String toString() {
        return "OrderLine{" +
            "id=" + id +
            ", productName='" + productName + '\'' +
            ", quantity=" + quantity +
            ", unitPrice=" + unitPrice +
            '}';
    }
}

Using Entities in Aggregates

package com.example.domain.order;

import com.example.shared.result.Result;
import static com.example.shared.result.Result.success;
import static com.example.shared.result.Result.failure;

public class Order {
    private final OrderId id;
    private final CustomerId customerId;
    private final List<OrderLine> lineItems;
    private OrderStatus status;

    // ... constructor, factory, etc ...

    /**
     * Add a line item to the order.
     *
     * Invariant: Cannot modify PLACED orders
     * Invariant: Line cannot be duplicate product (or can it?)
     */
    public Result<OrderError, Void> addLineItem(
        OrderLineId lineId,
        ProductId productId,
        String productName,
        Money unitPrice,
        int quantity
    ) {
        // Guard: Check status
        if (status != OrderStatus.DRAFT) {
            return failure(
                new CannotModifyOrderError(id, "Only DRAFT orders can be modified")
            );
        }

        // Guard: Validate line - throws exceptions, caller wraps if needed
        try {
            OrderLine line = OrderLine.create(
                lineId,
                productId,
                productName,
                unitPrice,
                quantity
            );

            // Check for duplicate product
            if (lineItems.stream()
                .anyMatch(l -> l.productId().equals(productId))) {
                return failure(
                    new DuplicateProductError(productId)
                );
            }

            lineItems.add(line);
            return success(null);

        } catch (IllegalArgumentException e) {
            return failure(
                new InvalidLineItemError(e.getMessage())
            );
        }
    }

    /**
     * Update quantity of a line item.
     */
    public Result<OrderError, Void> updateLineQuantity(
        OrderLineId lineId,
        int newQuantity
    ) {
        if (status != OrderStatus.DRAFT) {
            return failure(
                new CannotModifyOrderError(id, "Only DRAFT orders can be modified")
            );
        }

        OrderLine line = lineItems.stream()
            .filter(l -> l.id().equals(lineId))
            .findFirst()
            .orElse(null);

        if (line == null) {
            return failure(new LineNotFoundError(lineId));
        }

        try {
            line.updateQuantity(newQuantity);
            return success(null);
        } catch (IllegalArgumentException e) {
            return failure(
                new InvalidQuantityError(newQuantity, e.getMessage())
            );
        }
    }

    /**
     * Remove a line item.
     */
    public Result<OrderError, Void> removeLineItem(OrderLineId lineId) {
        if (status != OrderStatus.DRAFT) {
            return failure(
                new CannotModifyOrderError(id, "Only DRAFT orders can be modified")
            );
        }

        boolean removed = lineItems.removeIf(l -> l.id().equals(lineId));
        if (!removed) {
            return failure(new LineNotFoundError(lineId));
        }

        return success(null);
    }

    /**
     * Get immutable view of line items.
     */
    public List<OrderLine> lineItems() {
        return Collections.unmodifiableList(lineItems);
    }

    /**
     * Calculate order total.
     */
    public Money total() {
        return lineItems.stream()
            .map(OrderLine::total)
            .reduce(Money.zero("USD"), Money::add);
    }
}

Value Object IDs for Entities

package com.example.domain.account;

import java.util.Objects;
import java.util.UUID;

/**
 * Value object for AccountHolder identity.
 * Every entity needs a unique ID represented as a value object.
 */
public final record AccountHolderId(String value) {
    public AccountHolderId {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("AccountHolderId cannot be blank");
        }
    }

    public static AccountHolderId random() {
        return new AccountHolderId(UUID.randomUUID().toString());
    }

    @Override
    public String toString() {
        return value;
    }
}

public final record OrderLineId(String value) {
    public OrderLineId {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("OrderLineId cannot be blank");
        }
    }

    public static OrderLineId random() {
        return new OrderLineId(UUID.randomUUID().toString());
    }
}

Package-Private Visibility Example

// In com.example.domain.order package

// ✅ CORRECT: Can access OrderLine from Order in same package
public class Order {
    public void addLineItem(OrderLine line) {  // Package-private constructor
        lineItems.add(line);  // OK
    }
}

// ❌ WRONG: Cannot access OrderLine from outside package
// In com.example.application.order package
public class CreateOrderUseCase {
    public void execute(OrderLineData lineData) {
        // OrderLine line = new OrderLine(...);  // Compile error!
        // Must go through Order aggregate
        order.addLineItem(...);  // ✅ Correct
    }
}

Testing Entities

public class OrderLineTest {
    @Test
    void shouldCreateLineWithValidData() {
        OrderLine line = OrderLine.create(
            OrderLineId.random(),
            new ProductId("prod-123"),
            "Widget",
            Money.usd(10_00),  // $10
            5
        );

        assertThat(line.quantity()).isEqualTo(5);
        assertThat(line.total()).isEqualTo(Money.usd(50_00));  // $50
    }

    @Test
    void shouldRejectZeroQuantity() {
        assertThatThrownBy(() ->
            OrderLine.create(
                OrderLineId.random(),
                new ProductId("prod-123"),
                "Widget",
                Money.usd(10_00),
                0  // Invalid
            )
        ).isInstanceOf(IllegalArgumentException.class)
         .hasMessageContaining("Quantity must be positive");
    }

    @Test
    void shouldEquateByIdOnly() {
        OrderLineId id = OrderLineId.random();
        OrderLine line1 = OrderLine.create(id, productId, "A", price, 5);
        OrderLine line2 = OrderLine.create(id, productId, "B", price, 10);

        // Same ID = equal (other fields don't matter)
        assertThat(line1).isEqualTo(line2);
    }

    @Test
    void shouldUpdateQuantity() {
        OrderLine line = OrderLine.create(...);
        line.updateQuantity(10);

        assertThat(line.quantity()).isEqualTo(10);
    }
}

Key Features

  1. Package-Private Constructor: Only aggregate can create
  2. Static Factory Method: Validates before construction, can throw exceptions
  3. ID-Based Equality: Two entities equal if their IDs are equal
  4. Package-Private Mutations: Only aggregate can modify
  5. No Getters for Collections: Return defensive copies or immutable views
  6. Invariant Enforcement: Validate in factory and mutation methods
  7. Immutable Fields: All fields effectively final (or truly final)
  8. Aggregate Access Only: Never instantiate outside aggregate package
  9. Domain-Specific Types: Use value objects for IDs and important concepts
  10. Clear Ownership: Always document which aggregate owns this entity

Best Practices

  • Mark class public but constructor package-private (no explicit modifier)
  • Use static factory with same visibility (package-private) for validation
  • Throw IllegalArgumentException in entity factories (aggregates wrap in Result)
  • Override equals() and hashCode() based on entity ID only
  • Never expose mutable collections from entities
  • Document in Javadoc which aggregate owns this entity
  • Use final on value fields when possible
  • Keep entities focused on a single responsibility
  • Don't create entities with complex validation - that's the aggregate's job