mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 14:09:34 +01:00
docs: and skills
This commit is contained in:
parent
e4f0665086
commit
ccd4ee534a
25 changed files with 10412 additions and 0 deletions
|
|
@ -0,0 +1,600 @@
|
|||
# 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<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
|
||||
|
||||
```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
|
||||
Loading…
Add table
Add a link
Reference in a new issue