Architecture12 min de lecture

Architecture Hexagonale : Le guide pragmatique pour développer sereinement

Les tests « prennent du temps » — voilà ce qu'on entend dans 80% des équipes. Pourtant, après 10 ans à accompagner des projets critiques, je peux vous dire le contraire : les tests bien conçus vous font gagner du temps. La condition ? Votre architecture doit être pensée pour ça.

L'Architecture Hexagonale (ou Ports & Adapters) n'est pas une mode d'architecte logiciel. C'est une réponse concrète à un problème que vous avez déjà rencontré.

Le piège du couplage : un cas réel

Imaginons que vous devez calculer les frais de livraison. Logique simple, non ?

// ❌ Code couplé - cauchemar à tester
@Injectable()
export class ShippingService {
  constructor(
    @InjectRepository(Order) private orderRepo: Repository<Order>,
    private httpService: HttpService,
  ) {}

  async calculateFees(orderId: string): Promise<number> {
    const order = await this.orderRepo.findOne({ where: { id: orderId } });
    const { data } = await this.httpService.post('https://api.carrier.com/rates', {
      weight: order.weight,
    }).toPromise();
    return data.price * 1.2; // marge 20%
  }
}

Pour tester ce code, il vous faut :

  • Une base de données de test
  • Un mock du HttpService
  • Gérer les timeouts réseau
  • 3-5 secondes par test

Résultat : personne ne teste vraiment. Les bugs arrivent en prod.

L'approche hexagonale : isolation du métier

Le principe est simple : séparez ce qui change rarement (votre logique) de ce qui change souvent (vos outils).

// ✅ Domaine pur - testable en 10ms
export class ShippingCalculator {
  calculate(weight: number, baseRate: number): number {
    if (weight <= 0) throw new InvalidWeightError();
    return baseRate * 1.2; // Logique métier pure
  }
}

// Port (interface) - dans domain/ports/
export interface CarrierPort {
  getRate(weight: number): Promise<number>;
}

// Adaptateur (implémentation) - dans infrastructure/
@Injectable()
export class HttpCarrierAdapter implements CarrierPort {
  constructor(private httpService: HttpService) {}

  async getRate(weight: number): Promise<number> {
    const { data } = await this.httpService
      .post('https://api.carrier.com/rates', { weight })
      .toPromise();
    return data.price;
  }
}

Gain immédiat : votre test devient :

describe('ShippingCalculator', () => {
  it('should apply 20% margin', () => {
    const calculator = new ShippingCalculator();
    expect(calculator.calculate(5, 10)).toBe(12);
  }); // ⚡ 2ms d'exécution
});

Mise en place : 3 couches, 3 responsabilités

1. Le Domaine (cœur métier)

Règle d'or : zéro dépendance externe. Pas de @Injectable(), pas de framework.

// domain/shipping-policy.ts
export class ShippingPolicy {
  isFreeShipping(orderAmount: number, customerTier: string): boolean {
    if (customerTier === 'PREMIUM') return true;
    return orderAmount >= 50;
  }
}

2. Les Ports (contrats)

Définissent quoi faire, pas comment.

// domain/ports/order.repository.ts
export interface OrderRepository {
  findById(id: string): Promise<Order>;
  save(order: Order): Promise<void>;
}

3. Les Adaptateurs (implémentation)

Se branchent sur vos outils réels. Ici, avec NestJS et TypeORM.

// infrastructure/persistence/typeorm-order.repository.ts
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
  constructor(
    @InjectRepository(OrderEntity)
    private repo: Repository<OrderEntity>,
  ) {}

  async findById(id: string): Promise<Order> {
    const entity = await this.repo.findOne({ where: { id } });
    return Order.fromPersistence(entity);
  }

  async save(order: Order): Promise<void> {
    await this.repo.save(order.toPersistence());
  }
}

Retour d'expérience : Mission logistique e-commerce

Le contexte

Refonte complète du système logistique pour un grand compte. 15 000 colis/jour, intégration de 8 transporteurs différents (Chronopost, Colissimo, DHL...).

Les gains mesurés

Avant l'architecture hexagonale (ancien système) :

  • Ajout d'un transporteur : 3-4 semaines
  • Suite de tests : 45 minutes
  • Déploiements : 2 par mois (risque élevé)

Après :

  • Ajout d'un transporteur : 2-3 jours
  • Suite de tests : 4 minutes (11x plus rapide)
  • Déploiements : 5-8 par jour (zéro incident)

"L'architecture nous a permis de tester toutes les combinaisons de règles métier sans déployer. On a détecté 23 cas limites en phase de dev qui seraient passés en prod avant."
— Lead Dev, projet logistique

Exemple concret : Switch de transporteur

Pendant le Black Friday, un transporteur est tombé. Grâce à l'architecture :

  1. Création d'un nouvel adaptateur (30 min)
  2. Tests sur les vrais cas métier (1h)
  3. Déploiement (15 min)

Total : 2h pour basculer tout le trafic. Aucune interruption visible côté client.

Le super-pouvoir : reporter les décisions techniques

Voici le bénéfice le plus sous-estimé de l'hexagonal : vous n'avez pas besoin de tout décider dès le début.

Exemple concret : démarrer sans choisir la DB

Vous hésitez entre PostgreSQL et MongoDB ? Votre schéma n'est pas figé ? Commencez avec un adaptateur in-memory.

// infrastructure/persistence/in-memory-order.repository.ts
@Injectable()
export class InMemoryOrderRepository implements OrderRepository {
  private orders = new Map<string, Order>();

  async findById(id: string): Promise<Order> {
    const order = this.orders.get(id);
    if (!order) throw new OrderNotFoundError(id);
    return order;
  }

  async save(order: Order): Promise<void> {
    this.orders.set(order.id, order);
  }
}

Avantages :

  • Vous développez toute votre logique métier en 2 jours, pas en 2 semaines
  • Vous collectez de vraies données de votre domaine avant de designer le schéma DB
  • Vos tests sont ultra-rapides (pas de setup/teardown)

Plus tard, quand vous aurez plus d'infos, vous créez l'adaptateur PostgreSQL. Votre domaine ne change pas d'une ligne.

Exemple 2 : API externe incertaine

Vous devez intégrer une API de paiement, mais le contrat n'est pas finalisé ?

// infrastructure/payment/mock-payment.gateway.ts
@Injectable()
export class MockPaymentGateway implements PaymentGateway {
  async charge(amount: number): Promise<string> {
    // Simule un succès immédiat
    return `mock-${Date.now()}`;
  }
}

Votre équipe développe tout le parcours utilisateur pendant que les commerciaux négocient avec Stripe. Zéro blocage.

La vraie valeur : apprendre avant de s'engager

Lors de mes interventions, j'ai vu des équipes perdre 3 mois parce qu'elles avaient choisi :

  • Un schéma DB trop rigide (migrations cauchemars)
  • Une API externe qui ne correspondait pas au besoin réel
  • Une base NoSQL pour un use case relationnel

Avec l'hexagonal, vous testez vos hypothèses métier d'abord. Les choix techniques viennent après, avec de vraies données.

"On a commencé avec un simple Map() en mémoire. Après 2 sprints, on savait exactement quels index créer en DB. On a évité 15 migrations inutiles."
— Tech Lead, projet fintech

Les bénéfices business (pas que technique)

1. Testabilité = Vélocité

Le domaine isolé signifie tests en millisecondes, pas en minutes.

// Test de logique complexe - 5ms
describe('Order', () => {
  it('should apply discount rules', () => {
    const order = new Order();
    order.addItem(item, 3);
    order.applyDiscount('SUMMER20');
    expect(order.total()).toBe(48); // 60 - 20%
  });
});

Résultat : vous testez tous les cas limites avant même de déployer. Moins de bugs, moins de hotfixes, moins de stress.

2. Time-to-market réduit

Chaque nouvelle fonctionnalité se teste indépendamment. Plus besoin d'environnement de staging complet.

3. Onboarding accéléré

Un nouveau dev comprend le métier en lisant le domaine. Pas besoin de décrypter des SQL complexes.

4. Audit et conformité

Votre logique métier est documentée dans le code, pas dans des specs obsolètes.

Les controverses (soyons honnêtes)

"C'est de l'over-engineering"

Vrai pour : un MVP en 2 semaines, une app CRUD basique.

Faux pour : tout projet qui vivra plus de 6 mois avec une équipe de 3+ devs.

Mon conseil : commencez simple, refactorisez vers l'hexagonal quand la complexité apparaît, pas avant.

"Trop d'interfaces, trop de complexité"

La réalité : si vous appliquez déjà SOLID (le "D" = Dependency Inversion), vous créez des abstractions de toute façon.

// Avec ou sans hexagonal, vous faites ça :
interface UserRepository { ... }

// L'hexagonal dit juste : range cette interface dans /domain/ports/

L'hexagonal n'ajoute pas de complexité, il organise celle qui existe déjà.

Dans mes interventions, même les dev juniors comprennent vite : "Ah, c'est juste séparer le métier du technique !"

"Ça prend plus de temps à coder"

Faux. Vous écrivez les mêmes interfaces que vous auriez écrites avec SOLID.

La différence : au lieu de perdre 3 jours à débugger un test qui nécessite une DB, vous testez en 10ms. Le ROI est immédiat.

Checklist : quand adopter l'hexagonale ?

Dites OUI si vous cochez 2+ cases :

  • [ ] Votre logique métier est complexe (c'est toujours le cas)
  • [ ] Vous avez plusieurs sources de données (API, DB, fichiers...)
  • [ ] Vous déployez régulièrement (CI/CD)
  • [ ] L'équipe fera plus de 2 personnes
  • [ ] Le projet durera plus de 6 mois
  • [ ] Vous avez besoin de tests rapides

Dites NON si :

  • [ ] C'est un prototype jetable
  • [ ] Une app CRUD pure sans règles métier

Par où commencer ? (plan d'action)

Étape 1 : Identifiez votre domaine

Posez-vous la question : "Si je change de NestJS vers Express demain, qu'est-ce qui reste ?"

Réponse : vos règles métier. C'est votre domaine.

Étape 2 : Créez un port (interface)

Commencez par un seul service externe.

// domain/ports/payment.gateway.ts
export interface PaymentGateway {
  charge(amount: number): Promise<string>;
}

Étape 3 : Testez le domaine (la clé !)

Écrivez 5-10 tests unitaires sur votre logique métier. Pas de DB, pas d'API, pas de NestJS.

// Pure domain test - no framework needed
describe('Order', () => {
  it('calculates total with tax', () => {
    const order = new Order();
    order.addItem(new Item('Product', 100), 2);
    expect(order.totalWithTax(0.2)).toBe(240); // 200 + 20% VAT
  });
});

Si ce test tourne en moins de 10ms, vous avez gagné.

Étape 4 : Mesurez les gains

Chronométrez votre suite de tests avant/après. Vous devriez voir x5 à x10 de gain.

C'est la testabilité qui transforme votre code en actif business.

Structure de dossiers (NestJS)

src/
└── orders/                        # Feature "Orders" crie son intention
   ├── domain/                    # Logique métier pure (zéro @Injectable)
   │   ├── entities/
   │   │   └── order.entity.ts
   │   ├── services/
   │   │   └── order-pricing.service.ts
   │   ├── ports/
   │   │   ├── order.repository.ts
   │   │   └── payment.gateway.ts
   │   └── exceptions/
   │       └── invalid-order.error.ts
   ├── application/               # Use cases
   │   └── create-order.usecase.ts
   └── infrastructure/            # Adaptateurs
       ├── persistence/
       │   └── typeorm-order.repository.ts
       ├── http/
       │   ├── orders.controller.ts
       │   └── dto/
       └── orders.module.ts

Règle NestJS : seuls les adaptateurs utilisent @Injectable(). Le domaine reste pur.

En résumé

L'Architecture Hexagonale n'est pas un dogme. C'est une stratégie de réduction du risque.

La vérité : si vous appliquez déjà SOLID, vous créez des abstractions de toute façon. L'hexagonal, c'est juste les organiser intelligemment.

Vous gagnez :

  • Tests 10x plus rapides
  • Bugs divisés par 3-4
  • Vélocité constante (pas de ralentissement en phase 2)
  • Sérénité en déploiement

Lors de mes interventions, cette architecture nous a sauvés à chaque fois. Un transporteur qui tombe ? Un changement de règle fiscale ? Une migration de DB ? On modifie un adaptateur, on teste, on déploie.

Le code legacy, c'est du code qu'on a peur de toucher.
Le code hexagonal, c'est du code qu'on modifie avec confiance.


Vous voulez aller plus loin ?
Je propose des audits d'architecture et des ateliers pratiques pour transformer votre base de code.

Besoin d'un regard expert sur votre architecture ?

Discutons de votre projet et voyons comment je peux vous accompagner.

Discutons-en