1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 17:49:57 +01:00
effigenix/bin/.claude/skills/ddd-model/languages/java/style-guide.md
2026-02-18 23:25:12 +01:00

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

  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