1
0
Fork 0
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:
Sebastian Frick 2026-02-18 23:25:12 +01:00
parent e4f0665086
commit ccd4ee534a
25 changed files with 10412 additions and 0 deletions

View file

@ -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