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