← Contextes /
ddd-go-exchange.md 400 lignes · 13.9 KB
Personnaliser Télécharger
# CLAUDE.md — Domain-Driven Design en Go

> Contexte spécialisé pour Claude Code. Coller ce fichier à la racine du projet pour structurer un service Go selon les principes DDD : bounded contexts, aggregates, Value Objects, Anti-Corruption Layer et intégration CQRS.

---

## Section 1 : Bounded contexts — découper le domaine

Un bounded context est une frontière explicite à l'intérieur de laquelle un modèle a une signification précise. En dehors, le même mot peut vouloir dire autre chose. Trois bounded contexts naturels pour un service de trading crypto :

| Bounded Context | Charge | Cohérence | Complexité métier |
|-----------------|--------|-----------|-------------------|
| Market Data | Très haute (websockets, 10 updates/sec) | Éventuelle OK | Faible |
| Trading | Modérée (commands) | Forte (transactionnelle) | Haute |
| Portfolio | Faible (read model) | Éventuelle OK | Faible |

**Market Data** : prix, order books, ticker. Read-heavy, near real-time. Les données sont des snapshots immuables — un `Ticker` n'a pas d'état mutable, c'est une valeur à un instant T.

**Trading** : ordres, exécutions, positions. Command-heavy. Un `Order` a un cycle de vie complet (créé, soumis, partiellement exécuté, annulé). La logique métier est concentrée ici. C'est là que résident les aggregates.

**Portfolio** : balances, P&L, historique. Read model orienté reporting. Consomme les events émis par Trading. Quelques secondes de lag acceptables.

Pourquoi séparer : Market Data à 10 updates/sec n'a pas besoin du même modèle que Portfolio qui agrège 3 mois de données. Tout mettre dans le même modèle = compromis qui rendent tout plus complexe.

---

## Section 2 : Value Objects — immuabilité par design

Un Value Object est immuable. Son identité est définie par sa valeur, pas par une référence. En Go : struct sans pointeur, passée par valeur, jamais modifiée après création.

```go
// Value Object — immuable, égalité par valeur
type Price struct {
    Amount   decimal.Decimal
    Currency string
}

func (p Price) IsZero() bool {
    return p.Amount.IsZero()
}

// Toute "modification" retourne un nouveau Value Object
func (p Price) Add(other Price) (Price, error) {
    if p.Currency != other.Currency {
        return Price{}, fmt.Errorf("currency mismatch: %s vs %s", p.Currency, other.Currency)
    }
    return Price{Amount: p.Amount.Add(other.Amount), Currency: p.Currency}, nil
}

type TradingPair struct {
    Base  string // "BTC"
    Quote string // "USDT"
}

func (tp TradingPair) String() string {
    return tp.Base + "/" + tp.Quote
}
```

**Règles :**
- Passer par valeur, pas par pointeur (`Price`, pas `*Price`)
- Méthodes retournent des nouveaux objets, jamais de mutation
- Validation dans le constructeur si nécessaire
- `decimal.Decimal` (shopspring/decimal) pour les montants financiers — jamais `float64`

---

## Section 3 : Aggregates — cohérence garantie

Un Aggregate est une unité de cohérence. Toutes les modifications passent par ses méthodes — jamais par accès direct aux champs. Il garantit que ses invariants métier sont toujours respectés.

```go
type OrderStatus int

const (
    StatusPending   OrderStatus = iota
    StatusPartial
    StatusFilled
    StatusCancelled
)

// Aggregate — champs privés, accès uniquement via méthodes
type Order struct {
    id        OrderID
    pair      TradingPair
    side      Side // Buy / Sell
    quantity  decimal.Decimal
    filledQty decimal.Decimal
    status    OrderStatus
    events    []DomainEvent
}

// Constructeur avec validation des invariants
func NewOrder(pair TradingPair, side Side, qty decimal.Decimal) (*Order, error) {
    if qty.IsZero() || qty.IsNegative() {
        return nil, fmt.Errorf("invalid quantity: %s", qty)
    }
    o := &Order{
        id:       NewOrderID(),
        pair:     pair,
        side:     side,
        quantity: qty,
        status:   StatusPending,
    }
    o.events = append(o.events, OrderCreated{
        OrderID: o.id,
        Pair:    pair,
        Side:    side,
        Qty:     qty,
    })
    return o, nil
}

// Méthode métier — valide les invariants, émet un event
func (o *Order) Fill(qty decimal.Decimal, price Price) error {
    if o.status != StatusPending && o.status != StatusPartial {
        return ErrOrderNotFillable
    }
    remaining := o.quantity.Sub(o.filledQty)
    if qty.GreaterThan(remaining) {
        return fmt.Errorf("fill qty %s exceeds remaining %s", qty, remaining)
    }

    o.filledQty = o.filledQty.Add(qty)
    if o.filledQty.Equal(o.quantity) {
        o.status = StatusFilled
    } else {
        o.status = StatusPartial
    }

    o.events = append(o.events, OrderFilled{
        OrderID: o.id,
        Qty:     qty,
        Price:   price,
    })
    return nil
}

func (o *Order) Cancel() error {
    if o.status == StatusFilled || o.status == StatusCancelled {
        return ErrOrderAlreadyTerminal
    }
    o.status = StatusCancelled
    o.events = append(o.events, OrderCancelled{OrderID: o.id})
    return nil
}

// Events récupérés puis purgés après persistance
func (o *Order) PopEvents() []DomainEvent {
    events := o.events
    o.events = nil
    return events
}
```

**Ce qui n'est pas là et doit rester absent :** pas d'accès base de données, pas d'appel HTTP, pas de dépendance externe. L'aggregate est du Go pur. Il exprime les règles métier et génère des events — c'est tout. La persistance est le problème du repository.

---

## Section 4 : Repositories — abstraction de la persistance

Le repository est l'interface entre l'aggregate et la base de données. Défini par le domaine, implémenté par l'infrastructure.

```go
// Interface définie par le domaine
type OrderRepository interface {
    GetByID(ctx context.Context, id OrderID) (*Order, error)
    Save(ctx context.Context, order *Order) error
}

// Implémentation PostgreSQL — dans le package infrastructure
type PostgresOrderRepository struct {
    db *sqlx.DB
}

func (r *PostgresOrderRepository) Save(ctx context.Context, order *Order) error {
    tx, err := r.db.BeginTxx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback()

    // Persister l'aggregate
    _, err = tx.ExecContext(ctx,
        `INSERT INTO orders (id, pair, side, quantity, filled_qty, status)
         VALUES ($1, $2, $3, $4, $5, $6)
         ON CONFLICT (id) DO UPDATE SET
             filled_qty = EXCLUDED.filled_qty,
             status = EXCLUDED.status`,
        order.ID(), order.Pair().String(), order.Side(),
        order.Quantity(), order.FilledQty(), order.Status(),
    )
    if err != nil {
        return fmt.Errorf("upsert order: %w", err)
    }

    // Persister les domain events
    for _, event := range order.PopEvents() {
        if err := r.saveEvent(ctx, tx, event); err != nil {
            return fmt.Errorf("save event: %w", err)
        }
    }

    return tx.Commit()
}
```

---

## Section 5 : Anti-Corruption Layer — isoler les API externes

Binance retourne les prix en strings (`"4.00000000"`). OKEx utilise un format différent. Coinbase encore un autre. L'ACL traduit les modèles externes vers le domaine — votre logique métier n'entend jamais parler de Binance.

```go
// Modèle externe — ce que Binance envoie réellement
type binanceOrderBook struct {
    LastUpdateID int64      `json:"lastUpdateId"`
    Bids         [][]string `json:"bids"` // [["price", "qty"], ...]
    Asks         [][]string `json:"asks"`
}

// ACL : traduit le modèle Binance vers le domaine
func (c *BinanceClient) GetOrderBook(ctx context.Context, pair TradingPair) (*domain.OrderBook, error) {
    raw, err := c.fetchRaw(ctx, pair.String())
    if err != nil {
        return nil, fmt.Errorf("binance order book %s: %w", pair, err)
    }
    return translateBinanceOrderBook(raw)
}

func translateBinanceOrderBook(raw *binanceOrderBook) (*domain.OrderBook, error) {
    bids := make([]domain.PriceLevel, 0, len(raw.Bids))
    for _, b := range raw.Bids {
        if len(b) != 2 {
            return nil, fmt.Errorf("unexpected bid format: %v", b)
        }
        price, err := decimal.NewFromString(b[0])
        if err != nil {
            return nil, fmt.Errorf("invalid bid price %q: %w", b[0], err)
        }
        qty, err := decimal.NewFromString(b[1])
        if err != nil {
            return nil, fmt.Errorf("invalid bid qty %q: %w", b[1], err)
        }
        bids = append(bids, domain.PriceLevel{
            Price:    domain.Price{Amount: price, Currency: "USDT"},
            Quantity: qty,
        })
    }
    return &domain.OrderBook{Bids: bids}, nil
}
```

OKEx, même interface, traduction différente :

```go
// Modèle externe OKEx — format complètement différent
type okexOrderBook struct {
    Data []struct {
        Asks      [][]string `json:"asks"` // [["price", "qty", "liquidated", "count"], ...]
        Bids      [][]string `json:"bids"`
        Timestamp string     `json:"ts"`
    } `json:"data"`
}

func (c *OKExClient) GetOrderBook(ctx context.Context, pair TradingPair) (*domain.OrderBook, error) {
    raw, err := c.fetchRaw(ctx, pair)
    if err != nil {
        return nil, fmt.Errorf("okex order book %s: %w", pair, err)
    }
    return translateOKExOrderBook(raw)
}
```

Interface définie par le domaine — le service de trading ignore qui fournit l'order book :

```go
// Interface définie par le domaine, pas par les exchanges
type OrderBookProvider interface {
    GetOrderBook(ctx context.Context, pair TradingPair) (*OrderBook, error)
}

// Le service de trading n'a aucune dépendance sur Binance, OKEx ou Coinbase
type TradingService struct {
    orderBook OrderBookProvider
    orderRepo OrderRepository
}
```

Si Binance change son API v3 en v4, seule `translateBinanceOrderBook` change. Zéro impact sur la logique métier.

---

## Section 6 : Application services et intégration CQRS

Le command handler orchestre sans contenir de logique métier. La logique appartient à l'aggregate.

```go
// Command — intention métier, pas de logique
type PlaceOrderCommand struct {
    Pair     TradingPair
    Side     Side
    Quantity decimal.Decimal
}

// Handler — charge, appelle, persiste. C'est tout.
type PlaceOrderHandler struct {
    exchange  ExchangeSubmitter  // ACL vers les exchanges
    orderRepo OrderRepository
}

func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) error {
    // 1. Créer l'aggregate — validation des invariants
    order, err := domain.NewOrder(cmd.Pair, cmd.Side, cmd.Quantity)
    if err != nil {
        return fmt.Errorf("invalid order: %w", err)
    }

    // 2. Soumettre à l'exchange via ACL
    exchangeID, err := h.exchange.SubmitOrder(ctx, order)
    if err != nil {
        return fmt.Errorf("exchange submission: %w", err)
    }
    order.SetExchangeID(exchangeID)

    // 3. Persister l'aggregate et ses events
    return h.orderRepo.Save(ctx, order)
}
```

**Règle de répartition :** si une logique peut être testée sans mock (pas de dépendance externe), elle appartient à l'aggregate. Si elle orchestre des dépendances, elle appartient au handler.

Domain events : les aggregates émettent des events (`OrderCreated`, `OrderFilled`, `OrderCancelled`). Ces events sont persistés avec l'aggregate, puis propagés aux read models (Portfolio) via un event bus ou un outbox pattern.

```go
// Exemple d'event handler dans le bounded context Portfolio
type PortfolioEventHandler struct {
    portfolioRepo PortfolioRepository
}

func (h *PortfolioEventHandler) OnOrderFilled(ctx context.Context, event domain.OrderFilled) error {
    portfolio, err := h.portfolioRepo.GetByUserID(ctx, event.UserID)
    if err != nil {
        return fmt.Errorf("load portfolio: %w", err)
    }

    if err := portfolio.ApplyFill(event.Pair, event.Side, event.Qty, event.Price); err != nil {
        return fmt.Errorf("apply fill: %w", err)
    }

    return h.portfolioRepo.Save(ctx, portfolio)
}
```

---

## Section 7 : Structure de packages Go pour DDD

Organisation recommandée qui reflète les bounded contexts :

```
service/
├── domain/
│   ├── order.go          # Aggregate Order + méthodes métier
│   ├── order_events.go   # DomainEvents (OrderCreated, OrderFilled, ...)
│   ├── price.go          # Value Object Price
│   ├── trading_pair.go   # Value Object TradingPair
│   ├── errors.go         # ErrOrderNotFillable, ErrOrderAlreadyTerminal
│   └── repository.go     # Interfaces OrderRepository, OrderBookProvider
├── application/
│   ├── place_order.go    # Command PlaceOrderCommand + Handler
│   └── cancel_order.go   # Command CancelOrderCommand + Handler
├── infrastructure/
│   ├── postgres/
│   │   └── order_repo.go # PostgresOrderRepository
│   └── exchanges/
│       ├── binance.go    # BinanceClient (ACL Binance → domain)
│       └── okex.go       # OKExClient (ACL OKEx → domain)
└── marketdata/           # Bounded context séparé
    ├── domain/
    │   └── ticker.go
    └── ...
```

**Règle d'import :** le package `domain` ne peut importer que la stdlib et des libs utilitaires (`decimal`, `uuid`). Jamais de base de données, jamais de HTTP. Les packages `application` et `infrastructure` peuvent importer `domain`. `domain` n'importe jamais `infrastructure`.

---

## Section 8 : Quand appliquer DDD

DDD ajoute des couches d'abstraction. Sur un CRUD simple — liste d'utilisateurs, quelques endpoints REST, pas de règles métier — c'est du sur-engineering.

**DDD se justifie quand :**
- Les règles métier sont complexes et évoluent avec le temps
- Plusieurs API externes avec des formats divergents
- Invariants à maintenir transactionnellement
- L'expert du domaine peut reconnaître ses concepts dans le code

**Test le plus honnête :** si un trader peut lire votre code et reconnaître ses concepts métier (`Order`, `Position`, `TradingPair`), DDD fonctionne. Si votre code parle de `BinanceWebsocketMessageParser` et de `RestAPIResponseDTO`, vous avez laissé la technique envahir le domaine.