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

20 KiB

Aggregate Root Template (Java)

Template for creating aggregate roots in Java 21+ following DDD and Clean Architecture principles.

Pattern

An aggregate root is:

  • An entity with a public static factory method returning Result<Error, Aggregate>
  • A mutable class with a private constructor
  • The only entry point to modify its children
  • Enforces all invariants through mutation methods
  • All mutations return Result types instead of throwing exceptions

Complete Example: Account Aggregate

package com.example.domain.account;

import com.example.shared.result.Result;
import java.time.Instant;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import static com.example.shared.result.Result.success;
import static com.example.shared.result.Result.failure;

/**
 * Account aggregate root.
 *
 * Represents a bank account with balance, status, and holders.
 * Enforces all invariants around account operations.
 *
 * Invariant: Balance cannot be negative for standard accounts
 * Invariant: Balance cannot exceed credit limit for credit accounts
 * Invariant: Account must have at least one holder with OWNER role
 * Invariant: Frozen account cannot process debit operations
 * Invariant: Closed account cannot be modified
 */
public class Account {
    private final AccountId id;
    private Money balance;
    private AccountStatus status;
    private final AccountType accountType;
    private Money creditLimit;
    private final List<AccountHolder> holders;
    private final Instant createdAt;
    private Instant updatedAt;
    private final List<DomainEvent> events;

    /**
     * Private constructor - only accessible via factory methods.
     * Ensures validation always occurs before construction.
     */
    private Account(
        AccountId id,
        Money initialBalance,
        AccountType accountType,
        Money creditLimit,
        AccountStatus status,
        List<AccountHolder> holders,
        Instant createdAt
    ) {
        this.id = id;
        this.balance = initialBalance;
        this.accountType = accountType;
        this.creditLimit = creditLimit;
        this.status = status;
        this.holders = new ArrayList<>(holders);
        this.createdAt = createdAt;
        this.updatedAt = createdAt;
        this.events = new ArrayList<>();
    }

    /**
     * Factory method to create a new standard account.
     *
     * Validates:
     * - Initial balance must be non-negative
     * - Initial holder must have OWNER role
     *
     * @param id unique account identifier
     * @param initialBalance starting balance (must be >= 0)
     * @param owner initial owner holder
     * @return success with created account, or failure with reason
     */
    public static Result<AccountError, Account> create(
        AccountId id,
        Money initialBalance,
        AccountHolder owner
    ) {
        // Guard: Validate initial balance
        if (initialBalance == null) {
            return failure(new InvalidAccountDataError("Initial balance cannot be null"));
        }
        if (initialBalance.isNegative()) {
            return failure(new InvalidAccountDataError("Initial balance cannot be negative"));
        }

        // Guard: Validate owner role
        if (owner == null) {
            return failure(new InvalidAccountDataError("Owner cannot be null"));
        }
        if (!owner.role().equals(AccountRole.OWNER)) {
            return failure(new InvalidAccountDataError("Initial holder must have OWNER role"));
        }

        // Construct aggregate
        Account account = new Account(
            id,
            initialBalance,
            AccountType.STANDARD,
            Money.zero("USD"),  // Standard accounts have no credit
            AccountStatus.ACTIVE,
            List.of(owner),
            Instant.now()
        );

        // Raise domain event
        account.raise(new AccountCreatedEvent(id, initialBalance));

        return success(account);
    }

    /**
     * Factory method to create a new credit account with limit.
     *
     * @param id unique account identifier
     * @param initialBalance starting balance
     * @param owner initial owner holder
     * @param creditLimit maximum credit allowed
     * @return success with created account, or failure with reason
     */
    public static Result<AccountError, Account> createCredit(
        AccountId id,
        Money initialBalance,
        AccountHolder owner,
        Money creditLimit
    ) {
        // Guard: Validate credit limit
        if (creditLimit == null || creditLimit.isNegative()) {
            return failure(
                new InvalidAccountDataError("Credit limit must be non-negative")
            );
        }

        // Reuse standard account creation validation
        var standardAccount = create(id, initialBalance, owner);
        if (standardAccount instanceof Failure f) {
            return (Result<AccountError, Account>) (Object) standardAccount;
        }

        // Construct credit account from standard
        Account account = ((Success<AccountError, Account>) (Object) standardAccount).value();
        account.accountType = AccountType.CREDIT;
        account.creditLimit = creditLimit;

        return success(account);
    }

    /**
     * Reconstruct aggregate from database (internal use only).
     * Skips validation since data is from trusted source.
     */
    static Account reconstruct(
        AccountId id,
        Money balance,
        AccountType accountType,
        Money creditLimit,
        AccountStatus status,
        List<AccountHolder> holders,
        Instant createdAt,
        Instant updatedAt
    ) {
        Account account = new Account(
            id,
            balance,
            accountType,
            creditLimit,
            status,
            holders,
            createdAt
        );
        account.updatedAt = updatedAt;
        return account;
    }

    /**
     * Deposit money into account.
     *
     * Invariant: Cannot deposit to closed account
     * Invariant: Amount must be positive
     */
    public Result<AccountError, Void> deposit(Money amount) {
        // Guard: Check status
        if (status == AccountStatus.CLOSED) {
            return failure(new AccountClosedError(id));
        }

        // Guard: Check amount
        if (amount == null) {
            return failure(new InvalidOperationError("Amount cannot be null"));
        }
        if (amount.isNegativeOrZero()) {
            return failure(new InvalidAmountError(amount, "Amount must be positive"));
        }

        // Execute state change
        try {
            this.balance = balance.add(amount);
        } catch (Exception e) {
            return failure(new InvalidOperationError("Currency mismatch during deposit"));
        }

        this.updatedAt = Instant.now();
        raise(new DepositedEvent(id, amount, balance));

        return success(null);
    }

    /**
     * Withdraw money from account.
     *
     * Invariant: Cannot withdraw from closed account
     * Invariant: Cannot withdraw from frozen account
     * Invariant: Balance must stay >= 0 for standard accounts
     * Invariant: Balance must stay >= -creditLimit for credit accounts
     */
    public Result<AccountError, Void> withdraw(Money amount) {
        // Guard: Check status
        if (status == AccountStatus.CLOSED) {
            return failure(new AccountClosedError(id));
        }
        if (status == AccountStatus.FROZEN) {
            return failure(new AccountFrozenError(id));
        }

        // Guard: Check amount
        if (amount == null) {
            return failure(new InvalidOperationError("Amount cannot be null"));
        }
        if (amount.isNegativeOrZero()) {
            return failure(new InvalidAmountError(amount, "Amount must be positive"));
        }

        // Guard: Check balance constraints
        try {
            Money newBalance = balance.subtract(amount);

            if (accountType == AccountType.STANDARD && newBalance.isNegative()) {
                return failure(
                    new InsufficientFundsError(amount, balance)
                );
            }

            if (accountType == AccountType.CREDIT) {
                if (newBalance.negate().greaterThan(creditLimit)) {
                    return failure(new CreditLimitExceededError(creditLimit, newBalance));
                }
            }

            // Execute state change
            this.balance = newBalance;
            this.updatedAt = Instant.now();
            raise(new WithdrawnEvent(id, amount, balance));

            return success(null);

        } catch (Exception e) {
            return failure(new InvalidOperationError("Currency mismatch during withdrawal"));
        }
    }

    /**
     * Freeze account (blocks debit operations).
     *
     * Invariant: Cannot freeze closed account
     */
    public Result<AccountError, Void> freeze() {
        if (status == AccountStatus.CLOSED) {
            return failure(new AccountClosedError(id));
        }

        this.status = AccountStatus.FROZEN;
        this.updatedAt = Instant.now();
        raise(new AccountFrozenEvent(id));

        return success(null);
    }

    /**
     * Unfreeze account (allows debit operations again).
     *
     * Invariant: Cannot unfreeze closed account
     */
    public Result<AccountError, Void> unfreeze() {
        if (status == AccountStatus.CLOSED) {
            return failure(new AccountClosedError(id));
        }

        this.status = AccountStatus.ACTIVE;
        this.updatedAt = Instant.now();
        raise(new AccountUnfrozenEvent(id));

        return success(null);
    }

    /**
     * Close account (prevents all future modifications).
     */
    public Result<AccountError, Void> close() {
        if (status == AccountStatus.CLOSED) {
            return failure(new AlreadyClosedError(id));
        }

        this.status = AccountStatus.CLOSED;
        this.updatedAt = Instant.now();
        raise(new AccountClosedEvent(id));

        return success(null);
    }

    /**
     * Add a new holder to account.
     *
     * Invariant: Cannot add to closed account
     */
    public Result<AccountError, Void> addHolder(AccountHolder holder) {
        if (status == AccountStatus.CLOSED) {
            return failure(new AccountClosedError(id));
        }

        if (holder == null) {
            return failure(new InvalidAccountDataError("Holder cannot be null"));
        }

        // Check if holder already exists
        if (holders.stream().anyMatch(h -> h.id().equals(holder.id()))) {
            return failure(new DuplicateHolderError(holder.id()));
        }

        holders.add(holder);
        this.updatedAt = Instant.now();
        raise(new HolderAddedEvent(id, holder.id()));

        return success(null);
    }

    /**
     * Remove a holder from account.
     *
     * Invariant: Cannot remove last OWNER
     * Invariant: Cannot modify closed account
     */
    public Result<AccountError, Void> removeHolder(AccountHolderId holderId) {
        if (status == AccountStatus.CLOSED) {
            return failure(new AccountClosedError(id));
        }

        // Find the holder
        AccountHolder holderToRemove = holders.stream()
            .filter(h -> h.id().equals(holderId))
            .findFirst()
            .orElse(null);

        if (holderToRemove == null) {
            return failure(new HolderNotFoundError(holderId));
        }

        // Guard: Check invariant - must have at least one OWNER
        long ownerCount = holders.stream()
            .filter(h -> h.role() == AccountRole.OWNER && !h.id().equals(holderId))
            .count();

        if (ownerCount == 0) {
            return failure(new CannotRemoveLastOwnerError(id));
        }

        // Remove holder
        holders.remove(holderToRemove);
        this.updatedAt = Instant.now();
        raise(new HolderRemovedEvent(id, holderId));

        return success(null);
    }

    // ================== Getters (property-style accessors) ==================

    public AccountId id() {
        return id;
    }

    public Money balance() {
        return balance;
    }

    public AccountStatus status() {
        return status;
    }

    public AccountType accountType() {
        return accountType;
    }

    public Money creditLimit() {
        return creditLimit;
    }

    public List<AccountHolder> holders() {
        return Collections.unmodifiableList(holders);
    }

    public Instant createdAt() {
        return createdAt;
    }

    public Instant updatedAt() {
        return updatedAt;
    }

    /**
     * Get and clear pending domain events.
     * Call after persisting to ensure events are published only once.
     */
    public List<DomainEvent> events() {
        List<DomainEvent> result = new ArrayList<>(events);
        events.clear();
        return result;
    }

    // ================== Private Helper Methods ==================

    private void raise(DomainEvent event) {
        events.add(event);
    }

    // ================== Equality & Hash Code ==================

    /**
     * Equality based on aggregate ID (entity identity).
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Account account)) return false;
        return id.equals(account.id);
    }

    @Override
    public int hashCode() {
        return id.hashCode();
    }

    @Override
    public String toString() {
        return "Account{" +
            "id=" + id +
            ", balance=" + balance +
            ", status=" + status +
            ", holders=" + holders.size() +
            '}';
    }
}

Domain Events

package com.example.domain.account;

import java.time.Instant;

/**
 * Base class for domain events.
 * Raised when aggregate state changes.
 */
public sealed interface DomainEvent permits
    AccountCreatedEvent,
    DepositedEvent,
    WithdrawnEvent,
    AccountFrozenEvent,
    AccountUnfrozenEvent,
    AccountClosedEvent,
    HolderAddedEvent,
    HolderRemovedEvent {

    AccountId aggregateId();
    Instant occurredAt();
}

public record AccountCreatedEvent(
    AccountId id,
    Money initialBalance
) implements DomainEvent {
    @Override
    public AccountId aggregateId() { return id; }

    @Override
    public Instant occurredAt() { return Instant.now(); }
}

public record DepositedEvent(
    AccountId id,
    Money amount,
    Money newBalance
) implements DomainEvent {
    @Override
    public AccountId aggregateId() { return id; }

    @Override
    public Instant occurredAt() { return Instant.now(); }
}

public record WithdrawnEvent(
    AccountId id,
    Money amount,
    Money newBalance
) implements DomainEvent {
    @Override
    public AccountId aggregateId() { return id; }

    @Override
    public Instant occurredAt() { return Instant.now(); }
}

// Additional events...

Domain Errors (Sealed Interface)

package com.example.domain.account;

/**
 * Account domain errors - business rule violations.
 * Use sealed interface to restrict implementations and enable pattern matching.
 */
public sealed interface AccountError permits
    InvalidAccountDataError,
    AccountClosedError,
    AccountFrozenError,
    InvalidAmountError,
    InvalidOperationError,
    InsufficientFundsError,
    CreditLimitExceededError,
    CannotRemoveLastOwnerError,
    HolderNotFoundError,
    DuplicateHolderError,
    AlreadyClosedError {

    String message();
}

public record InvalidAccountDataError(String reason) implements AccountError {
    @Override
    public String message() {
        return "Invalid account data: " + reason;
    }
}

public record AccountClosedError(AccountId id) implements AccountError {
    @Override
    public String message() {
        return "Account is closed: " + id;
    }
}

public record AccountFrozenError(AccountId id) implements AccountError {
    @Override
    public String message() {
        return "Account is frozen: " + id;
    }
}

public record InvalidAmountError(Money amount, String reason) implements AccountError {
    @Override
    public String message() {
        return String.format("Invalid amount %s: %s", amount, reason);
    }
}

public record InsufficientFundsError(Money required, Money available) implements AccountError {
    @Override
    public String message() {
        return String.format(
            "Insufficient funds: required %s, available %s",
            required, available
        );
    }
}

public record CreditLimitExceededError(Money limit, Money newBalance) implements AccountError {
    @Override
    public String message() {
        return String.format(
            "Credit limit exceeded: limit %s, would be %s",
            limit, newBalance
        );
    }
}

public record CannotRemoveLastOwnerError(AccountId id) implements AccountError {
    @Override
    public String message() {
        return "Cannot remove last owner from account: " + id;
    }
}

// Additional error types...

Key Features

  1. Private Constructor: Only accessible via factory methods
  2. Public Static Factory: Returns Result<Error, Aggregate> for validation
  3. Invariant Documentation: All invariants documented in comments
  4. Guard Clauses: Check invariants before state changes
  5. Result Returns: All mutation methods return Result instead of throwing
  6. Domain Events: Raise events when state changes for eventually consistent communication
  7. ID-Based Equality: Aggregates equal if their IDs are equal
  8. Immutable Collections: Return defensive copies of internal collections
  9. Temporal Tracking: Track creation and update times
  10. Sealed Errors: Use sealed interfaces for type-safe error handling

Testing Pattern

public class AccountTest {
    @Test
    void shouldCreateAccountSuccessfully() {
        var result = Account.create(
            new AccountId("acc-123"),
            Money.usd(100_00),  // 100 USD
            new AccountHolder(...)
        );

        assertThat(result).satisfies(r -> {
            assertThat(r).isInstanceOf(Success.class);
            if (r instanceof Success<AccountError, Account> s) {
                assertThat(s.value().balance()).isEqualTo(Money.usd(100_00));
            }
        });
    }

    @Test
    void shouldRejectNegativeInitialBalance() {
        var result = Account.create(
            new AccountId("acc-123"),
            Money.usd(-100_00),  // Negative!
            new AccountHolder(...)
        );

        assertThat(result).satisfies(r -> {
            assertThat(r).isInstanceOf(Failure.class);
            if (r instanceof Failure<AccountError, Account> f) {
                assertThat(f.error())
                    .isInstanceOf(InvalidAccountDataError.class);
            }
        });
    }

    @Test
    void shouldPreventWithdrawalFromClosedAccount() {
        var account = Account.create(...)
            .orElseThrow(e -> new AssertionError("Setup failed"));

        account.close();  // Close first
        var result = account.withdraw(Money.usd(10_00));

        assertThat(result).satisfies(r -> {
            assertThat(r).isInstanceOf(Failure.class);
            if (r instanceof Failure<AccountError, Void> f) {
                assertThat(f.error())
                    .isInstanceOf(AccountClosedError.class);
            }
        });
    }
}

Notes

  • Use Money.zero(), Money.usd(), Money.eur() factory methods for convenience
  • Raise domain events before returning success so they're captured
  • Document all invariants in the class Javadoc
  • Use static reconstruct method for database hydration (bypasses validation)
  • Always use instanceof Success and instanceof Failure for pattern matching
  • Defensive copy collections in getters using Collections.unmodifiableList()