# Guide to Defining Invariants Invariants are business rules that must ALWAYS be true for an aggregate. They define the consistency boundaries of your domain. ## What is an Invariant? An invariant is a condition that must hold true at all times for an aggregate to be in a valid state. **Characteristics**: - Must be true before AND after every operation - Cannot be temporarily violated - Enforced within the aggregate boundary - Violation results in error (operation rejected) ## Identifying Invariants ### Questions to Ask 1. **"What would break the business if violated?"** - Example: Negative account balance for non-credit accounts 2. **"What rules must ALWAYS hold?"** - Example: Order total = sum of line items 3. **"What conditions make an operation invalid?"** - Example: Cannot ship order that's not paid 4. **"What relationships must be maintained?"** - Example: Account must have at least one owner 5. **"What limits exist?"** - Example: Maximum 10 items per order ### Common Invariant Categories | Category | Example | |----------|---------| | **Range constraints** | Balance >= 0, Quantity > 0 | | **Required relationships** | Order must have at least one line item | | **State transitions** | Cannot cancel shipped order | | **Uniqueness** | No duplicate line items for same product | | **Consistency** | Sum of parts equals total | | **Business limits** | Max withdrawal per day | ## Documenting Invariants ### Comment Format ```go // Invariant: ``` Place invariant comments: 1. At the struct definition (all invariants) 2. At methods that enforce specific invariants ### Example ```go // Account represents a bank account aggregate. // Invariants: // - Balance cannot be negative for standard accounts // - Account must have at least one holder with OWNER role // - Frozen account cannot process debit operations // - Credit limit cannot exceed maximum allowed for account type type Account struct { id AccountID balance Money holders []Holder status Status accountType AccountType creditLimit Money } ``` ## Enforcing Invariants ### In Constructor Validate all invariants at creation time: ```go func NewAccount(id AccountID, holder Holder, accountType AccountType) (*Account, error) { // Invariant: Account must have at least one holder with OWNER role if holder.Role() != RoleOwner { return nil, ErrFirstHolderMustBeOwner } // Invariant: Balance starts at zero (implicitly non-negative) return &Account{ id: id, balance: MustMoney(0), holders: []Holder{holder}, status: StatusActive, accountType: accountType, creditLimit: MustMoney(0), }, nil } ``` ### In Mutation Methods Check invariants before AND after state changes: ```go func (a *Account) Withdraw(amount Money) error { // Invariant: Frozen account cannot process debit operations if a.status == StatusFrozen { return ErrAccountFrozen } // Invariant: Balance cannot be negative for standard accounts newBalance, err := a.balance.Subtract(amount) if err != nil { return err } if a.accountType == AccountTypeStandard && newBalance.IsNegative() { return ErrInsufficientFunds } // Invariant: Credit limit cannot be exceeded if newBalance.IsNegative() && newBalance.Abs().GreaterThan(a.creditLimit) { return ErrCreditLimitExceeded } a.balance = newBalance return nil } ``` ### In Remove Operations Ensure invariants aren't violated by removal: ```go func (a *Account) RemoveHolder(holderID HolderID) error { // Invariant: Account must have at least one holder with OWNER role remainingOwners := 0 for _, h := range a.holders { if h.ID() != holderID && h.Role() == RoleOwner { remainingOwners++ } } if remainingOwners == 0 { return ErrCannotRemoveLastOwner } // Remove holder a.holders = removeHolder(a.holders, holderID) return nil } ``` ## Invariant Patterns ### Pattern 1: Validate-Then-Mutate ```go func (a *Aggregate) DoSomething(param Param) error { // 1. Validate preconditions (invariants) if err := a.validatePreconditions(param); err != nil { return err } // 2. Perform mutation a.applyChange(param) // 3. Validate postconditions (should always pass if logic is correct) // Usually implicit, but can be explicit for complex operations return nil } ``` ### Pattern 2: State Machine Transitions ```go func (o *Order) Ship() error { // Invariant: Only paid orders can be shipped if o.status != StatusPaid { return &InvalidStateTransitionError{ From: o.status, To: StatusShipped, Reason: "order must be paid before shipping", } } o.status = StatusShipped o.shippedAt = time.Now() return nil } ``` ### Pattern 3: Collection Invariants ```go func (o *Order) AddLineItem(item LineItem) error { // Invariant: No duplicate products in order for _, existing := range o.lineItems { if existing.ProductID() == item.ProductID() { return ErrDuplicateProduct } } // Invariant: Maximum 10 items per order if len(o.lineItems) >= 10 { return ErrMaxItemsExceeded } o.lineItems = append(o.lineItems, item) // Invariant: Total must equal sum of line items (maintained automatically) o.recalculateTotal() return nil } ``` ## Error Types for Invariant Violations Create specific error types for each invariant: ```go // Domain errors for invariant violations var ( ErrInsufficientFunds = errors.New("insufficient funds") ErrAccountFrozen = errors.New("account is frozen") ErrCannotRemoveLastOwner = errors.New("cannot remove last owner") ErrCreditLimitExceeded = errors.New("credit limit exceeded") ) // Or use typed errors with details type InsufficientFundsError struct { Requested Money Available Money } func (e InsufficientFundsError) Error() string { return fmt.Sprintf("insufficient funds: requested %s, available %s", e.Requested, e.Available) } ``` ## Testing Invariants Test both valid and invalid cases: ```go func TestAccount_Withdraw_InsufficientFunds(t *testing.T) { account := NewAccount(id, holder, AccountTypeStandard) account.Deposit(MustMoney(100)) // Invariant violation: Balance cannot be negative err := account.Withdraw(MustMoney(150)) assert.ErrorIs(t, err, ErrInsufficientFunds) assert.Equal(t, MustMoney(100), account.Balance()) // Unchanged } func TestAccount_RemoveHolder_LastOwner(t *testing.T) { account := NewAccount(id, owner, AccountTypeStandard) // Invariant violation: Cannot remove last owner err := account.RemoveHolder(owner.ID()) assert.ErrorIs(t, err, ErrCannotRemoveLastOwner) assert.Len(t, account.Holders(), 1) // Unchanged } ``` ## Invariants Checklist For each aggregate, verify: - [ ] All invariants are documented - [ ] Constructor enforces all creation-time invariants - [ ] Each mutation method maintains all invariants - [ ] Specific error types exist for each invariant violation - [ ] Tests cover both valid operations and invariant violations - [ ] Invalid state is impossible to reach through any sequence of operations