mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:20:23 +01:00
282 lines
7.3 KiB
Markdown
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
|