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.