mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 13:49:36 +01:00
docs: and skills
This commit is contained in:
parent
e4f0665086
commit
ccd4ee534a
25 changed files with 10412 additions and 0 deletions
|
|
@ -0,0 +1,687 @@
|
|||
# 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
|
||||
|
||||
```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<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
|
||||
|
||||
```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<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
|
||||
|
||||
```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<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()`
|
||||
Loading…
Add table
Add a link
Reference in a new issue