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

282 lines
7.3 KiB
Markdown

# 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: <clear, concise description>
```
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