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

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