mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 23:23:41 +01:00
7.3 KiB
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
-
"What would break the business if violated?"
- Example: Negative account balance for non-credit accounts
-
"What rules must ALWAYS hold?"
- Example: Order total = sum of line items
-
"What conditions make an operation invalid?"
- Example: Cannot ship order that's not paid
-
"What relationships must be maintained?"
- Example: Account must have at least one owner
-
"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:
- At the struct definition (all invariants)
- 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