mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 15:29:34 +01:00
docs: and skills
This commit is contained in:
parent
e4f0665086
commit
ccd4ee534a
25 changed files with 10412 additions and 0 deletions
282
bin/.claude/skills/ddd-model/rules/invariants.md
Normal file
282
bin/.claude/skills/ddd-model/rules/invariants.md
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
# 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue