04 - Les adaptateurs : brancher le monde réel
Ce que tu vas apprendre
- Comment implementer des adaptateurs entrants et sortants
- Pourquoi plusieurs adaptateurs pour un meme port changent la donne
- Des exemples concrets avec Express, PostgreSQL et Stripe
Prerequisites
Les adaptateurs, c'est là où le code technique vit
Jusqu'ici, on a parle du domaine (logique métier pure) et des ports (interfaces). Tout ca est abstrait. Les adaptateurs, c'est le concret. C'est la que tu importes Express, que tu ecris des requêtes SQL, que tu appelles l'API Stripe.
Un adaptateur implemente un port. Il traduit entre le monde extérieur et le domaine. Pas plus, pas moins.
Adaptateurs entrants : recevoir les requêtes
Un adaptateur entrant recoit une requête du monde extérieur et appelle un port entrant. L'exemple classique : un controller Express.
typescript// adapters/inbound/ExpressOrderController.ts
import { Router, Request, Response } from "express";
import { CreateOrderPort } from "../../ports/inbound/CreateOrderPort";
import { GetOrderPort } from "../../ports/inbound/GetOrderPort";
import { CancelOrderPort } from "../../ports/inbound/CancelOrderPort";
function createOrderRouter(
createOrder: CreateOrderPort,
getOrder: GetOrderPort,
cancelOrder: CancelOrderPort
): Router {
const router = Router();
router.post("/orders", async (req: Request, res: Response) => {
try {
const result = await createOrder.execute({
customerId: req.body.customerId,
items: req.body.items,
});
res.status(201).json(result);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
router.get("/orders/:id", async (req: Request, res: Response) => {
const order = await getOrder.execute(req.params.id);
if (!order) {
res.status(404).json({ error: "Commande introuvable" });
return;
}
res.json(order);
});
router.post("/orders/:id/cancel", async (req: Request, res: Response) => {
try {
await cancelOrder.execute(req.params.id);
res.status(200).json({ message: "Commande annulee" });
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}
Le controller ne contient aucune logique métier. Il fait trois choses : extraire les donnees de la requête HTTP, appeler le port entrant, formater la réponse HTTP. C'est un traducteur entre HTTP et le domaine.
Et voici le meme cas d'usage, branche sur une CLI :
typescript// adapters/inbound/CliCreateOrder.ts
import { CreateOrderPort } from "../../ports/inbound/CreateOrderPort";
async function handleCreateOrderCli(
createOrder: CreateOrderPort,
args: string[]
): Promise<void> {
const customerId = args[0];
const itemsJson = args[1];
const items = JSON.parse(itemsJson);
const result = await createOrder.execute({ customerId, items });
console.log(`Commande creee : ${result.orderId} (${result.totalPrice} EUR)`);
}
Deux adaptateurs entrants, le meme port, la meme logique métier. Zero duplication. C'est ca la puissance des ports et adaptateurs.
Adaptateurs sortants : parler au monde extérieur
Les adaptateurs sortants implementent les ports sortants. Ils contiennent le code d'infrastructure : requêtes SQL, appels API, envoi d'emails.
typescript// adapters/outbound/PostgresOrderRepository.ts
import { Pool } from "pg";
import { OrderRepositoryPort } from "../../ports/outbound/OrderRepositoryPort";
import { Order } from "../../domain/entities/Order";
class PostgresOrderRepository implements OrderRepositoryPort {
constructor(private pool: Pool) {}
async save(order: Order): Promise<void> {
const items = order.getItems();
await this.pool.query(
`INSERT INTO orders (id, customer_id, items, status, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
items = $3, status = $4`,
[
order.id,
order.customerId,
JSON.stringify(items),
order.getStatus(),
order.createdAt,
]
);
}
async findById(id: string): Promise<Order | null> {
const result = await this.pool.query(
"SELECT * FROM orders WHERE id = $1",
[id]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return new Order(
row.id,
row.customer_id,
JSON.parse(row.items),
row.status,
row.created_at
);
}
async findByCustomerId(customerId: string): Promise<Order[]> {
const result = await this.pool.query(
"SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC",
[customerId]
);
return result.rows.map(
(row) =>
new Order(
row.id,
row.customer_id,
JSON.parse(row.items),
row.status,
row.created_at
)
);
}
async delete(id: string): Promise<void> {
await this.pool.query("DELETE FROM orders WHERE id = $1", [id]);
}
}
typescript// adapters/outbound/StripePaymentGateway.ts
import Stripe from "stripe";
import { PaymentGatewayPort, PaymentResult } from "../../ports/outbound/PaymentGatewayPort";
class StripePaymentGateway implements PaymentGatewayPort {
private stripe: Stripe;
constructor(apiKey: string) {
this.stripe = new Stripe(apiKey);
}
async charge(params: {
orderId: string;
amount: number;
currency: string;
customerId: string;
}): Promise<PaymentResult> {
try {
const paymentIntent = await this.stripe.paymentIntents.create({
amount: Math.round(params.amount * 100),
currency: params.currency.toLowerCase(),
metadata: {
orderId: params.orderId,
customerId: params.customerId,
},
});
return {
success: true,
transactionId: paymentIntent.id,
errorMessage: null,
};
} catch (error) {
return {
success: false,
transactionId: null,
errorMessage: (error as Error).message,
};
}
}
}
Tout le code spécifique a Stripe est isole dans cet adaptateur. Le jour ou tu migres vers Adyen, Mollie ou un autre provider, tu ecris un nouvel adaptateur qui implemente PaymentGatewayPort. Le domaine et les use cases ne changent pas d'une ligne.
L'adaptateur de test : le secret le mieux garde
C'est là où ca devient vraiment interessant. Rien ne t'empeche d'écrire un adaptateur qui stocke tout en mémoire :
typescript// adapters/outbound/InMemoryOrderRepository.ts
import { OrderRepositoryPort } from "../../ports/outbound/OrderRepositoryPort";
import { Order } from "../../domain/entities/Order";
class InMemoryOrderRepository implements OrderRepositoryPort {
private orders: Map<string, Order> = new Map();
async save(order: Order): Promise<void> {
this.orders.set(order.id, order);
}
async findById(id: string): Promise<Order | null> {
return this.orders.get(id) ?? null;
}
async findByCustomerId(customerId: string): Promise<Order[]> {
return Array.from(this.orders.values()).filter(
(o) => o.customerId === customerId
);
}
async delete(id: string): Promise<void> {
this.orders.delete(id);
}
}
Meme interface. Pas de base de donnees. Pas de Docker. Pas de setup. On en reparle en détail dans l'article 05 sur les tests.
Le vrai pouvoir : la permutabilite
J'ai travaille sur un projet ou le client a change d'avis trois fois sur le provider d'email. D'abord SendGrid, puis Mailgun, puis finalement Amazon SES. Avec une architecture hexagonale en place, chaque changement a pris moins de 2 heures. Écrire le nouvel adaptateur, le brancher dans le bootstrap, lancer les tests d'intégration. Fini.
Sans cette architecture, chaque migration aurait implique de retrouver tous les appels a l'ancien SDK, les remplacer, retester tous les workflows concernes. Des jours de travail a chaque fois.
Sur paltemps.fr, cette flexibilité est un argument qu'on met en avant quand on propose de l'architecture hexagonale a un client. Le surcout initial est rembourse au premier changement d'infrastructure.
Résumé
- Les adaptateurs entrants traduisent les requêtes externes vers les ports entrants
- Les adaptateurs sortants implementent les ports sortants avec du code d'infrastructure
- Plusieurs adaptateurs pour le meme port (Express + CLI, PostgreSQL + InMemory)
- La permutabilite est le gain concret : changer de provider sans toucher au domaine
- L'adaptateur in-memory est un outil de test redoutable
Article précédent : 03 - Les ports
Article suivant : 05 - Tester sans souffrir : le vrai gain de l'hexa