# 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 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 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 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 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 account = Result.success(newAccount); // ✅ With static imports import static com.example.shared.result.Result.success; import static com.example.shared.result.Result.failure; Result account = success(newAccount); Result 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 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 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 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 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 lineItems; /** * Public factory - creates Order and its children. */ public static Result create(...) { return Result.success(new Order(...)); } /** * Package-private - only Order and other aggregate classes access this. */ public Result 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 lineItems; public List 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 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 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 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** instead of throwing exceptions