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 de order des messages sans customerId.
  • Les messages déjà en queue. La nouvelle version de stock lit des messages écrits avant la mise à jour, et n'y trouve pas clientId.
  • 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.

Un package pour les noms et les formes, chaque service garde le reste order producteur publie sur app.events valide chaque message contre son JSON Schema, dans la transaction de l'outbox garde : outbox, ContractValidator app-contracts package Composer partagé Routing : exchange, clés DTO readonly JSON Schemas fixtures figées et tests rien d'autre que PHP stock consommateur lit en ignorant les champs inconnus garde : stock.events , binding keys, handlers shipping consommateur lit en ignorant les champs inconnus garde : shipping.events , binding keys, handlers dépend du package (Composer, dépôt path ) Dans chaque service consommateur, hors du package : le sérialiseur Messenger et ses stamps.

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.0 puis 1.1 aprè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 :

  1. Créer le schéma, le DTO, la constante et les fixtures de la v2.
  2. Publier les deux versions, avec deux lignes d'outbox dans la même transaction.
  3. Lier chaque consommateur à la v2, handler compris, puis supprimer dans RabbitMQ la binding key de la v1 : Messenger n'en retire jamais. Entre-temps, chaque fait arrive deux fois, sous deux event_id : le handler écarte le doublon par orderId.
  4. Retirer la v1 quand aucune queue ne la reçoit plus et qu'aucun message v1 ne peut être rejoué.

Checklist

  • Des constantes, des DTO et des schémas partagés ; les queues chez chaque consommateur.
  • Un event_id posé par le producteur, dans une enveloppe commune.
  • Des ajouts optionnels, sans additionalProperties: false ; une v2 sinon.
  • 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_id et le compteur de tentatives.
  • Une fixture figée par révision, testée à chaque build.

Sources