mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 22:39:58 +01:00
19 KiB
19 KiB
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
// 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
// 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
// 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
// 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
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
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
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 ✅
- Aggregate Root is the only entry point
- Child entities (Holder) accessed via aggregate methods
- All changes through aggregate methods (Deposit, Withdraw, etc.)
- Invariants documented in comments
- Invariants checked in constructor
- Invariants checked in all mutation methods
- No direct references to other Aggregates (UserID is ID only)
- One Aggregate = one transaction boundary
Value Objects ✅
- Money is immutable (no setters)
- Money validates in constructor
- Money operations return new instances
- Equals compares all fields
- MustXxx variants for tests
Repository ✅
- Interface in domain layer
- Methods operate on aggregates
- Context parameter for all methods
- Domain-specific errors (ErrAccountNotFound)
Clean Architecture ✅
- Domain layer has no external dependencies
- Repository interface in domain, implementation in infrastructure
- Domain events for cross-aggregate communication