Schéma PostgreSQL

CREATE TABLE command_inbox (
consumer_group text NOT NULL,
command_id uuid NOT NULL, clé stable fournie par le producteur
command_type text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,

PRIMARY KEY (consumer_group, command_id)
);

Traitement transactionnel

BEGIN;

INSERT INTO command_inbox (
consumer_group,
command_id,
command_type,
payload
)
VALUES (
:consumer_group,
:command_id,
:command_type,
:payload
)
ON CONFLICT (consumer_group, command_id) DO NOTHING
RETURNING command_id;

Si aucune ligne n'est retournée, la commande a déjà été traitée : COMMIT, puis ACK.
Sinon, exécuter l'effet métier dans la même transaction :

UPDATE account
SET balance = balance - :amount
WHERE account_id = :account_id;

UPDATE command_inbox
SET processed_at = now()
WHERE consumer_group = :consumer_group
AND command_id = :command_id;

COMMIT;

Envoyer l’ACK au courtier uniquement après le COMMIT.

Ainsi :

crash avant COMMIT : l’effet métier et l’inbox sont annulés, puis la commande est rejouée ;
crash après COMMIT mais avant ACK : la commande est rejouée, mais la clé primaire bloque le second effet ;
traitements concurrents : la contrainte unique arbitre la déduplication.

La clé doit être un command_id stable réutilisé lors des republications, et non un identifiant de livraison généré par le courtier. Pour un effet externe ou l’émission d’un autre message, ajouter une outbox écrite dans cette même transaction.