1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:40:18 +01:00
effigenix/bin/.claude/skills/ddd-model/rules/invariants.md
2026-02-18 23:25:12 +01:00

7.3 KiB

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

// Invariant: <clear, concise description>

Place invariant comments:

  1. At the struct definition (all invariants)
  2. At methods that enforce specific invariants

Example

// 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:

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:

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:

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

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

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

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:

// 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:

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