Appliquer le Domain‑Driven Design (DDD) en Rust pour une logique métier complexe

Par Emmanuel Forgues - 17 septembre 2025

Chapô – La compétitivité reposant désormais sur des processus métiers sophistiqués, les équipes techniques recherchent des langages alliant sécurité, performance et expressivité. Le Domain‑Driven Design (DDD), méthode éprouvée pour structurer la logique métier, rencontre le langage Rust, reconnu pour son modèle de possession et ses garanties d’absence de data races. Cet article analyse l'articulation entre DDD et Rust pour concevoir des systèmes maintenables et évolutifs, en détaillant les principes clés, les choix architecturaux, un exemple concret d’implémentation et les limites à considérer avant d'engager une organisation dans cette voie.

Publié initialement le 17 septembre 2025.

Mis à jour le 26 avril 2026.

Migré vers StratoSentry le 18 mai 2026.

Introduction – Un besoin pressant de solidité métier

Imaginez une fintech gérant des flux de paiement en temps réel sous contrainte de conformité réglementaire (ex.<0xE2><0x80><0xAF>: lutte contre le blanchiment). La logique métier — calculs de frais, validation de seuils, agrégation de comptes — est à la fois complexe et critique. Une erreur peut entraîner des pertes financières ou des sanctions légales.

Les approches basées sur des frameworks dynamiques (Ruby on Rails, Node.js) permettent un prototypage rapide mais peinent à assurer la fiabilité nécessaire à grande échelle. À l'inverse, les langages bas‑niveau comme C++ offrent le contrôle requis mais imposent une charge cognitive élevée et un risque d’erreurs de mémoire.

Rust, via son système d’emprunt (ownership) et son typage fort, assure la sécurité mémoire sans garbage collector et des performances proches du natif. Couplé à la méthodologie DDD — qui place le domaine métier au cœur de l’architecture — il permet de formaliser les règles métiers dans le code tout en bénéficiant des garanties de compilation.

Cet article s’adresse aux directeurs techniques, aux architectes logiciels et aux développeurs senior souhaitant évaluer la pertinence de cette combinaison pour des projets à forte valeur métier.

1️⃣ Contexte et enjeux du développement orienté domaine

EnjeuImpact sur le projet
Complexité fonctionnelleMultiplicité de règles, contraintes légales, évolutions fréquentes.
Exigences de fiabilitéNécessité d’éviter les bugs qui compromettent la conformité ou la sécurité financière.
ScalabilitéLe système doit supporter un trafic croissant sans perte de latence.
Coût de maintenanceRéduire la dette technique et faciliter l’ajout de nouvelles fonctionnalités.

Le DDD répond à ces exigences via :

  • Ubiquitous Language – vocabulaire partagé entre experts métier et développeurs.
  • Bounded Contexts – découpage du domaine en sous‑domaines autonomes, limitant le couplage.
  • Aggregates – regroupement d’entités autour d’une racine garantissant la cohérence transactionnelle.

L'implémentation de ces concepts dans un langage empêchant les erreurs classiques (null pointer, data race) fournit une base solide pour la logique métier.

2️⃣ Principes fondamentaux du Domain‑Driven Design

  • Modélisation du domaine – Créer des Entités (identité persistante) et des Value Objects (immuables, définis par leurs attributs).
  • Aggregates & Racine d’Agrégat – Définir une frontière de cohérence ; toutes les modifications passent par la racine.
  • Repositories – Abstractions permettant de persister/retrouver des agrégats sans exposer l’infrastructure.
  • Domain Services – Opérations qui ne trouvent pas naturellement leur place dans une entité ou un value object.
  • Application Layer – Orchestration des use‑cases, coordination entre le domaine et les services d’infrastructure.
  • Infrastructure – Implémentations concrètes (bases de données, bus de messages) injectées via l’inversion de contrôle.

Ces couches sont souvent représentées par une architecture hexagonale ou Ports & Adapters, qui s’accorde naturellement avec le système de modules de Rust.

3️⃣ Pourquoi choisir Rust pour la logique métier complexe

AttributRustLangages concurrents
Sécurité mémoireOwnership, Borrow Checker → aucune fuite, aucun use‑after‑free.GC (Java, C#) ou vérifications à l’exécution (Python).
Absence de data racesConcurrence sûre grâce au typage (Send/Sync).Verrouillages manuels (C++), risques élevés.
PerformancesCompilation en code natif, optimisation LLVM.Interprétés ou JIT, overhead supplémentaire.
Typage fort & génériquesTypes immuables par défaut, traits pour abstractions.Typage dynamique ou moins expressif (Go).
Écosystème async moderneasync/await, runtime Tokio, actix‑web.Support async variable selon les langages.

Ces caractéristiques permettent de codifier les invariants métier directement dans le type system, réduisant ainsi la surface d’erreur à l’exécution. Par exemple, un Money value object peut être défini avec une précision fixe et une devise validée au moment de la compilation grâce aux traits.

4️⃣ Architecture hexagonale adaptée à DDD en Rust

+---------------------------+
|   Interface Utilisateur   |   ← Ports (Web, CLI)
+------------^--------------+
             |
      +------v------+          Application Service
      |   Use‑case  | ←─────>  (application layer)
      +------+------+          |
             |                 |
    +--------v---------+       |
    | Domaine (core)   |←──────┘
    | – Entités        |
    | – Value Objects  |
    | – Domain Services|
    +--------^---------+
             |
    +--------v----------+      Infrastructure
    | Repositories Impl.| ← DB, Message Bus, etc.
    +-------------------+
  • Ports (ex. : API REST avec actix‑web) exposent les use‑cases.
  • Adapters implémentent les repositories (ex. : diesel pour PostgreSQL) et sont injectés via des traits (dyn OrderRepository).
  • Le cœur du domaine ne dépend d’aucune bibliothèque externe, ce qui facilite les tests unitaires et la réutilisation.

Rust offre deux avantages majeurs à cette architecture<0xE2><0x80><0xAF>:

  • Le système de modules (pub(crate), pub) permet de restreindre l’accès aux entités uniquement via leurs agrégats, renforçant l’encapsulation décrite par DDD.
  • Les traits génériques servent de contrats d’interface (ports), tout en étant résolus à la compilation, évitant le coût du polymorphisme dynamique lorsqu’il n’est pas nécessaire.

5️⃣ Modélisation du domaine : entités, value objects et agrégats

5.1 Exemple de Value Object – Money

use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Money {
    amount: Decimal,
    currency: Currency,
}

impl Money {
    pub fn new(amount: Decimal, currency: Currency) -> Self {
        // Validation simple : pas de montant négatif
        assert!(amount >= Decimal::ZERO, "Le montant ne peut être négatif");
        Money { amount, currency }
    }

pub fn add(&self, other: &Money) -> Result<Money, &'static str> {
        if self.currency != other.currency {
            return Err("Devise différente");
        }
        Ok(Money::new(self.amount + other.amount, self.currency.clone()))
    }
}

Immuable, le type Money garantit à la compilation que les opérations de calcul ne peuvent être appliquées qu’à des devises compatibles.

5.2 Entité – Order

use uuid::Uuid;

#[derive(Debug)]
pub struct Order {
    id: Uuid,
    items: Vec<OrderItem>,
    total: Money,
    status: OrderStatus,
}

L’identité (id) persiste même si les attributs changent, conformément au concept d’entité DDD.

5.3 Agrégat – OrderRoot (racine)

pub trait OrderRepository {
    fn save(&self, order: &Order) -> Result<(), RepositoryError>;
    fn find_by_id(&self, id: Uuid) -> Result<Option<Order>, RepositoryError>;
}

impl Order {
    pub fn add_item(&mut self, item: OrderItem) -> Result<(), &'static str> {
        // Invariant : le total ne doit jamais dépasser un plafond métier
        let new_total = self.total.add(&item.price)?;
        if new_total.amount > Decimal::from(10_000) {
            return Err("Plafond de commande dépassé");
        }
        self.items.push(item);
        self.total = new_total;
        Ok(())
    }

pub fn confirm(&mut self) -> Result<(), &'static str> {
        if self.items.is_empty() {
            return Err("Impossible de confirmer une commande vide");
        }
        self.status = OrderStatus::Confirmed;
        Ok(())
    }
}

Toutes les mutations passent par la racine d’agrégat (Order), garantissant le respect des invariants (plafond, non‑vide).

6️⃣ Implémentation des repositories et de l’infrastructure

6.1 Trait du repository (port)

pub trait OrderRepository: Send + Sync {
    fn insert(&self, order: &Order) -> Result<(), RepositoryError>;
    fn update(&self, order: &Order) -> Result<(), RepositoryError>;
    fn get(&self, id: Uuid) -> Result<Option<Order>, RepositoryError>;
}

Le trait est object‑safe, permettant l’usage du dynamic dispatch au niveau de l’injection de dépendances.

6.2 Implémentation avec Diesel (adapter)

use diesel::prelude::*;
use crate::domain::{Order, OrderRepository};

pub struct DieselOrderRepo {
    pool: sqlx::PgPool,
}

#[async_trait::async_trait]
impl OrderRepository for DieselOrderRepo {
    async fn insert(&self, order: &Order) -> Result<(), RepositoryError> {
        // Mapping manuel ou via `diesel_derive_newtype`…
        unimplemented!()
    }

async fn update(&self, order: &Order) -> Result<(), RepositoryError> {
        unimplemented!()
    }

async fn get(&self, id: Uuid) -> Result<Option<Order>, RepositoryError> {
        unimplemented!()
    }
}

Remarque<0xE2><0x80><0xAF>: async_trait permet d’exposer des méthodes asynchrones tout en conservant la signature du trait.

6.3 Injection de dépendances avec tower::ServiceBuilder

use tower::ServiceBuilder;

let repo = DieselOrderRepo { pool };
let app_state = AppState {
    order_repo: Arc::new(repo),
};

let app = ServiceBuilder::new()
    .layer(Extension(app_state))
    .service(router);

Cette approche sépare le domaine (indépendant de diesel) de l’infrastructure, selon les principes DDD.

7️⃣ Gestion des transactions et cohérence éventuelle

L'atomicité globale est souvent impossible à garantir dans les systèmes distribués. Rust permet d'implémenter deux stratégies :

StratégieDescriptionImplémentation typique
Transaction locale (ACID)Utilisation d’une base relationnelle avec commit/rollback autour d’un agrégat.diesel::connection::Transactional ou le bloc pool.begin().await?.
Eventual consistencyPublication d’événements (Domain Events) après validation de l’agrégat, puis traitement asynchrone.Créer un DomainEventPublisher (trait) et l’injecter dans les services applicatifs; utilisation de Kafka ou NATS.

Le type system de Rust peut garantir que les événements ne sont émis qu’après un état valide<0xE2><0x80><0xAF>: la fonction qui confirme une commande retourne un struct OrderConfirmed implémentant DomainEvent.

8️⃣ Tests et validation – tirer parti du typage fort

8.1 Tests unitaires sur le domaine

#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

#[test]
    fn add_item_respects_ceiling() {
        let mut order = Order::new(Uuid::new_v4(), Money::new(dec!(0), Currency::EUR));
        let expensive_item = OrderItem::new(
            ProductId::new("P123"),
            Money::new(dec!(9_999), Currency::EUR),
        );

// Première insertion ok
        assert!(order.add_item(expensive_item.clone()).is_ok());

// Deuxième insertion dépasse le plafond de 10<0xE2><0x80><0xAF>000 EUR → erreur
        let err = order.add_item(expensive_item).unwrap_err();
        assert_eq!(err, "Plafond de commande dépassé");
    }
}

Le compilateur assure que les préconditions (ex.<0xE2><0x80><0xAF>: devise identique) sont respectées avant l’exécution du test.

8.2 Tests d’intégration avec tokio et base en mémoire

#[tokio::test]
async fn repository_roundtrip() {
    let pool = setup_test_db().await;
    let repo = DieselOrderRepo { pool };
    let order = Order::fixture(); // crée une commande valide

repo.insert(&order).await.unwrap();
    let fetched = repo.get(order.id()).await.unwrap().unwrap();

assert_eq!(fetched, order);
}

Ces tests valident que les adapters respectent le contrat du port sans logique métier supplémentaire.

9️⃣ Points de vigilance et limites : ce qu’il faut mesurer avant d’adopter

9.1 Courbe d’apprentissage

Rust possède une syntaxe et un modèle de possession qui demandent plusieurs semaines d’intégration pour des équipes habituées à JavaScript ou Python. La productivité initiale peut chuter de 30 % à 50 % selon les études internes de la communauté Rust (exemple tiré du State of Rust Survey 2023).

9.2 Maturité des bibliothèques DDD

Contrairement à l’écosystème Java, il n’existe pas de framework complet « DDD‑Rust ». Les développeurs doivent composer les abstractions (traits, crates) eux‑mêmes, ce qui augmente la charge de conception et le risque d’incohérences.

9.3 Gestion du borrowing dans les agrégats

Les agrégats contenant des collections mutables peuvent déclencher des conflits d’emprunt (cannot borrow order as mutable because it is also borrowed as immutable). La résolution passe souvent par refactoring (ex. : utilisation de Rc<RefCell<_>> ou du pattern Command), ce qui peut atténuer les garanties d’immutabilité.

9.4 Interopérabilité avec des services externes

Les systèmes existants (micro‑services Java, bases NoSQL) nécessitent souvent des bindings FFI ou des ponts HTTP/GRPC. Le coût de ces interfaçages doit être intégré dans le calcul total du projet.

9.5 Coût d’infrastructure

Rust génère des binaires statiques, ce qui simplifie le déploiement (Docker images < 30 Mo) mais peut augmenter la consommation de RAM lors du démarrage si plusieurs services lourds sont exécutés sur un même nœud.

🔟 Conclusion opérationnelle – Décider d’adopter DDD en Rust

Le

Poursuivre le parcours : Conformité NIS2, DORA et RGPDSolutionProduitDemander une démonstration

Retour au blog

StratoSentry - 125 boulevard Saint-Denis, 92400 Courbevoie, France - contact@stratosentry.com