mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:00:23 +01:00
16 KiB
16 KiB
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
- Package-Private Constructor: Only aggregate can create
- Static Factory Method: Validates before construction, can throw exceptions
- ID-Based Equality: Two entities equal if their IDs are equal
- Package-Private Mutations: Only aggregate can modify
- No Getters for Collections: Return defensive copies or immutable views
- Invariant Enforcement: Validate in factory and mutation methods
- Immutable Fields: All fields effectively final (or truly final)
- Aggregate Access Only: Never instantiate outside aggregate package
- Domain-Specific Types: Use value objects for IDs and important concepts
- Clear Ownership: Always document which aggregate owns this entity
Best Practices
- Mark class
publicbut constructorpackage-private(no explicit modifier) - Use
staticfactory with same visibility (package-private) for validation - Throw
IllegalArgumentExceptionin entity factories (aggregates wrap in Result) - Override
equals()andhashCode()based on entity ID only - Never expose mutable collections from entities
- Document in Javadoc which aggregate owns this entity
- Use
finalon value fields when possible - Keep entities focused on a single responsibility
- Don't create entities with complex validation - that's the aggregate's job