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 externeports/: interfaces TypeScript, le contrat entre les couchesadapters/: code d'infrastructure, les seuls fichiers qui importent des libs externesapp/: 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.tscable 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