Le problème
Notre boutique en ligne tient dans un monorepo. Le service
order publie
order.order.placed.v1, que
stock et shipping consomment.
Producteur, consommateurs et format du message changent dans
la même pull request, et la CI les teste ensemble.
Renommons customerId en
clientId des deux côtés : tous les tests
passent. En production, le changement casse pourtant à trois
endroits.
-
Le déploiement progressif. L'ancienne
version de
stock, encore en service, reçoit de la nouvelle version deorderdes messages sanscustomerId. -
Les messages déjà en queue. La nouvelle
version de
stocklit des messages écrits avant la mise à jour, et n'y trouve pasclientId. - Les rejeux. Un message mis en quarantaine la semaine dernière est rejoué aujourd'hui, dans un format que plus aucun code ne lit.
Deux défauts de format s'y ajoutent. Additionnées en
flottants puis encodées en JSON, des quantités de
0.1 et 0.2 kilo donnent
0.30000000000000004. Et un champ à
null ne dit pas s'il est inconnu, vide, effacé
ou oublié.
Les options
Partager les classes PHP, sans schéma
-
Ce qu'elle règle : rien à écrire, puisque
Messenger applique par défaut
serialize()aux classes partagées. - Ce qu'elle coûte : le format suit le code. Renommer une propriété casse les messages en queue, et un autre langage ne sait pas les lire.
Un JSON Schema par événement, des classes dans chaque service
- Ce qu'elle règle : le format est décrit hors du code.
- Ce qu'elle coûte : chaque service traduit le schéma à sa façon, et rien ne vérifie que le code le respecte.
Un package de contrats léger
- Ce qu'elle règle : constantes, DTO et schémas changent ensemble, et des tests vérifient qu'ils restent d'accord.
- Ce qu'elle coûte : des règles d'évolution à respecter, et deux descriptions du même message à maintenir.
Un registre de schémas et du code généré
- Ce qu'elle règle : le registre refuse une version incompatible avec la précédente, et le code est généré pour chaque langage.
- Ce qu'elle coûte : un service de plus à opérer, et une chaîne de génération à maintenir.
Notre recommandation
Un package app-contracts partagé, des
évolutions additives, une validation chez le producteur,
une lecture tolérante chez les consommateurs et des
fixtures figées.
Le package contient le nom de l'exchange, les clés de
routage, un DTO readonly par événement et les
JSON Schemas, sans dépendance d'exécution. Les queues, les
binding keys et les handlers restent chez chaque
consommateur, comme dans
l'article sur la topologie RabbitMQ.
Une enveloppe commune, un payload par événement
Les champs de l'enveloppe :
-
event_id: un UUID v7 posé par le producteur dans l'outbox ; -
event_name: identique à la clé de routage ; -
occurred_at: l'instant du fait, en RFC 3339 avec décalage horaire ; producer: le service émetteur ;-
schema_version: la révision du schéma,1.0puis1.1après un ajout ; -
correlation_id: relie les messages d'un même parcours ; pour une commande, son identifiant ; payload: les données de l'événement.
Des évolutions additives, une v2 pour le reste
Au sein d'une version, nous n'ajoutons que des champs
optionnels. Retirer un champ, le renommer, changer son type
ou le rendre obligatoire crée un nouvel événement,
order.order.placed.v2. Il est publié en
parallèle de la v1 pendant la transition.
Les schémas ne posent jamais
additionalProperties: false. En JSON Schema
2020-12, l'absence de ce mot-clé accepte toute propriété
supplémentaire. Un schéma fermé ferait de chaque ajout un
changement cassant.
Des décimales en chaîne, l'absence plutôt que null
Quantités et montants voyagent en chaînes décimales
("2.000"), dont le schéma fixe la forme. Le
consommateur calcule avec BCMath, sans flottant. Un champ
optionnel sans valeur est absent, jamais à
null : le schéma refuse null, et
le producteur sérialise sans les valeurs nulles.
Des chaînes, pas d'enum PHP
Un enum PHP rejette toute valeur inconnue :
BackedEnum::from() lève une
ValueError, et le Serializer Symfony refuse la
donnée par défaut. Une valeur ajoutée par un producteur plus
récent rendrait le message illisible chez les anciens
consommateurs. Nos DTO portent donc des chaînes, les valeurs
connues sont des constantes, et le consommateur prévoit un
cas par défaut.
Valider chez le producteur, tolérer chez le consommateur
Le producteur valide chaque message contre son JSON Schema en écrivant sa ligne d'outbox, dans la transaction de la commande. Un message invalide annule la transaction : l'erreur apparaît chez celui qui l'a causée. Le consommateur ne valide pas : il ignore les champs inconnus et ne lit que ce dont il a besoin.
Les compromis assumés
Deux descriptions du même message. Le schéma et le DTO changent ensemble, et les tests de snapshot signalent un écart. Nous écrivons les DTO à la main tant que les contrats restent peu nombreux.
Une discipline de versionnement. Une
v2 coûte une double publication, puis un
nettoyage. La revue de code doit repérer chaque changement
cassant.
Un consommateur qui fait confiance. Un message publié à la main, hors de l'outbox, n'est vérifié par personne. Nous réservons la publication manuelle aux rejeux.
Un format qui dépend de la
bibliothèque.
En 2020-12, format n'est qu'une annotation par
défaut. La bibliothèque opis/json-schema le vérifie, mais
accepte une date-heure sans décalage horaire :
occurred_at porte donc aussi un
pattern.
Le signal qui doit faire réexaminer ce choix : des écarts répétés entre schémas et DTO, ou un consommateur écrit dans un autre langage. La génération de code, voire un registre, devient alors rentable.
Mise en œuvre
Nous utilisons PHP 8.3 ou plus, Symfony 7.4 et opis/json-schema 2.6, qui gère le draft 2020-12. justinrainbow/json-schema 6.13, également maintenu, s'arrête au draft 2019-09.
1. Installer le package
Le package vit dans app-contracts/, à la racine
du monorepo. Son composer.json n'exige que PHP
: validateur, Serializer, PropertyAccess et PHPUnit ne
servent qu'aux tests. Chaque service le déclare comme dépôt
de type path :
{
"repositories": [{ "type": "path", "url": "../app-contracts" }],
"require": { "app/contracts": "*@dev" }
}
Composer crée un lien symbolique : une modification du
package est visible tout de suite. Pour une image Docker,
COMPOSER_MIRROR_PATH_REPOS=1 copie plutôt le
package dans vendor/.
2. Écrire les schémas
L'enveloppe, dans
app-contracts/schemas/common/envelope.v1.json :
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/app-contracts/schemas/common/envelope.v1.json",
"type": "object",
"required": [
"event_id",
"event_name",
"occurred_at",
"producer",
"schema_version",
"correlation_id",
"payload"
],
"properties": {
"event_id": { "type": "string", "format": "uuid" },
"event_name": { "type": "string" },
"occurred_at": {
"type": "string",
"format": "date-time",
"pattern": "(Z|[+-][0-9]{2}:[0-9]{2})$"
},
"producer": { "type": "string", "minLength": 1 },
"schema_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" },
"correlation_id": { "type": "string", "minLength": 1 },
"payload": { "type": "object" }
}
}
L'événement, dans
app-contracts/schemas/order.order.placed.v1.json, applique l'enveloppe par $ref et précise le
payload. En 2020-12, d'autres mots-clés peuvent accompagner
$ref.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/app-contracts/schemas/order.order.placed.v1.json",
"$ref": "common/envelope.v1.json",
"properties": {
"event_name": { "const": "order.order.placed.v1" },
"schema_version": { "pattern": "^1\\." },
"payload": {
"required": ["orderId", "customerId", "lines"],
"properties": {
"orderId": { "type": "string", "format": "uuid" },
"customerId": { "type": "string", "format": "uuid" },
"lines": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["productId", "quantity"],
"properties": {
"productId": { "type": "string", "format": "uuid" },
"quantity": { "type": "string", "pattern": "^[0-9]+\\.[0-9]{3}$" }
}
}
}
}
}
}
}
3. Le DTO et le catalogue
// app-contracts/src/Order/OrderPlacedV1.php
namespace AppContracts\Order;
final readonly class OrderPlacedV1
{
public const SCHEMA_VERSION = '1.0';
/**
* @param list<array{productId: string, quantity: string}> $lines
*/
public function __construct(
public string $orderId,
public string $customerId,
public array $lines,
) {
}
}
// app-contracts/src/EventCatalog.php
namespace AppContracts;
use AppContracts\Order\OrderCancelledV1;
use AppContracts\Order\OrderPlacedV1;
final class EventCatalog
{
public const SCHEMA_BASE_URI = 'https://example.com/app-contracts/schemas/';
public const SCHEMA_DIR = __DIR__ . '/../schemas';
/** Nom de l'événement => DTO. */
public const CLASSES = [
Routing::ORDER_PLACED_V1 => OrderPlacedV1::class,
Routing::ORDER_CANCELLED_V1 => OrderCancelledV1::class,
];
}
4. Valider dans la transaction du producteur
// src/Messenger/ContractValidator.php (service order)
namespace App\Messenger;
use AppContracts\EventCatalog;
use Opis\JsonSchema\Errors\ErrorFormatter;
use Opis\JsonSchema\Validator;
final class ContractValidator
{
private readonly Validator $validator;
public function __construct()
{
$this->validator = new Validator();
$this->validator->resolver()->registerPrefix(EventCatalog::SCHEMA_BASE_URI, EventCatalog::SCHEMA_DIR);
}
public function validate(string $eventName, string $json): void
{
// opis/json-schema attend des objets : pas de tableau associatif.
$result = $this->validator->validate(
json_decode($json, flags: \JSON_THROW_ON_ERROR),
EventCatalog::SCHEMA_BASE_URI . $eventName . '.json',
);
if (!$result->isValid()) {
throw new \UnexpectedValueException(json_encode((new ErrorFormatter())->format($result->error(), false)));
}
}
}
Le service order l'appelle en écrivant sa ligne
d'outbox :
// src/Outbox/OutboxWriter.php (service order), extrait de add()
$envelope = [
'event_id' => $eventId->toRfc4122(),
'event_name' => $eventName,
'occurred_at' => $occurredAt->format(\DateTimeInterface::RFC3339_EXTENDED),
'producer' => 'order',
'schema_version' => $event::SCHEMA_VERSION,
'correlation_id' => $correlationId,
'payload' => $this->normalizer->normalize($event, 'json', [
AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
]),
];
// Un message invalide lève une exception : la transaction est annulée.
$this->contractValidator->validate($eventName, json_encode($envelope, \JSON_THROW_ON_ERROR));
La ligne d'outbox reçoit ensuite cette enveloppe, et le relais la publie sans repasser par le DTO.
5. Un sérialiseur Messenger pour l'enveloppe
Côté consommateur, notre sérialiseur lit l'enveloppe,
retrouve la classe par event_name et la réécrit
lors d'une nouvelle tentative. Deux stamps
readonly, qui implémentent
StampInterface, portent l'enveloppe :
EventIdStamp pour event_id,
EventMetadataStamp pour le reste.
// src/Messenger/EventSerializer.php (stock et shipping)
namespace App\Messenger;
use AppContracts\EventCatalog;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\MessageDecodingFailedException;
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class EventSerializer implements SerializerInterface
{
public function __construct(
private readonly NormalizerInterface $normalizer,
private readonly DenormalizerInterface $denormalizer,
) {
}
public function encode(Envelope $envelope): array
{
$id = $envelope->last(EventIdStamp::class);
$meta = $envelope->last(EventMetadataStamp::class);
if (null === $id || null === $meta) {
throw new \LogicException('Missing event stamps.');
}
$body = [
// Le même event_id à chaque nouvelle tentative : l'idempotence en dépend.
'event_id' => $id->eventId,
'event_name' => $meta->eventName,
'occurred_at' => $meta->occurredAt,
'producer' => $meta->producer,
'schema_version' => $meta->schemaVersion,
'correlation_id' => $meta->correlationId,
// Absence plutôt que null : les champs à null ne sont pas écrits.
'payload' => $this->normalizer->normalize($envelope->getMessage(), 'json', [
AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
]),
];
return [
'body' => json_encode($body, \JSON_THROW_ON_ERROR),
'headers' => [
'Content-Type' => 'application/json',
// Sans ce compteur, chaque nouvelle tentative repartirait de zéro.
'X-Retry-Count' => (string) RedeliveryStamp::getRetryCountFromEnvelope($envelope),
],
];
}
public function decode(array $encodedEnvelope): Envelope
{
try {
$data = json_decode($encodedEnvelope['body'], true, flags: \JSON_THROW_ON_ERROR);
$class = EventCatalog::CLASSES[$data['event_name'] ?? '']
?? throw new \UnexpectedValueException('Unknown event name.');
// Champs inconnus ignorés : comportement par défaut du Serializer.
$message = $this->denormalizer->denormalize($data['payload'], $class, 'json');
$stamps = [
new EventIdStamp($data['event_id']),
new EventMetadataStamp(
$data['event_name'],
$data['occurred_at'],
$data['producer'],
$data['schema_version'],
$data['correlation_id'],
),
];
} catch (\Throwable $e) {
throw new MessageDecodingFailedException($e->getMessage(), 0, $e);
}
$retryCount = (int) ($encodedEnvelope['headers']['X-Retry-Count'] ?? 0);
if ($retryCount > 0) {
$stamps[] = new RedeliveryStamp($retryCount);
}
return new Envelope($message, $stamps);
}
}
Messenger compte les tentatives avec le
RedeliveryStamp : un sérialiseur qui le perd
relance un message en échec sans fin. Et sur une
MessageDecodingFailedException, le transport
AMQP rejette le message sans le remettre en queue. Il est
alors perdu, sauf dead letter exchange sur la queue.
Sérialiseur et stamps dépendent de Messenger : ils vivent
dans chaque service consommateur, pas dans
app-contracts. Côté order, le
relais publie l'enveloppe stockée avec son propre
sérialiseur. Ils s'activent par transport :
# config/packages/messenger.yaml (stock et shipping), extrait
framework:
messenger:
transports:
events:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
serializer: App\Messenger\EventSerializer
6. Figer une fixture par révision
La fixture
app-contracts/tests/fixtures/order.order.placed.v1.0.json
ne bougera plus :
{
"event_id": "01a0f6cc-9dc0-73c6-869a-45b14da4f9fc",
"event_name": "order.order.placed.v1",
"occurred_at": "2026-10-01T09:30:00.000+00:00",
"producer": "order",
"schema_version": "1.0",
"correlation_id": "01a0f6cc-9dc0-7b8a-9ea5-f190656412a9",
"payload": {
"orderId": "01a0f6cc-9dc0-7b8a-9ea5-f190656412a9",
"customerId": "019cf0d4-bdc0-7eaf-b33a-9c7f4a14876a",
"lines": [{ "productId": "01a05c4d-d5c0-7051-a329-665966ceab36", "quantity": "2.000" }]
}
}
// app-contracts/tests/ContractSnapshotTest.php
namespace AppContracts\Tests;
use AppContracts\EventCatalog;
use Opis\JsonSchema\Helper;
use Opis\JsonSchema\Validator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
final class ContractSnapshotTest extends TestCase
{
public static function fixtures(): iterable
{
foreach (glob(__DIR__ . '/fixtures/*.json') as $file) {
yield basename($file) => [$file];
}
}
#[DataProvider('fixtures')]
public function testFrozenFixtureStillFits(string $file): void
{
$validator = new Validator();
$validator->resolver()->registerPrefix(EventCatalog::SCHEMA_BASE_URI, EventCatalog::SCHEMA_DIR);
$serializer = new Serializer([new ObjectNormalizer()]);
$message = json_decode(file_get_contents($file), true, flags: \JSON_THROW_ON_ERROR);
$schema = EventCatalog::SCHEMA_BASE_URI . $message['event_name'] . '.json';
// 1. La fixture valide toujours le schéma.
self::assertTrue($validator->validate(Helper::toJSON($message), $schema)->isValid());
// 2. Elle se désérialise toujours dans le DTO.
$dto = $serializer->denormalize($message['payload'], EventCatalog::CLASSES[$message['event_name']]);
// 3. Le DTO resérialisé redonne le même payload.
$payload = $serializer->normalize($dto, null, [AbstractObjectNormalizer::SKIP_NULL_VALUES => true]);
self::assertEquals($message['payload'], $payload);
// 4. Et il valide toujours le schéma.
$message['payload'] = $payload;
self::assertTrue($validator->validate(Helper::toJSON($message), $schema)->isValid());
}
}
Renommer customerId dans le DTO et le schéma
fait échouer ce test : la fixture 1.0 ne valide plus. Un
champ de fixture oublié dans le DTO le fait échouer à
l'étape 3.
7. Ajouter un champ, puis publier une v2
Pour le mode de livraison, le schéma gagne une propriété
deliveryMode optionnelle. Le DTO passe en
révision 1.1 (SCHEMA_VERSION = '1.1') avec un dernier argument
?string $deliveryMode = null, et les valeurs
connues deviennent des constantes :
// app-contracts/src/Order/DeliveryMode.php
namespace AppContracts\Order;
final class DeliveryMode
{
public const STANDARD = 'standard';
public const EXPRESS = 'express';
}
La fixture order.order.placed.v1.1.json rejoint
la précédente, qui reste intacte. Chez
shipping, une valeur absente ou inconnue
retombe sur le cas par défaut :
// src/MessageHandler/PrepareShipmentOnOrderPlaced.php (service shipping), extrait
$shipment->setPriority(match ($event->deliveryMode) {
DeliveryMode::EXPRESS => Shipment::PRIORITY_HIGH,
default => Shipment::PRIORITY_NORMAL,
});
Un changement cassant suit un autre chemin :
-
Créer le schéma, le DTO, la constante et les fixtures de
la
v2. - Publier les deux versions, avec deux lignes d'outbox dans la même transaction.
-
Lier chaque consommateur à la
v2, handler compris, puis supprimer dans RabbitMQ la binding key de lav1: Messenger n'en retire jamais. Entre-temps, chaque fait arrive deux fois, sous deuxevent_id: le handler écarte le doublon parorderId. -
Retirer la
v1quand aucune queue ne la reçoit plus et qu'aucun messagev1ne peut être rejoué.
Checklist
- Des constantes, des DTO et des schémas partagés ; les queues chez chaque consommateur.
-
Un
event_idposé par le producteur, dans une enveloppe commune. -
Des ajouts optionnels, sans
additionalProperties: false; unev2sinon. -
Des décimales en chaînes, l'absence plutôt que
null, pas d'enum PHP. - La validation chez le producteur, dans la transaction de l'outbox.
-
Un sérialiseur qui conserve
event_idet le compteur de tentatives. - Une fixture figée par révision, testée à chaque build.
Sources
-
JSON Schema 2020-12,
Core
(
additionalProperties,$ref) et Validation (format). - IETF, RFC 3339, section 5.6 et RFC 9562 (UUID v7).
-
opis/json-schema
: version 2.6.0 du 17 octobre 2025,
documentation
et
DateTimeFormats. - justinrainbow/json-schema : version 6.13.1 du 30 septembre 2026.
-
Symfony,
sérialiseur Messenger personnalisé,
SendFailedMessageForRetryListener,AmqpReceiveretConnection. - Symfony, Serializer, UID ; PHP, BackedEnum::from.
-
Composer,
dépôts
pathetCOMPOSER_MIRROR_PATH_REPOS. - Confluent, Schema Evolution and Compatibility.