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