Validation métier : vérifier les règles dans le service
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 :
- l'existence : les objets nécessaires existent-ils ?
- l'état : sont-ils dans un état qui permet l'opération ?
- les règles : plafonds, quotas, cohérence avec les autres données ;
- 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 à
@Transactionalet à des exceptions qui héritent deRuntimeException, 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
- La forme dans le DTO, les règles métier dans le service ou dans l'objet métier.
- Une exception par règle, avec un nom explicite, un message qui dit quoi et pourquoi, et un code stable.
- Vérifier d'abord, agir ensuite : existence, état, règles, puis action.
- 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.
- Une règle écrite à un seul endroit : si deux services la vérifient, elle finira par diverger.
- Pour l'unicité et les compteurs, la base de données a le dernier mot.
- Un refus métier n'est jamais une réponse 200. Une convention unique pour toute l'API (422 ou 409).
- 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.