mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:19:35 +01:00
20 KiB
20 KiB
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:
/**
* 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:
/**
* 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:
/**
* 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
// ❌ 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
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
// 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:
// ❌ 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
// 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
/**
* 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
/**
* 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:
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)
// ✅ 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
// ✅ 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
/**
* 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
- Use records for value objects, IDs, errors
- Use classes for aggregates and entities
- Private constructors on aggregates, public factories for validation
- Sealed interfaces for closed type hierarchies (errors, statuses)
- Pattern matching instead of instanceof + casting
- Static imports for Result.success/failure and common factories
- Property-style accessors (method name = property name)
- No setters in domain objects (return Result instead)
- Defensive copies for mutable collections
- Package-private for child entities (enforce aggregate boundaries)
- Final records by default
- Compact constructors for validation
- Document invariants in Javadoc
- Use Result<E, T> instead of throwing exceptions