Comment je modélise les règles métier avec des exceptions de domaine dans Symfony, en toute sécurité

Illustration de Comment je modélise les règles métier avec des exceptions de domaine dans Symfony, en toute sécurité

Utiliser des exceptions de domaine nommées pour exprimer le rejet de règles métier, annuler les commandes, traverser Symfony Messenger en toute sécurité, produire des réponses de problème RFC 9457 et distinguer les échecs attendus des incidents réels.

Avez-vous déjà lu un service qui renvoie false, null ou une chaîne d’erreur en vous demandant ce qui avait précisément échoué ?

PHP
if (! $subscriptionService->subscribe($user)) {
    return new JsonResponse(['error' => 'Unable to subscribe'], 400);
}

L’utilisateur était-il déjà abonné ? Était-il couvert par une organisation ? Le produit était-il indisponible ? PostgreSQL avait-il échoué ? Le prestataire de paiement avait-il dépassé le délai d’attente ?

L’appelant ne peut pas le savoir. Le langage métier a été réduit à un unique échec technique.

En construisant une plateforme Symfony composée de plusieurs contextes bornés, j’ai choisi de modéliser les règles métier rejetées sous forme d’exceptions de domaine nommées. Une politique ne lève pas RuntimeException('Invalid operation'), mais une exception comme UserAlreadySubscribed, WeakPassword ou EditorialTransitionNotAllowed.

Ces exceptions traversent mon bus applicatif, déclenchent l’annulation de la transaction en base de données, sont journalisées selon leur gravité et deviennent, à la frontière de présentation, des réponses HTTP de problème sûres et traduites.

En tant que cofondateur, ce sujet m’importe, car les exceptions ne sont pas qu’un détail de programmation. Elles définissent la manière dont les règles du produit sont communiquées entre les experts du domaine, les développeurs back-end et front-end, le support et les outils d’observabilité.

Un pipeline d’exceptions totalement dépourvu de risques n’existe pas. Une exception peut divulguer des données sensibles, être absorbée, générer des alertes en double, laisser un état partiel ou exposer comme contrat public un nom de classe instable.

L’objectif n’est donc pas de « lever des exceptions partout ». Il s’agit d’intégrer délibérément l’échec au modèle et de contrôler précisément la façon dont il traverse chaque frontière.

Dans cet article, je présente l’architecture générique que j’utilise et la manière de la reproduire dans un projet Symfony.

Un rejet fait partie du langage du domaine

Prenons l’exigence suivante :

Un client ne peut pas souscrire un abonnement individuel s’il est déjà couvert par l’abonnement d’une organisation.

Le chemin négatif n’est pas un accident d’implémentation. Il fait partie de la règle.

Comparons ces deux API :

PHP
public function subscribe(UserId $userId): bool;
PHP
/** @throws UserAlreadyCoveredByOrganization */
public function ensureCanStartIndividualSubscription(
    UserId $userId,
    DateTimeImmutable $now,
): void;

La seconde API conserve la raison. Le handler peut s’arrêter immédiatement, la transaction peut être annulée et les couches externes peuvent traduire le même rejet sans dupliquer la règle.

Le nom de l’exception rejoint le langage omniprésent :

Mermaid

Tous les « non » ne doivent pas devenir des exceptions

J’utilise délibérément deux styles de politiques.

Une méthode de décision renvoie une valeur lorsque les deux résultats constituent des branches normales :

PHP
final readonly class SubscriptionPolicy
{
    public function hasAccess(UserId $userId, DateTimeImmutable $now): bool
    {
        return $this->subscriptions->hasRunningSubscription($userId, $now)
            || $this->memberships->isCovered($userId, $now);
    }
}

Cette approche est utile pour afficher un paywall, décider quelle action proposer ou sélectionner un workflow.

Une méthode d’assertion lève une exception lorsque l’appelant a déjà choisi une opération et que sa poursuite enfreindrait une règle métier :

PHP
final readonly class SubscriptionPolicy
{
    public function ensureCanStartIndividualSubscription(
        UserId $userId,
        DateTimeImmutable $now,
    ): void {
        if ($this->subscriptions->hasRunningSubscription($userId, $now)) {
            throw SubscriptionRuleViolated::userAlreadySubscribed();
        }

        if ($this->memberships->isCovered($userId, $now)) {
            throw SubscriptionRuleViolated::userAlreadyCoveredByOrganization();
        }
    }
}

Ma convention de nommage rend la différence visible :

  • can…(), has…() et is…() répondent à une question.

  • ensure…() se termine normalement ou lève un rejet précis.

Les exceptions conviennent bien pour interrompre un cas d’usage. Elles sont peu adaptées à une branche ordinaire dans une boucle ou à une question que les appelants posent régulièrement.

Étape 1 : définir un contrat explicite pour les échecs présentés à l’utilisateur

Je n’expose pas chaque DomainException à l’utilisateur. Seules les exceptions qui implémentent un contrat marqueur explicite peuvent être présentées :

PHP
use Throwable;

interface UserFacingError extends Throwable
{
    public function id(): string;

    /** @return array<string, scalar|null> */
    public function params(): array;

    public function translationDomain(): string;
}

Le contrat transporte trois valeurs stables :

  • id est un identifiant d’erreur et de traduction lisible par une machine ;

  • params contient les paramètres scalaires du message traduit ;

  • translationDomain sélectionne le catalogue de messages.

La classe d’exception PHP est un détail d’implémentation interne. L’ID constitue le contrat sémantique stable.

Je fournis ensuite une classe de base pour les rejets métier attendus :

PHP
use DomainException;

abstract class UserFacingDomainException extends DomainException implements UserFacingError
{
    /** @param array<string, scalar|null> $params */
    protected function __construct(
        string $internalMessage,
        private readonly string $id,
        private readonly array $params = [],
        private readonly string $translationDomain = 'messages',
    ) {
        parent::__construct($internalMessage);
    }

    public function id(): string
    {
        return $this->id;
    }

    public function params(): array
    {
        return $this->params;
    }

    public function translationDomain(): string
    {
        return $this->translationDomain;
    }
}

Observez ce qui ne figure pas dans le contrat : un code de statut HTTP.

Le domaine sait qu’une règle d’abonnement a été enfreinte. Il ignore si l’appelant utilise HTTP, la CLI, un worker de messages ou un test. L’association du rejet à 409 Conflict relève de Presentation.

Étape 2 : modéliser une exception par famille d’échecs significative

Évitez une BusinessException universelle dont le message change partout. Elle ne fournit aucun type utile à intercepter ou à tester.

À l’autre extrême, une classe par phrase peut créer des centaines de petits fichiers. Je regroupe souvent les échecs étroitement liés sous un même concept du domaine et utilise des constructeurs nommés :

PHP
final class WeakPassword extends UserFacingDomainException
{
    public static function tooShort(int $minimumLength): self
    {
        return new self(
            sprintf(
                'Password must contain at least %d characters.',
                $minimumLength,
            ),
            'identity.exceptions.weak_password.too_short',
            ['%minimum_length%' => $minimumLength],
        );
    }

    public static function missingUppercase(): self
    {
        return new self(
            'Password must contain at least one uppercase letter.',
            'identity.exceptions.weak_password.missing_uppercase',
        );
    }

    public static function missingNumber(): self
    {
        return new self(
            'Password must contain at least one number.',
            'identity.exceptions.weak_password.missing_number',
        );
    }
}

Les constructeurs nommés sont préférables à ceci :

PHP
throw new WeakPassword('missing_uppercase', []);

Ils rendent les variantes d’échec valides faciles à découvrir, centralisent les identifiants et empêchent les appelants de construire des exceptions partiellement valides.

Pour les transitions d’état, l’exception peut conserver les valeurs pertinentes du domaine :

PHP
final class AccountTransitionNotAllowed extends UserFacingDomainException
{
    public static function lockFrom(AccountStatus $current): self
    {
        return new self(
            sprintf('Cannot lock an account from status "%s".', $current->value),
            'identity.exceptions.account_status.lock_from',
            [
                '%current_status%' => $current->value,
                '%expected_status%' => AccountStatus::Active->value,
            ],
        );
    }
}

Le message interne aide les développeurs et les tests. Le message public provient d’un catalogue de traductions contrôlé.

Étape 3 : lever l’exception depuis l’objet qui possède la règle

Un agrégat possède les invariants relatifs à son propre état :

PHP
final class User
{
    public function lock(DateTimeImmutable $now): void
    {
        if ($this->status === AccountStatus::Locked) {
            return; // This operation is intentionally idempotent.
        }

        if ($this->status !== AccountStatus::Active) {
            throw AccountTransitionNotAllowed::lockFrom($this->status);
        }

        $this->status = AccountStatus::Locked;
        $this->updatedAt = $now;
        $this->recordThat(new UserLocked($this->id, $now));
    }
}

Une politique possède une règle qui nécessite des informations extérieures à un seul agrégat :

PHP
final readonly class UserUniquenessPolicy
{
    public function __construct(
        private UserRepository $users,
        private EmailValidator $emailValidator,
    ) {}

    public function ensureEmailIsAvailable(EmailAddress $email): void
    {
        if (! $this->emailValidator->isLegitimate($email)) {
            throw DisposableEmailNotAllowed::fromEmail($email);
        }

        if ($this->users->findByEmail($email) instanceof User) {
            throw new EmailAlreadyUsed();
        }
    }
}

Ne déplacez pas cette règle dans le contrôleur sous prétexte que HTTP a besoin d’une réponse d’erreur. Elle doit aussi s’appliquer aux commandes console, aux importations, aux messages d’arrière-plan et aux tests.

L’exception naît là où le fait métier est connu, puis se propage vers l’extérieur sans perdre son sens.

Étape 4 : laisser le handler applicatif orchestrer

Le handler de commande ne doit ni traduire l’exception en HTTP ni la remplacer par un échec générique :

PHP
#[AsMessageHandler(bus: 'command.bus')]
final readonly class StartSubscriptionHandler
{
    public function __construct(
        private SubscriptionPolicy $policy,
        private SubscriptionRepository $subscriptions,
        private Clock $clock,
    ) {}

    public function __invoke(StartSubscription $command): void
    {
        $now = $this->clock->now();

        $this->policy->ensureCanStartIndividualSubscription(
            $command->userId,
            $now,
        );

        $subscription = Subscription::start(
            SubscriptionId::new(),
            $command->userId,
            $now,
        );

        $this->subscriptions->save($subscription);
    }
}

Aucun try/catch n’est nécessaire pour une propagation ordinaire. Si la politique rejette la commande, le reste du handler ne s’exécute pas.

L’application obtient ainsi une importante règle d’ordonnancement : valider tout ce qui peut l’être avant de modifier l’état ou d’appeler un système externe.

Étape 5 : rendre la commande transactionnelle par défaut

Une exception n’est sûre que si la persistance partielle est maîtrisée.

Mon bus de commandes exécute les handlers ordinaires dans le middleware de transaction de Doctrine :

YAML
framework:
    messenger:
        buses:
            command.bus:
                middleware:
                    - App\Shared\Infrastructure\Bus\CorrelationMiddleware
                    - App\Shared\Infrastructure\Bus\TransactionMiddleware

Conceptuellement, le middleware procède ainsi :

PHP
$entityManager->wrapInTransaction(function () use ($next, $envelope): void {
    $next->handle($envelope);
});

Si une exception de domaine s’échappe, Doctrine annule les modifications effectuées pendant cette commande. Après l’annulation, l’exception poursuit sa propagation.

Mermaid

Cela protège l’état de la base de données. En revanche, il est impossible d’annuler un e-mail déjà envoyé, un fichier déjà téléversé ou un paiement déjà encaissé. Maintenez les entrées-sorties irréversibles hors de la transaction de base de données, ou utilisez une outbox, des clés d’idempotence et des actions de compensation.

Les commandes qui renoncent volontairement à la transaction par défaut doivent documenter leur gestion des échecs partiels. « Non transactionnel » n’est pas un indicateur de performance : il transfère au cas d’usage la responsabilité de la cohérence.

Étape 6 : extraire l’exception de Symfony Messenger sans perdre l’originale

Symfony Messenger encapsule les échecs des handlers dans HandlerFailedException. Si cette enveloppe atteint Presentation, le type du domaine devient plus difficile à reconnaître et les tests se retrouvent couplés à Messenger.

Mon adaptateur de bus retire les enveloppes imbriquées et relance l’objet d’origine :

PHP
final class SymfonyCommandBus implements CommandBus
{
    use HandleTrait {
        HandleTrait::handle as messengerHandle;
    }

    public function handle(object $command): mixed
    {
        try {
            return $this->messengerHandle($command);
        } catch (HandlerFailedException $failure) {
            $throwable = $this->unwrap($failure);
            $this->failureLogger->command($command, $throwable);

            throw $throwable;
        }
    }

    private function unwrap(HandlerFailedException $failure): Throwable
    {
        $throwable = $failure;

        while (
            $throwable instanceof HandlerFailedException
            && $throwable->getPrevious() instanceof Throwable
        ) {
            $throwable = $throwable->getPrevious();
        }

        return $throwable;
    }
}

Le même modèle d’adaptateur est utilisé pour les requêtes synchrones.

Relancer l’exception d’origine est important. En créer une nouvelle fait perdre l’identité de l’objet, peut tronquer la chaîne causale et compromettre la journalisation unique.

Étape 7 : distinguer les rejets attendus des incidents

Une adresse e-mail déjà utilisée constitue une information métier utile. Un échec de connexion à la base de données est un incident opérationnel. Les deux sont des exceptions, mais ne doivent pas déclencher la même alerte.

Je classe la gravité de manière centralisée :

PHP
final readonly class FailureLogger
{
    public function __construct(
        private LoggerInterface $logger,
        private LoggedExceptionRegistry $registry,
    ) {}

    public function command(object $command, Throwable $throwable): void
    {
        if ($this->registry->contains($throwable)) {
            return;
        }

        $this->registry->mark($throwable);

        $context = [
            'messageClass' => $command::class,
            'exception' => $throwable,
            'exceptionClass' => $throwable::class,
        ];

        if ($throwable instanceof DomainException) {
            $this->logger->warning(
                'Command handling rejected by an expected application rule',
                $context,
            );

            return;
        }

        $this->logger->critical(
            'Command handling failed unexpectedly',
            $context,
        );
    }
}

Attendu ne signifie pas invisible. Une hausse soudaine des conflits d’abonnement ou des transitions éditoriales rejetées peut révéler une friction dans le produit ou un comportement abusif. Je conserve la trace, sans alerter l’ingénieur d’astreinte comme le ferait une panne d’infrastructure.

Le journal contient l’objet exception pour la trace de pile et sa classe pour les recherches structurées. Limitez le contexte supplémentaire aux informations métier pertinentes. Ne journalisez jamais de mots de passe, de jetons d’accès, d’identifiants de paiement, de contenus privés téléversés ou de données personnelles brutes au seul motif qu’ils figuraient dans la commande.

Étape 8 : éviter les journaux en double grâce à l’identité de l’objet

Une commande imbriquée peut traverser plusieurs adaptateurs de bus. Un handler peut ajouter du contexte utile, journaliser l’exception et la relancer. Symfony peut ensuite journaliser à nouveau l’exception finale du kernel.

Sans coordination, un seul échec produit plusieurs alertes.

Je suis les objets exception déjà journalisés pendant la requête :

PHP
final class LoggedExceptionRegistry
{
    /** @var WeakMap<Throwable, true> */
    private WeakMap $exceptions;

    public function __construct()
    {
        $this->exceptions = new WeakMap();
    }

    public function contains(Throwable $throwable): bool
    {
        return isset($this->exceptions[$throwable]);
    }

    public function mark(Throwable $throwable): void
    {
        $this->exceptions[$throwable] = true;
    }
}

WeakMap est utile ici, car ses clés reposent sur l’identité des objets sans maintenir inutilement les exceptions en mémoire.

Un processeur Monolog marque également toute exception déjà présente dans une entrée de journal et normalise sa gravité :

PHP
if ($exception instanceof UserFacingError || $exception instanceof DomainException) {
    return $record->with(level: Level::Warning);
}

return $record->with(level: Level::Critical);

J’obtiens ainsi une règle cohérente, que l’exception ait d’abord été journalisée par un handler, un adaptateur de bus ou le framework.

Étape 9 : traduire en HTTP uniquement les exceptions explicitement sûres

Le contrôleur reste léger :

PHP
#[Route('/subscriptions', methods: ['POST'])]
final class StartSubscriptionController extends AbstractController
{
    #[Serialize(code: Response::HTTP_CREATED)]
    public function __invoke(#[MapRequestPayload] StartSubscriptionInput $input): array
    {
        $this->handleCommand(new StartSubscription(
            userId: $this->currentUserId(),
            productId: new ProductId($input->productId),
        ));

        return [];
    }
}

Un listener unique des exceptions du kernel prend en charge la traduction publique :

PHP
#[AsEventListener(KernelEvents::EXCEPTION)]
final readonly class UserFacingErrorListener
{
    public function __construct(
        private TranslatorInterface $translator,
    ) {}

    public function __invoke(ExceptionEvent $event): void
    {
        if (! $event->isMainRequest()) {
            return;
        }

        $throwable = $this->unwrap($event->getThrowable());

        if (! $throwable instanceof UserFacingError) {
            return;
        }

        $status = $this->statusCode($throwable);
        $detail = $this->translator->trans(
            $throwable->id(),
            $throwable->params(),
            $throwable->translationDomain(),
        );

        $event->setResponse(new JsonResponse([
            'type' => $this->problemType($throwable->id()),
            'title' => Response::$statusTexts[$status] ?? 'Error',
            'status' => $status,
            'detail' => $detail,
            'instance' => $event->getRequest()->getRequestUri(),
        ], $status, [
            'Content-Type' => 'application/problem+json',
        ]));
    }
}

Il s’agit de la principale frontière de divulgation.

Si l’exception n’implémente pas UserFacingError, le listener n’intervient pas. Le gestionnaire d’erreurs de Symfony en production renvoie une réponse générique 500, tandis que les outils d’observabilité conservent la trace de pile interne.

Ne sérialisez jamais dans la réponse $throwable->getTrace(), $throwable->getFile(), les messages de la base de données, les exceptions précédentes ou des propriétés arbitraires de l’exception.

Étape 10 : associer les familles du domaine à la sémantique HTTP

L’exception de domaine ne doit pas transporter de statut HTTP. Le listener associe les familles sémantiques :

PHP
private function statusCode(UserFacingError $error): int
{
    return match (true) {
        $error instanceof HumanVerificationFailed => 403,
        $error instanceof EntityNotFound => 404,
        $error instanceof InvalidInput => 422,
        default => 409,
    };
}

Ma correspondance générique est la suivante :

  • 404 Not Found lorsqu’une entité demandée du domaine n’existe pas ;

  • 422 Unprocessable Content lorsque l’entrée est structurellement valide, mais inacceptable ;

  • 403 Forbidden pour l’échec d’un contrôle de sécurité lorsqu’il est sûr d’en révéler davantage ;

  • 409 Conflict pour une opération métier incompatible avec l’état actuel du domaine ;

  • 500 Internal Server Error pour les échecs techniques inattendus.

Vous pouvez choisir une autre correspondance. La cohérence importe davantage que de prétendre que les codes de statut HTTP peuvent exprimer l’ensemble du domaine. Les champs stables type et detail du problème portent le sens le plus précis.

Pour les autorisations, choisissez avec prudence entre 403 et 404. Renvoyer 404 peut éviter de confirmer l’existence d’une ressource lorsque l’appelant n’est pas autorisé à la connaître.

Étape 11 : traduire les messages publics au lieu d’exposer les messages internes

L’ID de l’exception correspond à une entrée d’un catalogue de traductions :

YAML
identity.exceptions.email_already_used: >-
    This email address is not available.

identity.exceptions.weak_password.too_short: >-
    The password must contain at least %minimum_length% characters.

access.exceptions.user_already_covered_by_organization: >-
    Your organization subscription already covers this account.

La réponse de l’API est un document de problème conforme au format RFC 9457 :

Plain text
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
JSON
{
  "type": "https://api.example.test/problems/access/user-already-covered-by-organization",
  "title": "Conflict",
  "status": 409,
  "detail": "Your organization subscription already covers this account.",
  "instance": "/api/subscriptions"
}

Les ID de traduction présentent plusieurs avantages :

  • la formulation publique peut changer sans modifier le comportement du domaine ;

  • les messages de diagnostic internes peuvent rester précis ;

  • plusieurs langues partagent une même erreur sémantique ;

  • le front-end reçoit une structure de problème cohérente.

Les paramètres doivent être des valeurs scalaires explicitement autorisées. Ne transmettez pas une entité complète, le contenu d’une requête, la réponse d’un fournisseur ou une exception dans les paramètres de traduction.

Si une traduction manque, utilisez un message public générique préparé à l’avance. Le recours à getMessage() n’est sûr que si le contrat UserFacingError garantit que chaque exception qui l’implémente contient un message relu et non sensible.

Étape 12 : traiter le problème en toute sécurité dans le front-end

Le front-end valide le document de problème comme toute autre donnée réseau inconnue :

TypeScript
const apiProblemSchema = z.looseObject({
  type: z.string().optional(),
  title: z.string().optional(),
  status: z.number().optional(),
  detail: z.string().optional(),
  instance: z.string().optional(),
  violations: z
    .array(
      z.object({
        propertyPath: z.string(),
        title: z.string(),
      }),
    )
    .optional(),
});

export function getErrorMessage(
  error: unknown,
  fallback = "Unable to complete the request.",
) {
  if (!isAxiosError(error)) {
    return fallback;
  }

  const result = apiProblemSchema.safeParse(error.response?.data);

  return result.success
    ? result.data.detail ?? fallback
    : fallback;
}

Une mutation peut ensuite afficher le message sûr du back-end :

TypeScript
useMutation({
  mutationFn: startSubscription,
  onError(error) {
    toast.error(getErrorMessage(error));
  },
});

Ne conditionnez pas le comportement du produit en comparant des phrases traduites. Si le front-end doit réagir différemment — ouvrir un écran de mise à niveau, rediriger vers la connexion ou mettre un champ en évidence — exposez un type de problème ou un ID d’erreur stable et lisible par une machine.

Étape 13 : n’intercepter que lorsque vous pouvez changer le résultat

La plupart des exceptions de domaine doivent se propager. Leur interception est appropriée lorsque la couche actuelle peut agir de manière utile.

Rendre une opération idempotente

La suppression d’une recherche récente absente peut être considérée comme réussie :

PHP
try {
    $recentSearch = $this->recentSearches->get($command->id);
    $this->recentSearches->remove($recentSearch);
} catch (RecentSearchNotFound) {
    // The requested final state already exists.
}

Il s’agit d’une décision produit, et non d’une règle générale selon laquelle les exceptions d’absence doivent être ignorées.

Traduire un échec de l’infrastructure

Un adaptateur peut préserver la cause tout en renvoyant un échec au niveau applicatif :

PHP
try {
    $profile = $provider->fetchProfile($authorizationCode);
} catch (ProviderSdkException $previous) {
    throw FederatedProviderAuthenticationFailed::fromProvider($previous);
}

L’application ne dépend plus du type du SDK du fournisseur, et les journaux conservent la chaîne causale.

Renvoyer un résultat propre au protocole

Un callback OAuth peut devoir rediriger au lieu de renvoyer du JSON. Son handler peut convertir les échecs connus en un code d’erreur choisi dans une courte liste autorisée, tout en journalisant les échecs inattendus en interne.

Ajouter du contexte et relancer

Si un handler connaît des identifiants qui amélioreront réellement le diagnostic, il peut les journaliser puis relancer le même objet :

PHP
} catch (Throwable $throwable) {
    $this->logger->critical('Unable to update policy document', [
        'documentId' => (string) $command->documentId,
        'exception' => $throwable,
        'exceptionClass' => $throwable::class,
    ]);

    throw $throwable;
}

Le registre de journalisation unique empêche le bus de le journaliser à nouveau.

N’interceptez pas une exception uniquement pour écrire throw $exception;, renvoyer false ou remplacer une exception de domaine précise par SomethingWentWrong.

Lorsque vous interceptez des échecs techniques, capturez Throwable, et pas seulement Exception, si l’objectif est le nettoyage, la journalisation ou la traduction à la frontière de tous les échecs PHP. Relancez toujours, sauf si le bloc catch prend délibérément en charge la récupération.

Étape 14 : rendre volontairement vagues les rejets sensibles à la sécurité

Toutes les raisons réelles ne doivent pas parvenir à l’appelant.

Lors de l’authentification, tous les états internes suivants peuvent produire la même réponse publique :

  • l’adresse e-mail n’existe pas ;

  • le mot de passe est incorrect ;

  • le compte est archivé ;

  • le compte est verrouillé ;

  • l’identité fédérée n’est pas connectée.

Renvoyer l’exception exacte créerait un canal d’énumération des comptes.

Le domaine peut toujours modéliser des échecs précis pour l’audit et les décisions internes, tandis que la frontière d’authentification les associe à un unique message public :

JSON
{
  "type": "https://api.example.test/problems/authentication-failed",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Invalid credentials."
}

Une gestion sûre des exceptions consiste à décider quelles distinctions concernent l’utilisateur et lesquelles doivent rester dans les journaux.

Appliquez le même raisonnement aux contrôles de propriété, à la réinitialisation des mots de passe, aux invitations, aux paiements, à la visibilité des documents et aux limites de débit.

Étape 15 : tenir compte de la concurrence et des effets de bord externes

Une politique d’unicité effectue généralement une vérification avant la création :

PHP
if ($this->users->findByEmail($email) instanceof User) {
    throw new EmailAlreadyUsed();
}

Deux requêtes concurrentes peuvent toutes deux réussir cette vérification. La base de données doit tout de même imposer une contrainte d’unicité :

SQL
CREATE UNIQUE INDEX uniq_users_email ON users (email);

La politique exprime l’intention et donne un nom clair au conflit normal. La contrainte élimine la condition de concurrence.

Si une expérience utilisateur cohérente dans cette situation est importante, interceptez uniquement la violation connue de la contrainte d’unicité à la frontière de persistance ou d’application, puis traduisez-la en EmailAlreadyUsed. Ne convertissez pas chaque exception de base de données en conflit du domaine.

De même, l’annulation d’une transaction en base de données ne peut pas retirer un paiement externe ni un e-mail. Utilisez le modèle de fiabilité approprié :

  • une outbox transactionnelle pour les événements et l’envoi d’e-mails ;

  • des clés d’idempotence pour les paiements ou les requêtes aux fournisseurs ;

  • des gestionnaires de transaction explicites autour de l’unité qui modifie l’état ;

  • une compensation lorsqu’un effet de bord externe ne peut pas être différé ;

  • des politiques de nouvelle tentative fondées sur le type d’échec.

Pour les handlers Messenger asynchrones, une exception ne devient plus une réponse HTTP. Déterminez si chaque échec peut être retenté, est irrécupérable ou doit être déplacé vers un transport d’échec. Retenter trois fois un rejet attendu du domaine est rarement utile.

Étape 16 : tester les rejets comme des comportements à part entière

L’architecture des exceptions nécessite des tests à plusieurs niveaux.

Tester unitairement le vocabulaire de la politique

PHP
#[DataProvider('weakPasswords')]
public function testItRejectsWeakPasswords(
    string $password,
    string $expectedErrorId,
): void {
    try {
        $this->policy->ensureSatisfiedBy(new PlainPassword($password));
    } catch (WeakPassword $error) {
        self::assertSame($expectedErrorId, $error->id());

        return;
    }

    self::fail('Weak password was accepted.');
}

Testez la variante exacte, l’ID et les paramètres lorsqu’ils font partie du contrat.

Tester à travers le cas d’usage applicatif

Plain text
Scenario: Rejecting a registration with an email already used
    Given a registered user exists with email "jane@example.test"
    When another user registers with email "jane@example.test"
    Then registration should be rejected because the email is already used
    And no second account should be registered

Cela prouve que le handler invoque réellement la politique et que la transaction ne laisse aucun état partiel.

Tester l’adaptateur de bus

Vérifiez qu’un échec de handler encapsulé est extrait, journalisé une seule fois puis relancé sous la forme du même objet exception.

Tester la frontière HTTP

Pour chaque famille sémantique, vérifiez le statut, le type de contenu, le type de problème stable, le détail traduit et l’absence de trace de pile ou de champs privés.

Testez également une RuntimeException inattendue et confirmez que la réponse de production est générique tandis que le journal interne est critique.

Tester la classification d’observabilité

Un rejet attendu du domaine doit produire un avertissement. Un échec inattendu de l’infrastructure doit être critique. Une exception déjà journalisée ne doit pas créer une autre entrée.

Erreurs fréquentes de modélisation des exceptions

Lever des exceptions génériques

throw new RuntimeException('Invalid operation') supprime la raison métier et rend impossible toute traduction sûre.

Placer les codes de statut HTTP dans le domaine

Le domaine exprime la sémantique métier. Presentation décide de la sémantique HTTP.

Exposer toutes les exceptions de domaine

Certains échecs révèlent l’existence d’un compte, sa propriété, l’état interne d’un workflow ou une politique de sécurité. Exigez un marqueur explicite pour les erreurs présentées à l’utilisateur, puis relisez son message et ses paramètres.

Intercepter dans chaque handler

N’interceptez une exception que pour la récupération, la traduction, la compensation, la conversion de protocole, le nettoyage ou l’ajout d’un contexte réellement utile. Sinon, laissez l’échec se propager.

Journaliser les rejets attendus comme critiques

Cela crée une fatigue liée aux alertes et masque les incidents réels. Conservez la télémétrie des rejets attendus, mais classez-la séparément.

Journaliser la même exception à chaque couche

Journalisez une seule fois avec le meilleur contexte disponible. Préservez le même objet lors de la relance et utilisez une déduplication limitée à la requête lorsque plusieurs frontières peuvent l’observer.

Compter sur une vérification de politique pour garantir l’unicité

Des conditions de concurrence entre vérification et insertion existent. Garantissez l’unicité avec une contrainte de base de données.

Renvoyer les messages traduits comme codes machine

Les formulations changent. Utilisez un type de problème ou un ID d’erreur stable pour les décisions du front-end.

Retenter chaque exception

Les dépassements de délai techniques peuvent justifier une nouvelle tentative. Ce n’est pas le cas d’un mot de passe faible ou d’une transition d’état interdite.

Absorber Throwable

Si le bloc catch ne prend pas délibérément en charge la récupération, relancez l’exception. Un échec silencieux est généralement plus risqué que sa propagation.

Conclusion

Les exceptions de domaine fonctionnent bien lorsqu’elles sont traitées comme un modèle d’échec conçu délibérément, et non comme une échappatoire.

  • Les politiques renvoient des booléens pour les décisions normales et lèvent des exceptions depuis les méthodes ensure…() lorsqu’une opération choisie enfreint une règle.

  • Les agrégats lèvent des exceptions nommées pour les transitions d’état invalides.

  • Un marqueur destiné à l’utilisateur contrôle explicitement quels échecs peuvent quitter le back-end.

  • Des ID d’erreur stables et des paramètres scalaires séparent le sens métier de la formulation publique.

  • Les handlers applicatifs orchestrent et laissent normalement les échecs précis se propager.

  • Le middleware de transaction annule les modifications de la base de données lorsqu’une commande échoue.

  • Les adaptateurs de bus extraient les échecs de Messenger et préservent l’exception d’origine.

  • Les rejets attendus sont journalisés comme avertissements ; les échecs inattendus sont critiques.

  • Un registre WeakMap empêche la journalisation répétée d’une même exception.

  • Un listener Symfony unique traduit les exceptions sûres en réponses de problème RFC 9457 cohérentes.

  • Les exceptions inattendues restent privées et deviennent des réponses génériques 500.

  • Les workflows sensibles à la sécurité regroupent délibérément les raisons internes dans des messages publics plus sûrs.

  • Les contraintes de base de données, l’idempotence, les outboxes et les politiques de nouvelle tentative traitent les risques que les exceptions seules ne peuvent résoudre.

La règle la plus importante est simple : laissez le domaine nommer l’échec, mais laissez chaque frontière externe décider de ce qu’elle est autorisée à faire de cette information.

J’obtiens ainsi des politiques expressives, des commandes atomiques, des journaux utiles, des erreurs front-end prévisibles et un risque bien plus faible de divulguer le mauvais détail au mauvais public.

Bon développement !

Articles liés