# 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.