Validation métier : vérifier les règles dans le service

Publié le · Mis à jour le

L'idée en une phrase

La validation du DTO vérifie que les données sont bien formées ; la validation métier vérifie qu'elles sont acceptables pour l'application : le produit existe, le stock suffit, la commande peut encore être annulée. Ces règles ont besoin de la base de données ou de l'état de l'application, donc elles vivent dans le service (ou dans l'objet métier lui-même), et elles se traduisent par des exceptions métier.

Une image pour comprendre : le service instructeur

Reprenons l'administration de la fiche sur la validation des DTO. Le guichet a vérifié que ton formulaire était bien rempli. Ton dossier part maintenant au service instructeur, qui vérifie autre chose :

  • ton dossier existe-t-il ? On ne peut pas modifier une demande qui n'a jamais été déposée ;
  • dans quel état est-il ? Une demande déjà acceptée ne peut plus être retirée ;
  • respecte-t-il les règles ? Le montant demandé ne dépasse pas le plafond, tu n'as pas déjà une demande en cours.

Pour répondre, l'instructeur consulte ses dossiers : il ne peut pas décider en regardant seulement le formulaire. Et s'il refuse, il te dit pourquoi, avec un motif précis.

C'est le rôle de la validation métier.

Validation de forme ou règle métier ?

Vérification Type Où ?
le nom est renseigné, l'e-mail a le bon format forme DTO (voir la fiche sur la validation des DTO)
le client existe métier service
le client est actif métier service, ou objet métier
la commande n'est pas déjà expédiée métier objet métier
le montant ne dépasse pas le plafond du client métier service
le stock est suffisant métier base de données (voir la fiche sur la concurrence)
l'e-mail n'est pas déjà utilisé métier service + contrainte en base

La question à se poser : « Est-ce que je peux répondre en regardant seulement le DTO ? » Si oui, c'est de la forme. Si j'ai besoin de la base de données, de l'heure qu'il est ou de l'état d'un objet, c'est une règle métier.

Pourquoi dans le service, et pas dans le contrôleur ?

Le contrôleur n'est qu'une porte d'entrée. La même opération peut être déclenchée par :

  • une requête HTTP ;
  • un traitement planifié (@Scheduled) ;
  • un message reçu d'une autre application ;
  • un import de fichier.

Si la règle « on ne commande pas au-delà du plafond » est écrite dans le contrôleur, l'import de fichier la contourne. Dans le service, toutes les portes passent par la même règle.

Le contrôleur reçoit, valide la forme et délègue. Le service décide.

Signaler un refus : une exception métier

Une règle métier non respectée n'est pas un bug : c'est un refus normal, qu'il faut expliquer. La bonne façon de le signaler en Spring Boot, c'est une exception métier (voir la fiche sur les exceptions).

Une classe de base pour toutes les règles métier

public abstract class RegleMetierException extends RuntimeException {

    private final String code;

    protected RegleMetierException(String code, String message) {
        super(message);
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

Chaque exception porte deux informations :

  • un message lisible par un humain, pour les logs et l'affichage ;
  • un code stable (PLAFOND_DEPASSE), pour le front-end.

Une exception par règle

public class PlafondDepasseException extends RegleMetierException {

    public PlafondDepasseException(BigDecimal montant, BigDecimal plafond) {
        super("PLAFOND_DEPASSE",
                "Le montant de la commande (" + montant + " €) dépasse le plafond autorisé (" + plafond + " €)");
    }
}
public class ClientInactifException extends RegleMetierException {

    public ClientInactifException(Long clientId) {
        super("CLIENT_INACTIF", "Le client " + clientId + " est inactif et ne peut pas commander");
    }
}

Le nom de la classe dit déjà quelle règle n'est pas respectée. Dans les logs, PlafondDepasseException est immédiatement compréhensible ; IllegalStateException beaucoup moins.

Astuce : le message répond à « quoi, sur quoi, et pourquoi ». « Le montant de la commande (1 250 €) dépasse le plafond autorisé (1 000 €) » permet de comprendre le refus sans ouvrir le code.

Le cas « introuvable » à part

Une ressource qui n'existe pas n'est pas tout à fait une règle métier : c'est une adresse qui ne mène nulle part. On lui donne en général sa propre famille, qui donnera une 404 :

public class RessourceIntrouvableException extends RuntimeException {

    public RessourceIntrouvableException(String type, Object id) {
        super(type + " introuvable : " + id);
    }
}

Écrire les règles dans le service

Vérifier d'abord, agir ensuite

@Service
public class CommandeService {

    private static final BigDecimal PLAFOND = new BigDecimal("1000.00");

    private final ClientRepository clientRepository;
    private final CommandeRepository commandeRepository;

    // constructeur...

    @Transactional
    public CommandeDto commander(Long clientId, CreationCommandeDto dto) {
        // 1. Existence
        Client client = clientRepository.findById(clientId)
                .orElseThrow(() -> new RessourceIntrouvableException("Client", clientId));

        // 2. État
        if (!client.estActif()) {
            throw new ClientInactifException(clientId);
        }

        // 3. Règles
        BigDecimal montant = calculerMontant(dto);
        if (montant.compareTo(PLAFOND) > 0) {
            throw new PlafondDepasseException(montant, PLAFOND);
        }

        // 4. Action : on n'arrive ici que si tout est valide
        Commande commande = Commande.creer(client, dto.lignes(), montant);
        commandeRepository.save(commande);
        return CommandeDto.depuis(commande);
    }
}

L'ordre est toujours le même :

  1. l'existence : les objets nécessaires existent-ils ?
  2. l'état : sont-ils dans un état qui permet l'opération ?
  3. les règles : plafonds, quotas, cohérence avec les autres données ;
  4. l'action, seulement à la fin.

Chaque vérification en échec arrête immédiatement la méthode. Le code qui suit n'a plus à se demander si les données sont valides : on le lit de haut en bas, sans if imbriqués.

Note : grâce à @Transactional et à des exceptions qui héritent de RuntimeException, si une règle échoue après une première modification, la transaction est annulée (voir la fiche sur les exceptions). Mais vérifier avant d'agir reste plus simple à lire et à tester.

Une règle sur l'état d'un objet : dans l'objet lui-même

Certaines règles ne concernent que l'objet et son état : « une commande expédiée ne peut plus être annulée ». Elles ont leur place dans la classe de l'objet, à côté des données qu'elles protègent :

public class Commande {

    private Statut statut;

    public void annuler() {
        if (statut == Statut.EXPEDIEE || statut == Statut.LIVREE) {
            throw new CommandeNonAnnulableException(id, statut);
        }
        statut = Statut.ANNULEE;
    }
}
@Transactional
public void annuler(Long commandeId) {
    Commande commande = commandeRepository.findById(commandeId)
            .orElseThrow(() -> new RessourceIntrouvableException("Commande", commandeId));
    commande.annuler(); // ✅ la règle est dans la commande
}

L'intérêt : impossible de contourner la règle. Si un autre service veut annuler une commande, il passe aussi par annuler(). Alors qu'avec un setStatut(ANNULEE) accessible partout, chaque développeur doit se souvenir de vérifier l'état avant.

La règle a besoin de… Elle va…
seulement des données de l'objet dans l'objet (commande.annuler())
d'autres objets ou de la base (un repository) dans le service

Astuce : méfie-toi des setStatut(...) publics. Une méthode qui porte un nom métier (annuler(), expedier(), payer()) vérifie la règle et documente ce qu'on a le droit de faire.

Une règle qui dépend d'autres données : attention à la concurrence

Certaines règles comparent avec d'autres données en base : « l'e-mail n'est pas déjà utilisé », « il reste du stock ». Les vérifier en Java avec un if laisse une fenêtre entre la lecture et l'écriture, où une autre requête peut passer (voir la fiche sur la concurrence).

if (utilisateurRepository.existsByEmail(dto.email())) {
    throw new EmailDejaUtiliseException(dto.email()); // ✅ un message clair dans le cas normal
}
utilisateurRepository.save(new Utilisateur(dto.email()));

Le if donne un message clair dans le cas courant. Mais la vraie protection reste dans la base : une contrainte d'unicité, ou un calcul fait directement dans la requête SQL pour un stock. Garde les deux.

Transformer les refus en réponses HTTP

Dans le gestionnaire global (voir la fiche sur les exceptions), une seule méthode suffit pour toutes les règles métier, grâce à la classe de base :

@RestControllerAdvice
public class GestionnaireErreurs {

    @ExceptionHandler(RessourceIntrouvableException.class)
    public ProblemDetail introuvable(RessourceIntrouvableException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); // 404
    }

    @ExceptionHandler(RegleMetierException.class)
    public ProblemDetail regleMetier(RegleMetierException e) {
        ProblemDetail probleme = ProblemDetail.forStatusAndDetail(
                HttpStatusCode.valueOf(422), e.getMessage());          // 422
        probleme.setProperty("code", e.getCode());
        return probleme;
    }

    // ... validation des DTO, erreurs inattendues
}

La réponse :

{
  "status": 422,
  "detail": "Le montant de la commande (1250.00 €) dépasse le plafond autorisé (1000.00 €)",
  "code": "PLAFOND_DEPASSE"
}

Le front-end s'appuie sur le code pour décider quoi afficher, jamais sur le message, qui peut être reformulé à tout moment.

Quel code HTTP ?

Situation Code
la forme des données est invalide (DTO) 400 Bad Request
la ressource n'existe pas 404 Not Found
la requête est bien formée, mais une règle métier la refuse 422 Unprocessable Content
conflit avec l'état actuel (modification concurrente, doublon) 409 Conflict

Note : selon les équipes, les refus métier sont renvoyés en 422 ou en 409. Les deux se défendent. L'important est que toute l'API suive la même convention : renseigne-toi sur celle de ton projet.

Tester les règles métier

Chaque règle se teste dans les deux sens : elle laisse passer ce qui est valide et elle refuse ce qui ne l'est pas. Avec l'injection par constructeur (voir la fiche sur l'injection de dépendances), ces tests n'ont pas besoin de Spring :

@Test
void refuse_une_commande_au_dela_du_plafond() {
    when(clientRepository.findById(1L)).thenReturn(Optional.of(clientActif()));
    CreationCommandeDto dto = commandeDe(new BigDecimal("1250.00"));

    assertThatThrownBy(() -> service.commander(1L, dto))
            .isInstanceOf(PlafondDepasseException.class);

    verify(commandeRepository, never()).save(any()); // ✅ rien n'a été enregistré
}

@Test
void accepte_une_commande_egale_au_plafond() {
    when(clientRepository.findById(1L)).thenReturn(Optional.of(clientActif()));
    CreationCommandeDto dto = commandeDe(new BigDecimal("1000.00"));

    service.commander(1L, dto);

    verify(commandeRepository).save(any());
}

Le deuxième test vérifie la limite exacte : le plafond est-il autorisé ou non ? C'est souvent là que se cachent les bugs (un > à la place d'un >=).

Et les règles placées dans l'objet métier se testent encore plus simplement, sans aucun faux objet :

@Test
void une_commande_expediee_ne_peut_pas_etre_annulee() {
    Commande commande = commandeAvecStatut(Statut.EXPEDIEE);

    assertThatThrownBy(commande::annuler)
            .isInstanceOf(CommandeNonAnnulableException.class);
}

Ce qu'il ne faut pas faire

public boolean commander(...) {       // ❌ true / false : l'appelant ne sait pas pourquoi
    if (...) return false;
}

public String commander(...) {        // ❌ un message d'erreur renvoyé comme résultat
    if (...) return "Plafond dépassé";
}

@PostMapping("/commandes")
public ResponseEntity<?> commander(...) {
    if (client.estInactif()) {        // ❌ la règle est dans le contrôleur : l'import de fichier la contourne
        return ResponseEntity.badRequest().build();
    }
}

try {
    service.commander(...);
} catch (RegleMetierException e) {
    return ResponseEntity.ok(new Reponse(false, e.getMessage())); // ❌ un refus renvoyé avec un code 200
}

Le dernier cas est fréquent : l'API répond « 200 OK » à une opération refusée. Les outils de supervision ne voient aucune erreur, et chaque client de l'API doit lire le corps de la réponse pour savoir si ça a marché.

Les bonnes habitudes

  1. La forme dans le DTO, les règles métier dans le service ou dans l'objet métier.
  2. Une exception par règle, avec un nom explicite, un message qui dit quoi et pourquoi, et un code stable.
  3. Vérifier d'abord, agir ensuite : existence, état, règles, puis action.
  4. Les règles sur l'état d'un objet dans l'objet lui-même, derrière une méthode au nom métier plutôt qu'un setter.
  5. Une règle écrite à un seul endroit : si deux services la vérifient, elle finira par diverger.
  6. Pour l'unicité et les compteurs, la base de données a le dernier mot.
  7. Un refus métier n'est jamais une réponse 200. Une convention unique pour toute l'API (422 ou 409).
  8. Teste chaque règle dans les deux sens, et à sa limite exacte.

À retenir

  • La validation métier répond à : « ces données sont-elles acceptables pour l'application ? ». Elle a besoin de la base ou de l'état des objets.
  • Elle vit dans le service, ou dans l'objet métier quand elle ne concerne que son état. Jamais dans le contrôleur.
  • Un refus se signale par une exception métier explicite, avec un message et un code.
  • Le gestionnaire global transforme ces exceptions en 404 ou 422 (ou 409), avec le code pour le front-end.
  • Les règles qui comparent avec d'autres données sont protégées en dernier recours par la base.
  • Chaque règle se teste quand elle accepte, quand elle refuse, et à sa limite.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note