# 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` - 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 ```java 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 holders; private final Instant createdAt; private Instant updatedAt; private final List 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 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 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 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) (Object) standardAccount; } // Construct credit account from standard Account account = ((Success) (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 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 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 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 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 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 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 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 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 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 events() { List 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 ```java 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) ```java 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` 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 ```java 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 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 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 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()`