mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:30:16 +01:00
20 KiB
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
Resulttypes 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
- Private Constructor: Only accessible via factory methods
- Public Static Factory: Returns
Result<Error, Aggregate>for validation - Invariant Documentation: All invariants documented in comments
- Guard Clauses: Check invariants before state changes
- Result Returns: All mutation methods return
Resultinstead of throwing - Domain Events: Raise events when state changes for eventually consistent communication
- ID-Based Equality: Aggregates equal if their IDs are equal
- Immutable Collections: Return defensive copies of internal collections
- Temporal Tracking: Track creation and update times
- 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
staticreconstruct method for database hydration (bypasses validation) - Always use
instanceof Successandinstanceof Failurefor pattern matching - Defensive copy collections in getters using
Collections.unmodifiableList()