mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:20:23 +01:00
767 lines
20 KiB
Markdown
767 lines
20 KiB
Markdown
# Java 21+ Style Guide for DDD
|
|
|
|
This guide covers Java conventions and modern language features for Domain-Driven Design implementations.
|
|
|
|
## Records vs Classes for Value Objects
|
|
|
|
### Use Records for Value Objects
|
|
|
|
Records are perfect for immutable value objects with validation:
|
|
|
|
```java
|
|
/**
|
|
* Use record for simple value object.
|
|
* Automatically generates equals, hashCode, toString.
|
|
*/
|
|
public record Money(
|
|
java.math.BigDecimal amount,
|
|
String currency
|
|
) {
|
|
/**
|
|
* Compact constructor performs validation.
|
|
*/
|
|
public Money {
|
|
if (amount == null) {
|
|
throw new IllegalArgumentException("Amount cannot be null");
|
|
}
|
|
if (currency == null || currency.isBlank()) {
|
|
throw new IllegalArgumentException("Currency cannot be empty");
|
|
}
|
|
if (amount.signum() < 0) {
|
|
throw new IllegalArgumentException("Amount cannot be negative");
|
|
}
|
|
// Canonicalize to 2 decimal places
|
|
amount = amount.setScale(2, java.math.RoundingMode.HALF_UP);
|
|
}
|
|
|
|
/**
|
|
* Static factory for common currencies.
|
|
*/
|
|
public static Money usd(long cents) {
|
|
return new Money(
|
|
java.math.BigDecimal.valueOf(cents).scaleByPowerOfTen(-2),
|
|
"USD"
|
|
);
|
|
}
|
|
|
|
public static Money eur(long cents) {
|
|
return new Money(
|
|
java.math.BigDecimal.valueOf(cents).scaleByPowerOfTen(-2),
|
|
"EUR"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* MustXxx variant for tests (panics on error).
|
|
*/
|
|
public static Money mustUsd(String amount) {
|
|
try {
|
|
return usd(Long.parseLong(amount));
|
|
} catch (NumberFormatException e) {
|
|
throw new AssertionError("Invalid money for test: " + amount, e);
|
|
}
|
|
}
|
|
|
|
public boolean isNegativeOrZero() {
|
|
return amount.signum() <= 0;
|
|
}
|
|
|
|
public boolean isPositive() {
|
|
return amount.signum() > 0;
|
|
}
|
|
|
|
/**
|
|
* Domain operation: add money (must be same currency).
|
|
*/
|
|
public Result<MoneyError, Money> add(Money other) {
|
|
if (!currency.equals(other.currency)) {
|
|
return Result.failure(
|
|
new CurrencyMismatchError(currency, other.currency)
|
|
);
|
|
}
|
|
return Result.success(
|
|
new Money(amount.add(other.amount), currency)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Domain operation: multiply by factor.
|
|
*/
|
|
public Money multiply(int factor) {
|
|
if (factor < 0) {
|
|
throw new IllegalArgumentException("Factor cannot be negative");
|
|
}
|
|
return new Money(
|
|
amount.multiply(java.math.BigDecimal.valueOf(factor)),
|
|
currency
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Formatted display.
|
|
*/
|
|
public String formatted() {
|
|
return String.format("%s %s", currency, amount);
|
|
}
|
|
}
|
|
|
|
public sealed interface MoneyError permits CurrencyMismatchError {
|
|
String message();
|
|
}
|
|
|
|
public record CurrencyMismatchError(
|
|
String from,
|
|
String to
|
|
) implements MoneyError {
|
|
@Override
|
|
public String message() {
|
|
return String.format("Currency mismatch: %s vs %s", from, to);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Use Classes for Aggregates/Entities
|
|
|
|
Keep mutable aggregates and entities as classes for encapsulation:
|
|
|
|
```java
|
|
/**
|
|
* Aggregate root - use class for mutability.
|
|
* Package-private constructor forces use of factory.
|
|
*/
|
|
public class Account {
|
|
private final AccountId id;
|
|
private Money balance;
|
|
private AccountStatus status;
|
|
private final java.time.Instant createdAt;
|
|
private java.time.Instant updatedAt;
|
|
|
|
/**
|
|
* Private constructor - use factory method.
|
|
*/
|
|
private Account(
|
|
AccountId id,
|
|
Money initialBalance,
|
|
AccountStatus status,
|
|
java.time.Instant createdAt
|
|
) {
|
|
this.id = id;
|
|
this.balance = initialBalance;
|
|
this.status = status;
|
|
this.createdAt = createdAt;
|
|
this.updatedAt = createdAt;
|
|
}
|
|
|
|
/**
|
|
* Factory method in aggregate.
|
|
*/
|
|
public static Result<AccountError, Account> create(
|
|
AccountId id,
|
|
Money initialBalance
|
|
) {
|
|
if (initialBalance.isNegative()) {
|
|
return Result.failure(
|
|
new InvalidBalanceError(initialBalance)
|
|
);
|
|
}
|
|
|
|
Account account = new Account(
|
|
id,
|
|
initialBalance,
|
|
AccountStatus.ACTIVE,
|
|
java.time.Instant.now()
|
|
);
|
|
|
|
return Result.success(account);
|
|
}
|
|
|
|
// ✅ Getters with accessor method names (not get prefix)
|
|
public AccountId id() { return id; }
|
|
public Money balance() { return balance; }
|
|
public AccountStatus status() { return status; }
|
|
|
|
/**
|
|
* Invariant: Cannot withdraw from closed account
|
|
* Invariant: Cannot withdraw more than balance
|
|
*/
|
|
public Result<AccountError, Void> withdraw(Money amount) {
|
|
// Guard: Check status
|
|
if (status == AccountStatus.CLOSED) {
|
|
return Result.failure(new AccountClosedError(id));
|
|
}
|
|
|
|
// Guard: Check amount
|
|
if (amount.isNegativeOrZero()) {
|
|
return Result.failure(new InvalidAmountError(amount));
|
|
}
|
|
|
|
// Guard: Check balance
|
|
if (balance.amount().compareTo(amount.amount()) < 0) {
|
|
return Result.failure(
|
|
new InsufficientFundsError(amount, balance)
|
|
);
|
|
}
|
|
|
|
// Execute state change
|
|
this.balance = new Money(
|
|
balance.amount().subtract(amount.amount()),
|
|
balance.currency()
|
|
);
|
|
this.updatedAt = java.time.Instant.now();
|
|
|
|
return Result.success(null);
|
|
}
|
|
|
|
// Equality based on ID (entity identity)
|
|
@Override
|
|
public boolean equals(Object o) {
|
|
if (this == o) return true;
|
|
if (!(o instanceof Account account)) return false;
|
|
return Objects.equals(id, account.id);
|
|
}
|
|
|
|
@Override
|
|
public int hashCode() {
|
|
return Objects.hash(id);
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return "Account{" +
|
|
"id=" + id +
|
|
", balance=" + balance +
|
|
", status=" + status +
|
|
'}';
|
|
}
|
|
}
|
|
```
|
|
|
|
## Sealed Interfaces for Type Hierarchies
|
|
|
|
Use sealed interfaces for error types and domain concepts:
|
|
|
|
```java
|
|
/**
|
|
* Sealed interface - only permitted implementations.
|
|
*/
|
|
public sealed interface AccountError permits
|
|
AccountClosedError,
|
|
InsufficientFundsError,
|
|
InvalidAmountError,
|
|
AccountNotFoundError {
|
|
String message();
|
|
}
|
|
|
|
/**
|
|
* Only this class can implement AccountError.
|
|
*/
|
|
public record AccountClosedError(AccountId id) implements AccountError {
|
|
@Override
|
|
public String message() {
|
|
return "Account closed: " + id;
|
|
}
|
|
}
|
|
|
|
// More implementations...
|
|
|
|
/**
|
|
* Sealed with final subclasses.
|
|
*/
|
|
public sealed interface OrderStatus permits
|
|
DraftStatus,
|
|
PlacedStatus,
|
|
ShippedStatus,
|
|
DeliveredStatus,
|
|
CancelledStatus {
|
|
String description();
|
|
}
|
|
|
|
public final record DraftStatus() implements OrderStatus {
|
|
@Override
|
|
public String description() {
|
|
return "Draft - being composed";
|
|
}
|
|
}
|
|
|
|
public final record PlacedStatus(java.time.Instant placedAt) implements OrderStatus {
|
|
@Override
|
|
public String description() {
|
|
return "Placed on " + placedAt;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Pattern Matching
|
|
|
|
### Instance Checking
|
|
|
|
```java
|
|
// ❌ Old style
|
|
public void process(OrderError error) {
|
|
if (error instanceof InvalidAmountError) {
|
|
InvalidAmountError e = (InvalidAmountError) error;
|
|
System.out.println(e.amount());
|
|
}
|
|
}
|
|
|
|
// ✅ New style - pattern matching
|
|
public void process(OrderError error) {
|
|
if (error instanceof InvalidAmountError e) {
|
|
System.out.println(e.amount());
|
|
}
|
|
}
|
|
```
|
|
|
|
### Switch with Pattern Matching
|
|
|
|
```java
|
|
public String formatError(OrderError error) {
|
|
return switch (error) {
|
|
case InvalidAmountError e ->
|
|
"Invalid amount: " + e.amount();
|
|
case LineItemNotFoundError e ->
|
|
"Line item not found: " + e.lineItemId();
|
|
case OrderAlreadyPlacedError e ->
|
|
"Order already placed: " + e.orderNumber();
|
|
case OrderCancelledError e ->
|
|
"Order cancelled: " + e.orderNumber();
|
|
case EmptyOrderError ->
|
|
"Cannot place order with no items";
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pattern matching with Result.
|
|
*/
|
|
public void handleResult(Result<OrderError, Order> result) {
|
|
switch (result) {
|
|
case Success(Order order) -> {
|
|
System.out.println("Order created: " + order.orderNumber());
|
|
}
|
|
case Failure(InvalidAmountError e) -> {
|
|
logger.warn("Invalid amount in order: " + e.amount());
|
|
}
|
|
case Failure(EmptyOrderError) -> {
|
|
logger.warn("User attempted to place empty order");
|
|
}
|
|
case Failure(OrderError e) -> {
|
|
logger.error("Unexpected order error: " + e.message());
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Record Pattern Matching
|
|
|
|
```java
|
|
// Record patterns (Java 21+)
|
|
public void processTransfer(Object obj) {
|
|
if (obj instanceof Transfer(
|
|
Money amount,
|
|
AccountId from,
|
|
AccountId to
|
|
)) {
|
|
System.out.println("Transfer " + amount + " from " + from + " to " + to);
|
|
}
|
|
}
|
|
|
|
// In switch expressions
|
|
public String describeTransfer(Object obj) {
|
|
return switch (obj) {
|
|
case Transfer(Money amount, AccountId from, AccountId to) ->
|
|
String.format("Transfer %s from %s to %s", amount, from, to);
|
|
case Withdrawal(Money amount, AccountId account) ->
|
|
String.format("Withdrawal %s from %s", amount, account);
|
|
default -> "Unknown operation";
|
|
};
|
|
}
|
|
```
|
|
|
|
## Static Imports
|
|
|
|
Use static imports for readability:
|
|
|
|
```java
|
|
// ❌ Verbose without static import
|
|
Result<AccountError, Account> account = Result.success(newAccount);
|
|
|
|
// ✅ With static imports
|
|
import static com.example.shared.result.Result.success;
|
|
import static com.example.shared.result.Result.failure;
|
|
|
|
Result<AccountError, Account> account = success(newAccount);
|
|
Result<AccountError, Void> error = failure(new AccountClosedError(id));
|
|
```
|
|
|
|
### Import Aliases
|
|
|
|
```java
|
|
// For classes with same name in different packages
|
|
import com.example.domain.account.AccountError;
|
|
import com.example.application.account.AccountError as AppAccountError;
|
|
```
|
|
|
|
## Private Constructors + Public Static Factories
|
|
|
|
### Aggregate Pattern
|
|
|
|
```java
|
|
/**
|
|
* Aggregates use private constructor + public factory.
|
|
* Ensures validation always happens.
|
|
*/
|
|
public class Order {
|
|
private final OrderNumber number;
|
|
private final CustomerId customerId;
|
|
private final List<OrderLine> lineItems;
|
|
private OrderStatus status;
|
|
|
|
/**
|
|
* Private - access only via factory.
|
|
*/
|
|
private Order(
|
|
OrderNumber number,
|
|
CustomerId customerId,
|
|
OrderStatus status
|
|
) {
|
|
this.number = number;
|
|
this.customerId = customerId;
|
|
this.lineItems = new ArrayList<>();
|
|
this.status = status;
|
|
}
|
|
|
|
/**
|
|
* Create new order (in DRAFT status).
|
|
*/
|
|
public static Result<OrderError, Order> create(
|
|
OrderNumber number,
|
|
CustomerId customerId
|
|
) {
|
|
// Validation before construction
|
|
if (number == null) {
|
|
return Result.failure(new InvalidOrderNumberError());
|
|
}
|
|
if (customerId == null) {
|
|
return Result.failure(new InvalidCustomerIdError());
|
|
}
|
|
|
|
Order order = new Order(number, customerId, OrderStatus.DRAFT);
|
|
return Result.success(order);
|
|
}
|
|
|
|
/**
|
|
* Reconstruct from database (internal use).
|
|
* Skips validation since data is from trusted source.
|
|
*/
|
|
static Order reconstruct(
|
|
OrderNumber number,
|
|
CustomerId customerId,
|
|
List<OrderLine> lineItems,
|
|
OrderStatus status
|
|
) {
|
|
Order order = new Order(number, customerId, status);
|
|
order.lineItems.addAll(lineItems);
|
|
return order;
|
|
}
|
|
|
|
/**
|
|
* For tests - panics on error.
|
|
*/
|
|
public static Order mustCreate(String number, String customerId) {
|
|
return create(
|
|
new OrderNumber(number),
|
|
new CustomerId(customerId)
|
|
).orElseThrow(e ->
|
|
new AssertionError("Failed to create test order: " + e.message())
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Value Object Pattern
|
|
|
|
```java
|
|
/**
|
|
* Value object - sealed to prevent subclassing.
|
|
*/
|
|
public final record Money(
|
|
java.math.BigDecimal amount,
|
|
String currency
|
|
) {
|
|
/**
|
|
* Compact constructor validates.
|
|
*/
|
|
public Money {
|
|
if (amount == null || currency == null) {
|
|
throw new IllegalArgumentException("Cannot be null");
|
|
}
|
|
if (amount.signum() < 0) {
|
|
throw new IllegalArgumentException("Amount cannot be negative");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Factory for USD (common case).
|
|
*/
|
|
public static Money usd(long cents) {
|
|
return new Money(
|
|
java.math.BigDecimal.valueOf(cents, 2),
|
|
"USD"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Factory for arbitrary currency.
|
|
*/
|
|
public static Result<MoneyError, Money> of(String amount, String currency) {
|
|
try {
|
|
return Result.success(
|
|
new Money(new java.math.BigDecimal(amount), currency)
|
|
);
|
|
} catch (NumberFormatException e) {
|
|
return Result.failure(new InvalidMoneyFormatError(amount));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Must-variant for tests.
|
|
*/
|
|
public static Money mustUsd(long cents) {
|
|
return usd(cents);
|
|
}
|
|
|
|
public static Money mustOf(String amount, String currency) {
|
|
return of(amount, currency)
|
|
.orElseThrow(e ->
|
|
new AssertionError("Test money construction failed: " + e.message())
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Package-Private for Entities
|
|
|
|
Hide child entities from outside aggregates:
|
|
|
|
```java
|
|
package com.example.domain.order;
|
|
|
|
/**
|
|
* Aggregate root - public.
|
|
*/
|
|
public class Order {
|
|
private final List<OrderLine> lineItems;
|
|
|
|
/**
|
|
* Public factory - creates Order and its children.
|
|
*/
|
|
public static Result<OrderError, Order> create(...) {
|
|
return Result.success(new Order(...));
|
|
}
|
|
|
|
/**
|
|
* Package-private - only Order and other aggregate classes access this.
|
|
*/
|
|
public Result<OrderError, Void> addLineItem(...) {
|
|
// Internal validation
|
|
OrderLine line = new OrderLine(...); // Package-private constructor
|
|
lineItems.add(line);
|
|
return Result.success(null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Entity - package-private constructor.
|
|
* Only Order aggregate can create it.
|
|
*/
|
|
public class OrderLine {
|
|
private final OrderLineId id;
|
|
private final ProductId productId;
|
|
private int quantity;
|
|
|
|
/**
|
|
* Package-private - only Order can use.
|
|
*/
|
|
OrderLine(
|
|
OrderLineId id,
|
|
ProductId productId,
|
|
String name,
|
|
Money unitPrice,
|
|
int quantity
|
|
) {
|
|
this.id = id;
|
|
this.productId = productId;
|
|
this.quantity = quantity;
|
|
}
|
|
|
|
/**
|
|
* Package-private factory - used by Order.
|
|
*/
|
|
static OrderLine create(
|
|
OrderLineId id,
|
|
ProductId productId,
|
|
String name,
|
|
Money unitPrice,
|
|
int quantity
|
|
) {
|
|
if (quantity <= 0) {
|
|
throw new IllegalArgumentException("Quantity must be positive");
|
|
}
|
|
return new OrderLine(id, productId, name, unitPrice, quantity);
|
|
}
|
|
|
|
// ✅ Package-private accessor (no public getter for modification)
|
|
int getQuantity() { return quantity; }
|
|
|
|
/**
|
|
* Package-private mutation - only Order calls this.
|
|
*/
|
|
void updateQuantity(int newQuantity) {
|
|
if (newQuantity <= 0) {
|
|
throw new IllegalArgumentException("Quantity must be positive");
|
|
}
|
|
this.quantity = newQuantity;
|
|
}
|
|
}
|
|
|
|
// Outside the package - cannot access OrderLine directly
|
|
Order order = Order.create(...).success();
|
|
// ❌ OrderLine line = new OrderLine(...); // Compile error
|
|
// ❌ order.lineItems().get(0); // No public getter
|
|
|
|
// ✅ Access through Order aggregate only
|
|
order.addLineItem(...);
|
|
order.removeLineItem(...);
|
|
```
|
|
|
|
## Naming Conventions
|
|
|
|
| Element | Convention | Example |
|
|
|---------|-----------|---------|
|
|
| Class | PascalCase | `Account`, `Order`, `Transfer` |
|
|
| Record | PascalCase | `Money`, `OrderNumber`, `CustomerId` |
|
|
| Interface | PascalCase | `Repository`, `AccountError`, `OrderStatus` |
|
|
| Variable | camelCase | `accountId`, `initialBalance`, `orderNumber` |
|
|
| Constant | UPPER_SNAKE_CASE | `MAX_AMOUNT`, `DEFAULT_CURRENCY` |
|
|
| Method | camelCase (no get/set prefix) | `balance()`, `withdraw()`, `transfer()` |
|
|
| Enum | PascalCase values | `ACTIVE`, `DRAFT`, `PLACED` |
|
|
|
|
## Accessor Methods (Property-Style)
|
|
|
|
```java
|
|
// ✅ Preferred in DDD - property-style accessors
|
|
public class Account {
|
|
private Money balance;
|
|
|
|
public Money balance() { // Property name, not getBalance()
|
|
return balance;
|
|
}
|
|
|
|
public AccountId id() { // Not getId()
|
|
return id;
|
|
}
|
|
}
|
|
|
|
// ❌ Avoid getter/setter naming in domain
|
|
public class Account {
|
|
public Money getBalance() { ... } // Too verbose for domain
|
|
public void setBalance(Money m) { ... } // Never use setters
|
|
}
|
|
```
|
|
|
|
## Immutability
|
|
|
|
```java
|
|
// ✅ Immutable record
|
|
public record Money(
|
|
java.math.BigDecimal amount,
|
|
String currency
|
|
) {
|
|
// No setters, fields are final automatically
|
|
}
|
|
|
|
// ✅ Defensive copy for mutable collection
|
|
public class Order {
|
|
private List<OrderLine> lineItems;
|
|
|
|
public List<OrderLine> lineItems() {
|
|
return List.copyOf(lineItems); // Returns unmodifiable copy
|
|
}
|
|
|
|
public void addLineItem(OrderLine line) {
|
|
lineItems.add(line); // Internal modification only
|
|
}
|
|
}
|
|
|
|
// ❌ Avoid direct exposure of mutable collections
|
|
public class Order {
|
|
public List<OrderLine> getLineItems() { // Caller could modify!
|
|
return lineItems;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Documentation
|
|
|
|
```java
|
|
/**
|
|
* Order aggregate root.
|
|
*
|
|
* Represents a customer's order with multiple line items.
|
|
* State transitions: DRAFT -> PLACED -> SHIPPED -> DELIVERED
|
|
* Or: DRAFT/PLACED -> CANCELLED
|
|
*
|
|
* Invariant: Cannot modify order after PLACED
|
|
* Invariant: Cannot place order with zero line items
|
|
* Invariant: Order total = sum of line item totals
|
|
*
|
|
* @see OrderLine
|
|
* @see OrderRepository
|
|
*/
|
|
public class Order {
|
|
|
|
/**
|
|
* Creates new order in DRAFT status.
|
|
*
|
|
* @param number unique order identifier
|
|
* @param customerId customer who placed the order
|
|
* @return new Order wrapped in Result
|
|
* @throws nothing - errors are in Result type
|
|
*/
|
|
public static Result<OrderError, Order> create(
|
|
OrderNumber number,
|
|
CustomerId customerId
|
|
) {
|
|
...
|
|
}
|
|
|
|
/**
|
|
* Places the order (transitions DRAFT -> PLACED).
|
|
*
|
|
* Invariant: Can only place DRAFT orders
|
|
* Invariant: Order must have at least one line item
|
|
*
|
|
* @return success if placed, failure with reason if not
|
|
*/
|
|
public Result<OrderError, Void> place() {
|
|
...
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices Summary
|
|
|
|
1. **Use records** for value objects, IDs, errors
|
|
2. **Use classes** for aggregates and entities
|
|
3. **Private constructors** on aggregates, **public factories** for validation
|
|
4. **Sealed interfaces** for closed type hierarchies (errors, statuses)
|
|
5. **Pattern matching** instead of instanceof + casting
|
|
6. **Static imports** for Result.success/failure and common factories
|
|
7. **Property-style accessors** (method name = property name)
|
|
8. **No setters** in domain objects (return Result instead)
|
|
9. **Defensive copies** for mutable collections
|
|
10. **Package-private** for child entities (enforce aggregate boundaries)
|
|
11. **Final records** by default
|
|
12. **Compact constructors** for validation
|
|
13. **Document invariants** in Javadoc
|
|
14. **Use Result<E, T>** instead of throwing exceptions
|