Architecture hexagonale - 06 - Projet complet : une API de commandes en TypeScript

Projet complet en architecture hexagonale avec TypeScript. API de commandes avec domaine, ports, adaptateurs et tests.

06 - Projet complet : une API de commandes en TypeScript

Ce que tu vas apprendre

  • Comment structurer un projet hexagonal complet
  • Comment cabler les couches avec de l'injection de dépendances manuelle
  • Le code complet d'une API de commandes fonctionnelle

Prerequisites


La structure du projet

On a vu chaque couche séparément dans les articles précédents. Maintenant, on assemble le tout. Voici la structure de fichiers complète :

src/
  domain/
    entities/
      Order.ts
    value-objects/
      Money.ts
    services/
      ShippingCalculator.ts
  ports/
    inbound/
      CreateOrderPort.ts
      GetOrderPort.ts
      CancelOrderPort.ts
    outbound/
      OrderRepositoryPort.ts
      PaymentGatewayPort.ts
      EmailSenderPort.ts
  adapters/
    inbound/
      ExpressOrderController.ts
    outbound/
      PostgresOrderRepository.ts
      InMemoryOrderRepository.ts
      StripePaymentGateway.ts
      ConsoleEmailSender.ts
  app/
    CreateOrderUseCase.ts
    GetOrderUseCase.ts
    CancelOrderUseCase.ts
  main.ts

Quatre dossiers a la racine de src/. Pas plus. Chaque dossier a un rôle precis :

  • domain/ : logique métier pure, zero import externe
  • ports/ : interfaces TypeScript, le contrat entre les couches
  • adapters/ : code d'infrastructure, les seuls fichiers qui importent des libs externes
  • app/ : use cases, l'orchestration entre domaine et ports

La couche app : les use cases

Les use cases sont le lien entre les ports entrants et le domaine. Ils orchestrent la logique sans la contenir.

typescript// app/CreateOrderUseCase.ts

import { randomUUID } from "crypto";
import { CreateOrderPort, CreateOrderCommand, CreateOrderResult } from "../ports/inbound/CreateOrderPort";
import { OrderRepositoryPort } from "../ports/outbound/OrderRepositoryPort";
import { Order } from "../domain/entities/Order";

class CreateOrderUseCase implements CreateOrderPort {
  constructor(private orderRepository: OrderRepositoryPort) {}

  async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
    const order = new Order(
      randomUUID(),
      command.customerId,
      [],
      "draft",
      new Date()
    );

    for (const item of command.items) {
      order.addItem(item);
    }

    await this.orderRepository.save(order);

    return {
      orderId: order.id,
      totalPrice: order.totalPrice(),
      status: order.getStatus(),
    };
  }
}
typescript// app/GetOrderUseCase.ts

import { GetOrderPort, OrderView } from "../ports/inbound/GetOrderPort";
import { OrderRepositoryPort } from "../ports/outbound/OrderRepositoryPort";

class GetOrderUseCase implements GetOrderPort {
  constructor(private orderRepository: OrderRepositoryPort) {}

  async execute(orderId: string): Promise<OrderView | null> {
    const order = await this.orderRepository.findById(orderId);
    if (!order) return null;

    return {
      id: order.id,
      customerId: order.customerId,
      items: order.getItems(),
      totalPrice: order.totalPrice(),
      status: order.getStatus(),
      createdAt: order.createdAt,
    };
  }
}
typescript// app/CancelOrderUseCase.ts

import { CancelOrderPort } from "../ports/inbound/CancelOrderPort";
import { OrderRepositoryPort } from "../ports/outbound/OrderRepositoryPort";

class CancelOrderUseCase implements CancelOrderPort {
  constructor(private orderRepository: OrderRepositoryPort) {}

  async execute(orderId: string): Promise<void> {
    const order = await this.orderRepository.findById(orderId);
    if (!order) {
      throw new Error(`Commande ${orderId} introuvable`);
    }

    order.cancel();
    await this.orderRepository.save(order);
  }
}

Chaque use case est simple. Il récupéré des entités via le repository, exécuté la logique métier sur ces entités, puis sauvegarde le résultat. Pas de requête SQL, pas de validation HTTP, pas de formatage JSON.

Le cablage : main.ts

C'est ici que tout se connecte. Et tu n'as pas besoin d'un framework d'injection de dépendances. Du constructeur, du new, et c'est regle.

typescript// main.ts

import express from "express";
import { Pool } from "pg";
import { PostgresOrderRepository } from "./adapters/outbound/PostgresOrderRepository";
import { StripePaymentGateway } from "./adapters/outbound/StripePaymentGateway";
import { ConsoleEmailSender } from "./adapters/outbound/ConsoleEmailSender";
import { CreateOrderUseCase } from "./app/CreateOrderUseCase";
import { GetOrderUseCase } from "./app/GetOrderUseCase";
import { CancelOrderUseCase } from "./app/CancelOrderUseCase";
import { createOrderRouter } from "./adapters/inbound/ExpressOrderController";

// --- Adaptateurs sortants ---
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});
const orderRepository = new PostgresOrderRepository(pool);
const paymentGateway = new StripePaymentGateway(process.env.STRIPE_API_KEY!);
const emailSender = new ConsoleEmailSender();

// --- Use cases ---
const createOrder = new CreateOrderUseCase(orderRepository);
const getOrder = new GetOrderUseCase(orderRepository);
const cancelOrder = new CancelOrderUseCase(orderRepository);

// --- Adaptateurs entrants ---
const app = express();
app.use(express.json());
app.use("/api", createOrderRouter(createOrder, getOrder, cancelOrder));

// --- Demarrage ---
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Serveur demarre sur le port ${PORT}`);
});

Lis ce fichier du haut vers le bas. D'abord les adaptateurs sortants (infrastructure), puis les use cases (logique applicative), puis les adaptateurs entrants (exposition HTTP). C'est le seul fichier qui connaît tous les composants. C'est le point d'assemblage.

Je déconseillé d'utiliser un conteneur IoC (InversifyJS, tsyringe, etc.) pour un projet de cette taille. L'injection manuelle via constructeur est lisible, typee, et debuggable. Quand tu as 50+ services, ca se discute. En-dessous, un framework d'injection ajoute de la complexité sans benefice réel.

Un adaptateur email pour le dev

Pour le développement local, pas besoin de SendGrid. Un simple adaptateur qui log dans la console fait l'affaire :

typescript// adapters/outbound/ConsoleEmailSender.ts

import { EmailSenderPort } from "../../ports/outbound/EmailSenderPort";

class ConsoleEmailSender implements EmailSenderPort {
  async sendOrderConfirmation(params: {
    to: string;
    orderId: string;
    totalPrice: number;
  }): Promise<void> {
    console.log(
      `[EMAIL] Confirmation commande ${params.orderId} ` +
      `envoyee a ${params.to} (${params.totalPrice} EUR)`
    );
  }
}

En prod, tu branches SendGridEmailSender. En dev, ConsoleEmailSender. En test, InMemoryEmailSender qui stocke les emails dans un tableau pour que tu puisses les vérifier. Meme interface, trois comportements différents.

Le flux complet d'une requête

Pour bien visualiser comment les couches s'articulent, suivons une requête POST /api/orders de bout en bout :

1. Express recoit POST /api/orders
2. ExpressOrderController extrait customerId et items du body
3. Il appelle createOrder.execute({ customerId, items })
4. CreateOrderUseCase cree une entite Order (domaine)
5. L'entite valide les items (regles metier)
6. Le use case appelle orderRepository.save(order)
7. PostgresOrderRepository execute le INSERT SQL
8. Le use case retourne { orderId, totalPrice, status }
9. Le controller repond 201 avec le JSON

A chaque étape, le code ne connaît que sa couche et la couche en-dessous (via les interfaces). Le controller ne sait pas que c'est PostgreSQL. Le use case ne sait pas que c'est Express. Le domaine ne sait rien du tout.

Sur paltemps.fr, c'est ce type de structure qu'on met en place pour les projets backend avec une logique métier non triviale. L'investissement initial est d'environ 30 minutes de setup, et le retour se fait sentir des la première semaine.


Résumé

  • Quatre dossiers : domain/, ports/, adapters/, app/
  • Les use cases implementent les ports entrants et utilisent les ports sortants
  • Le main.ts cable tout avec de l'injection manuelle via constructeur
  • Pas besoin de framework IoC pour la plupart des projets
  • Chaque couche ne connaît que ses interfaces, jamais les implementations des autres

Article précédent : 05 - Les tests

Article suivant : 07 - Quand utiliser (et ne pas utiliser) l'hexa

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.