# 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 ```java 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 ```java 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 ```java 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 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 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 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 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 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 ```java 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 ```java // 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 ```java 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