DDD en Go appliqué aux API d'exchange crypto

Vous écrivez du CQRS. Vous parlez d'aggregates. Vous émettez des domain events. Mais d'où viennent ces concepts ? Du Domain-Driven Design. Sans comprendre DDD, CQRS est juste un pattern qu'on copie-colle en espérant que ça tienne. Avec DDD, ça devient un outil qu'on utilise consciemment, pour de bonnes raisons.

Cet article pose les fondations conceptuelles. Le terrain concret : un service Go qui consomme les API de plusieurs exchanges crypto (Binance, OKEx, Coinbase). Parce qu'un Order dans le trading, c'est une chose que n'importe quel trader comprend — c'est exactement le type de domaine où DDD fait sens.

Le domaine d'abord, la technique ensuite

Le principe central de DDD est dérangeant quand on vient d'une culture technique : le modèle métier prime sur la technique. On ne commence pas par "quelle base de données", ni par "quelle API Binance expose". On commence par "qu'est-ce qu'un Order dans ce système ? Qu'est-ce qu'une Position ? Que se passe-t-il quand un ordre est partiellement exécuté ?"

Pour un service de trading crypto, le domaine c'est : des ordres qu'on place, des positions qui évoluent, des market data qui arrivent en continu, un portfolio dont on surveille la valeur. Pas "un wrapper Binance". Pas "un consommateur de websocket". Ce sont des détails d'implémentation.

La différence en pratique : si votre modèle est centré sur le domaine, vous pouvez remplacer Binance par OKEx sans toucher à votre logique métier. Si votre modèle est centré sur l'API externe, vous réécrivez tout dès que l'exchange change un endpoint.

DDD ne dit pas "ignorez la technique". Il dit "ne laissez pas la technique contaminer votre modèle métier".

Bounded contexts : découper sans trancher dans le vif

C'est le concept DDD le plus pratique, et le plus souvent mal compris. Un bounded context, c'est une frontière explicite à l'intérieur de laquelle un modèle a une signification précise et cohérente. En dehors de cette frontière, le même mot peut vouloir dire autre chose — et c'est acceptable.

Pour un service de trading crypto, trois bounded contexts naturels émergent :

Market Data

Prix, order books, ticker, trades récents. Ce contexte est read-heavy, near real-time. Binance peut envoyer 10 mises à jour par seconde sur BTC/USDT. Le modèle ici est simple : des snapshots immuables. Un Ticker dans Market Data n'a pas d'état mutable — c'est juste une valeur à un instant T.

Trading

Ordres, exécutions, positions. Ce contexte est command-heavy. Un Order ici a un cycle de vie complet : créé, soumis, partiellement exécuté, annulé. La logique métier est concentrée ici : règles de validation, gestion des rejets par l'exchange, calcul des frais. C'est là que résident vos aggregates.

Portfolio

Balances, P&L, historique des trades. Ce contexte est un read model orienté reporting. Il consomme les events émis par Trading pour maintenir une vue cohérente du portefeuille. Sa mise à jour peut être légèrement décalée — quelques secondes de lag sont acceptables pour du reporting.

Pourquoi les séparer ? Parce qu'ils ont des contraintes radicalement différentes. Market Data à 10 updates/sec n'a pas besoin du même modèle que Portfolio reporting qui agrège des données sur 3 mois. Mettre tout dans le même modèle, c'est faire des compromis qui rendent tout plus complexe sans bénéfice.

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

Aggregates et Value Objects en Go

Deux briques de base en DDD que Go implémente naturellement, sans framework.

Un Value Object est immuable. Son identité est définie par sa valeur, pas par une référence. Price{Amount: 42000, Currency: "USD"} est égal à un autre Price avec les mêmes champs. En Go, ça se traduit par une struct sans pointeur, passée par valeur.


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

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

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
}

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. Et en event sourcing, il émet des events pour décrire ce qui s'est passé.


type OrderStatus int

const (
    StatusPending   OrderStatus = iota
    StatusPartial
    StatusFilled
    StatusCancelled
)

// Aggregate — cohérence garantie, modification via méthodes uniquement
type Order struct {
    id           OrderID
    pair         TradingPair
    side         Side // Buy / Sell
    quantity     decimal.Decimal
    filledQty    decimal.Decimal
    status       OrderStatus
    events       []DomainEvent
}

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
}

func (o *Order) Fill(qty decimal.Decimal, price Price) error {
    if o.status != StatusPending && o.status != StatusPartial {
        return ErrOrderNotFillable
    }
    if qty.GreaterThan(o.quantity.Sub(o.filledQty)) {
        return fmt.Errorf("fill qty %s exceeds remaining %s", qty, o.quantity.Sub(o.filledQty))
    }

    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
}

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

Remarquez ce qui n'est pas là : 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 de quelqu'un d'autre.

Anti-Corruption Layer : isoler les API externes

Binance retourne les prix sous forme de strings : "4.00000000". OKEx utilise un format différent. Coinbase encore un autre. Si vous laissez ces formats externes entrer dans votre domaine, vous avez un problème : votre modèle devient dépendant des caprices de trois exchanges différents.

L'Anti-Corruption Layer (ACL) est la frontière de traduction. À l'extérieur : le modèle de l'exchange. À l'intérieur : votre domaine. L'ACL traduit, et votre domaine n'entend jamais parler de Binance.


// 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,
        })
    }
    // idem pour asks...
    return &domain.OrderBook{Bids: bids}, nil
}

Et pour OKEx, la même interface, une traduction différente :


// 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)
}

Votre code de trading n'a aucune idée de qui fournit l'order book. Il appelle une interface OrderBookProvider et reçoit un domain.OrderBook. Si Binance change son API v3 en v4 avec un nouveau format de réponse, seule la fonction translateBinanceOrderBook change. Zéro impact sur la logique métier.


// 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
}

DDD et CQRS : comment les deux s'articulent

Si vous avez lu les articles sur les aggregates CQRS et les command handlers, vous avez déjà vu DDD en action sans que ce soit explicitement nommé.

DDD vous donne le modèle : aggregates, Value Objects, domain events, bounded contexts. CQRS est le pattern architectural qui exploite ce modèle. La relation est directe :

  • Une Command exprime une intention métier (PlaceOrder, CancelOrder)
  • Le command handler charge l'aggregate, appelle une méthode métier
  • L'aggregate valide l'invariant et émet un DomainEvent
  • L'event est persisté, puis propagé aux read models (Portfolio, historique)

// Command — intention métier
type PlaceOrderCommand struct {
    Pair     TradingPair
    Side     Side
    Quantity decimal.Decimal
}

// Handler — orchestre sans logique métier
func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) error {
    order, err := domain.NewOrder(cmd.Pair, cmd.Side, cmd.Quantity)
    if err != nil {
        return fmt.Errorf("invalid order: %w", err)
    }

    // 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)

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

Sans DDD, le command handler accumule de la logique métier. Avec DDD, il orchestre : il charge, appelle, persiste. La logique appartient à l'aggregate.

Ce que DDD ne résout pas

DDD ajoute de la complexité. Des couches d'abstraction, des interfaces partout, des traductions entre modèles. Sur un service CRUD simple — liste d'utilisateurs, quelques endpoints REST, pas de règles métier complexes — c'est du sur-engineering caractérisé.

Pour un moteur de trading crypto avec des règles métier sur plusieurs exchanges, des invariants à maintenir, des formats externes divergents et une logique qui évolue avec le marché — ça se justifie. Le coût de l'abstraction est compensé par la capacité à changer une pièce sans faire tomber les autres.

Le test le plus honnête : si un expert du domaine — un trader — peut lire votre code et reconnaître ses concepts métier, DDD fonctionne. Si votre code parle de BinanceWebsocketMessageParser et de RestAPIResponseDTO, vous avez laissé la technique envahir le domaine.

Conclusion

DDD n'est pas une architecture, c'est une philosophie de modélisation. Elle dit : le code doit parler le langage des gens qui comprennent le problème, pas le langage des gens qui comprennent la technique. Dans un service de trading crypto, ça se traduit par des Order, des Position, des TradingPair — pas des BinanceResponseWrapper.

Les bounded contexts donnent la structure. Les aggregates donnent la cohérence. Les Anti-Corruption Layers donnent l'isolation. Et quand vous ajoutez CQRS par-dessus, vous avez un système où chaque partie a une responsabilité claire et des frontières explicites.

La prochaine fois que vous écrivez un command handler, posez-vous la question : est-ce que cette logique appartient au handler ou à l'aggregate ? Si la réponse est "à l'aggregate", vous faites du DDD.

Commentaires (0)