1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 12:29:36 +01:00
effigenix/bin/.claude/skills/ddd-model/examples/banking-go.md
2026-02-18 23:25:12 +01:00

633 lines
19 KiB
Markdown

# Example: Banking Domain
This example demonstrates DDD modeling for a banking domain, covering all phases of the workflow.
## Phase 1: Domain Discovery
### Domain Description
A digital banking platform that allows customers to open accounts, make transfers, and earn loyalty rewards.
### Subdomain Classification
| Subdomain | Type | DDD Investment |
|-----------|------|----------------|
| **Accounts** | Core | Full DDD - competitive advantage through account features |
| **Transfers** | Core | Full DDD - business-critical payment processing |
| **Loyalty** | Core | Full DDD - customer retention differentiator |
| **Fees** | Supporting | Simplified DDD - necessary but not differentiating |
| **Notifications** | Generic | CRUD - standard email/SMS sending |
---
## Phase 2: Bounded Contexts
### Context Map
```
┌────────────────────────────────────────────────┐
│ BANKING DOMAIN │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ ACCOUNTS │─────>│ TRANSFERS │ │
│ │ │ CS │ │ │
│ │ - Account │ │ - Transfer │ │
│ │ - Balance │ │ - Payment │ │
│ │ - Holder │ │ - Status │ │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │ CF │ P │
│ v v │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ FEES │ │ LOYALTY │ │
│ │ (Supporting) │ │ (Core) │ │
│ │ │ │ │ │
│ │ - Fee │ │ - Program │ │
│ │ - Schedule │ │ - Tier │ │
│ └──────────────┘ └──────────────┘ │
│ │
└────────────────────────────────────────────────┘
Legend:
CS = Customer-Supplier (Accounts supplies, Transfers consumes)
CF = Conformist (Fees conforms to Accounts model)
P = Partnership (Transfers and Loyalty evolve together)
```
### Ubiquitous Language
#### Accounts Context
| Term | Definition |
|------|------------|
| Account | A financial account owned by one or more customers |
| Balance | Current amount of money in the account (in minor units) |
| Holder | A person with access to the account (Owner, Operator, Viewer) |
| Freeze | Temporarily block all debit operations on an account |
| Standard Account | Account that cannot have negative balance |
| Credit Account | Account with approved credit limit |
#### Transfers Context
| Term | Definition |
|------|------------|
| Transfer | Movement of money between two accounts |
| Internal Transfer | Transfer within the same bank |
| Source Account | Account debited in a transfer |
| Destination Account | Account credited in a transfer |
| Pending | Transfer initiated but not yet completed |
| Completed | Transfer successfully processed |
| Failed | Transfer that could not be completed |
#### Loyalty Context
| Term | Definition |
|------|------------|
| Program | Loyalty program a customer is enrolled in |
| Tier | Level within the program (Bronze, Silver, Gold, Platinum) |
| Points | Accumulated loyalty points |
| Earn Rate | Points earned per currency unit spent |
| Redemption | Converting points to rewards |
---
## Phase 3: Tactical Modeling
### Accounts Bounded Context
#### Aggregate: Account
```
Account Aggregate
├── Account (Aggregate Root)
│ ├── AccountID (Value Object)
│ ├── Balance (Value Object: Money)
│ ├── Status (Value Object: Active|Frozen|Closed)
│ ├── AccountType (Value Object: Standard|Credit)
│ ├── CreditLimit (Value Object: Money)
│ └── Holders[] (Entity)
│ ├── HolderID (Value Object)
│ ├── UserID (Value Object - reference to User aggregate)
│ └── Role (Value Object: Owner|Operator|Viewer)
└── Invariants:
- Balance >= 0 for Standard accounts
- Balance >= -CreditLimit for Credit accounts
- At least one Holder with Owner role
- Cannot debit Frozen account
- Cannot modify Closed account
```
#### Value Objects
```go
// AccountID - unique identifier
type AccountID struct { value uuid.UUID }
// Money - monetary amount with currency
type Money struct {
amount int64 // cents
currency string // ISO 4217
}
// Status - account status
type Status struct { value string }
var (
StatusActive = Status{"active"}
StatusFrozen = Status{"frozen"}
StatusClosed = Status{"closed"}
)
// AccountType
type AccountType struct { value string }
var (
AccountTypeStandard = AccountType{"standard"}
AccountTypeCredit = AccountType{"credit"}
)
// Role - holder role
type Role struct { value string }
var (
RoleOwner = Role{"owner"}
RoleOperator = Role{"operator"}
RoleViewer = Role{"viewer"}
)
```
### Transfers Bounded Context
#### Aggregate: Transfer
```
Transfer Aggregate
├── Transfer (Aggregate Root)
│ ├── TransferID (Value Object)
│ ├── SourceAccountID (Value Object - reference only!)
│ ├── DestinationAccountID (Value Object - reference only!)
│ ├── Amount (Value Object: Money)
│ ├── Status (Value Object: Pending|Completed|Failed)
│ ├── FailureReason (Value Object, optional)
│ ├── InitiatedAt (timestamp)
│ └── CompletedAt (timestamp, optional)
└── Invariants:
- Amount must be positive
- Source and Destination must be different
- Status transitions: Pending → Completed|Failed only
- Cannot modify after Completed or Failed
```
### Loyalty Bounded Context
#### Aggregate: LoyaltyProgram
```
LoyaltyProgram Aggregate
├── LoyaltyProgram (Aggregate Root)
│ ├── ProgramID (Value Object)
│ ├── CustomerID (Value Object - reference only!)
│ ├── Tier (Value Object: Bronze|Silver|Gold|Platinum)
│ ├── Points (Value Object)
│ ├── EarnRate (Value Object)
│ └── PointsHistory[] (Value Object)
│ ├── Date
│ ├── Amount
│ ├── Type (Earned|Redeemed|Expired)
│ └── Description
└── Invariants:
- Points >= 0
- Tier determined by points thresholds
- Cannot redeem more points than available
```
---
## Phase 4: Invariants
### Account Aggregate Invariants
```go
// Account aggregate invariants:
//
// Invariant: Balance cannot be negative for standard accounts
// Enforced in: Withdraw(), constructor
//
// Invariant: Balance cannot exceed credit limit for credit accounts
// Enforced in: Withdraw(), constructor
//
// Invariant: Account must have at least one holder with OWNER role
// Enforced in: RemoveHolder(), constructor
//
// Invariant: Frozen account cannot process debit operations
// Enforced in: Withdraw()
//
// Invariant: Closed account cannot be modified
// Enforced in: all mutation methods
```
### Transfer Aggregate Invariants
```go
// Transfer aggregate invariants:
//
// Invariant: Transfer amount must be positive
// Enforced in: constructor
//
// Invariant: Source and destination accounts must be different
// Enforced in: constructor
//
// Invariant: Status can only transition Pending → Completed or Pending → Failed
// Enforced in: Complete(), Fail()
//
// Invariant: Completed or Failed transfer cannot be modified
// Enforced in: all mutation methods
```
### LoyaltyProgram Aggregate Invariants
```go
// LoyaltyProgram aggregate invariants:
//
// Invariant: Points balance cannot be negative
// Enforced in: RedeemPoints()
//
// Invariant: Tier is determined by points thresholds
// Enforced in: EarnPoints() (automatic upgrade), calculateTier()
//
// Invariant: Cannot redeem more points than available
// Enforced in: RedeemPoints()
```
---
## Phase 5: Generated Code
### Account Aggregate
```go
package accounts
import (
"errors"
"time"
)
var (
ErrInsufficientFunds = errors.New("insufficient funds")
ErrAccountFrozen = errors.New("account is frozen")
ErrAccountClosed = errors.New("account is closed")
ErrCannotRemoveLastOwner = errors.New("cannot remove last owner")
ErrNegativeAmount = errors.New("amount must be positive")
ErrCreditLimitExceeded = errors.New("credit limit exceeded")
)
// AccountAggregate represents a bank account.
//
// Invariants:
// - Balance >= 0 for standard accounts
// - Balance >= -CreditLimit for credit accounts
// - At least one holder with OWNER role
// - Frozen account cannot process debit operations
// - Closed account cannot be modified
type AccountAggregate struct {
id AccountID
balance Money
holders []Holder
status Status
accountType AccountType
creditLimit Money
createdAt time.Time
updatedAt time.Time
events []DomainEvent
}
// NewAccount creates a new standard account.
func NewAccount(id AccountID, initialHolder Holder) (*AccountAggregate, error) {
if initialHolder.Role() != RoleOwner {
return nil, ErrCannotRemoveLastOwner
}
return &AccountAggregate{
id: id,
balance: MustMoney(0, "USD"),
holders: []Holder{initialHolder},
status: StatusActive,
accountType: AccountTypeStandard,
creditLimit: MustMoney(0, "USD"),
createdAt: time.Now(),
updatedAt: time.Now(),
events: make([]DomainEvent, 0),
}, nil
}
// NewCreditAccount creates a new credit account with limit.
func NewCreditAccount(id AccountID, initialHolder Holder, creditLimit Money) (*AccountAggregate, error) {
account, err := NewAccount(id, initialHolder)
if err != nil {
return nil, err
}
account.accountType = AccountTypeCredit
account.creditLimit = creditLimit
return account, nil
}
func (a *AccountAggregate) ID() AccountID { return a.id }
func (a *AccountAggregate) Balance() Money { return a.balance }
func (a *AccountAggregate) Status() Status { return a.status }
func (a *AccountAggregate) AccountType() AccountType { return a.accountType }
func (a *AccountAggregate) CreditLimit() Money { return a.creditLimit }
func (a *AccountAggregate) CreatedAt() time.Time { return a.createdAt }
func (a *AccountAggregate) UpdatedAt() time.Time { return a.updatedAt }
// Deposit adds money to the account.
func (a *AccountAggregate) Deposit(amount Money) error {
if a.status == StatusClosed {
return ErrAccountClosed
}
if amount.IsNegativeOrZero() {
return ErrNegativeAmount
}
newBalance, err := a.balance.Add(amount)
if err != nil {
return err
}
a.balance = newBalance
a.updatedAt = time.Now()
a.raise(DepositedEvent{AccountID: a.id, Amount: amount, NewBalance: a.balance})
return nil
}
// Withdraw removes money from the account.
func (a *AccountAggregate) Withdraw(amount Money) error {
if a.status == StatusClosed {
return ErrAccountClosed
}
if a.status == StatusFrozen {
return ErrAccountFrozen
}
if amount.IsNegativeOrZero() {
return ErrNegativeAmount
}
newBalance, err := a.balance.Subtract(amount)
if err != nil {
return err
}
// Check balance constraints based on account type
if a.accountType == AccountTypeStandard && newBalance.IsNegative() {
return ErrInsufficientFunds
}
if a.accountType == AccountTypeCredit && newBalance.IsNegative() {
if newBalance.Abs().GreaterThan(a.creditLimit) {
return ErrCreditLimitExceeded
}
}
a.balance = newBalance
a.updatedAt = time.Now()
a.raise(WithdrawnEvent{AccountID: a.id, Amount: amount, NewBalance: a.balance})
return nil
}
// Freeze blocks debit operations on the account.
func (a *AccountAggregate) Freeze() error {
if a.status == StatusClosed {
return ErrAccountClosed
}
a.status = StatusFrozen
a.updatedAt = time.Now()
a.raise(AccountFrozenEvent{AccountID: a.id})
return nil
}
// Unfreeze allows debit operations again.
func (a *AccountAggregate) Unfreeze() error {
if a.status == StatusClosed {
return ErrAccountClosed
}
a.status = StatusActive
a.updatedAt = time.Now()
a.raise(AccountUnfrozenEvent{AccountID: a.id})
return nil
}
// AddHolder adds a new holder to the account.
func (a *AccountAggregate) AddHolder(holder Holder) error {
if a.status == StatusClosed {
return ErrAccountClosed
}
a.holders = append(a.holders, holder)
a.updatedAt = time.Now()
return nil
}
// RemoveHolder removes a holder from the account.
func (a *AccountAggregate) RemoveHolder(holderID HolderID) error {
if a.status == StatusClosed {
return ErrAccountClosed
}
// Check invariant: must keep at least one owner
remainingOwners := 0
for _, h := range a.holders {
if h.ID() != holderID && h.Role() == RoleOwner {
remainingOwners++
}
}
if remainingOwners == 0 {
return ErrCannotRemoveLastOwner
}
// Remove holder
newHolders := make([]Holder, 0, len(a.holders)-1)
for _, h := range a.holders {
if h.ID() != holderID {
newHolders = append(newHolders, h)
}
}
a.holders = newHolders
a.updatedAt = time.Now()
return nil
}
// Holders returns a copy of the holders list.
func (a *AccountAggregate) Holders() []Holder {
result := make([]Holder, len(a.holders))
copy(result, a.holders)
return result
}
// Events returns and clears pending domain events.
func (a *AccountAggregate) Events() []DomainEvent {
events := a.events
a.events = make([]DomainEvent, 0)
return events
}
func (a *AccountAggregate) raise(event DomainEvent) {
a.events = append(a.events, event)
}
```
### Money Value Object
```go
package accounts
import (
"errors"
"fmt"
)
var (
ErrMoneyCurrencyMismatch = errors.New("cannot operate on different currencies")
ErrMoneyCurrencyRequired = errors.New("currency is required")
ErrMoneyCurrencyInvalid = errors.New("currency must be 3-letter ISO code")
)
// Money represents a monetary amount with currency.
// Immutable value object.
type Money struct {
amount int64 // cents/minor units
currency string // ISO 4217
}
// NewMoney creates a new Money value object.
func NewMoney(amount int64, currency string) (Money, error) {
if currency == "" {
return Money{}, ErrMoneyCurrencyRequired
}
if len(currency) != 3 {
return Money{}, ErrMoneyCurrencyInvalid
}
return Money{amount: amount, currency: currency}, nil
}
// MustMoney creates Money or panics. Use in tests only.
func MustMoney(amount int64, currency string) Money {
m, err := NewMoney(amount, currency)
if err != nil {
panic(err)
}
return m
}
// USD creates Money in US Dollars.
func USD(cents int64) Money {
return MustMoney(cents, "USD")
}
func (m Money) Amount() int64 { return m.amount }
func (m Money) Currency() string { return m.currency }
// Add returns a new Money with the sum.
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, ErrMoneyCurrencyMismatch
}
return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}
// Subtract returns a new Money with the difference.
func (m Money) Subtract(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, ErrMoneyCurrencyMismatch
}
return Money{amount: m.amount - other.amount, currency: m.currency}, nil
}
// Multiply returns Money multiplied by factor.
func (m Money) Multiply(factor int) Money {
return Money{amount: m.amount * int64(factor), currency: m.currency}
}
func (m Money) IsNegative() bool { return m.amount < 0 }
func (m Money) IsNegativeOrZero() bool { return m.amount <= 0 }
func (m Money) IsPositive() bool { return m.amount > 0 }
func (m Money) IsZero() bool { return m.amount == 0 }
func (m Money) Abs() Money {
if m.amount < 0 {
return Money{amount: -m.amount, currency: m.currency}
}
return m
}
func (m Money) GreaterThan(other Money) bool { return m.amount > other.amount }
func (m Money) LessThan(other Money) bool { return m.amount < other.amount }
func (m Money) Equals(other Money) bool {
return m.amount == other.amount && m.currency == other.currency
}
func (m Money) String() string {
sign := ""
amount := m.amount
if amount < 0 {
sign = "-"
amount = -amount
}
dollars := amount / 100
cents := amount % 100
return fmt.Sprintf("%s%d.%02d %s", sign, dollars, cents, m.currency)
}
```
### Repository Interface
```go
package accounts
import (
"context"
"errors"
)
var ErrAccountNotFound = errors.New("account not found")
// AccountRepository defines persistence operations for Account aggregates.
type AccountRepository interface {
Save(ctx context.Context, account *AccountAggregate) error
FindByID(ctx context.Context, id AccountID) (*AccountAggregate, error)
FindByHolderUserID(ctx context.Context, userID UserID) ([]*AccountAggregate, error)
Exists(ctx context.Context, id AccountID) (bool, error)
Delete(ctx context.Context, id AccountID) error
}
```
---
## Phase 6: Validation Checklist
### Account Aggregate ✅
- [x] Aggregate Root is the only entry point
- [x] Child entities (Holder) accessed via aggregate methods
- [x] All changes through aggregate methods (Deposit, Withdraw, etc.)
- [x] Invariants documented in comments
- [x] Invariants checked in constructor
- [x] Invariants checked in all mutation methods
- [x] No direct references to other Aggregates (UserID is ID only)
- [x] One Aggregate = one transaction boundary
### Value Objects ✅
- [x] Money is immutable (no setters)
- [x] Money validates in constructor
- [x] Money operations return new instances
- [x] Equals compares all fields
- [x] MustXxx variants for tests
### Repository ✅
- [x] Interface in domain layer
- [x] Methods operate on aggregates
- [x] Context parameter for all methods
- [x] Domain-specific errors (ErrAccountNotFound)
### Clean Architecture ✅
- [x] Domain layer has no external dependencies
- [x] Repository interface in domain, implementation in infrastructure
- [x] Domain events for cross-aggregate communication