Systeme d'alarme IoT evenementiel : hexagonal, CQRS, Kafka

Backend d'un systeme de video-surveillance dont le capteur est un iPhone
recycle. Le probleme est simple, la structure est le sujet :

- domain/ sans aucun import de framework (verifie par 7 regles ArchUnit
  qui tournent aussi pendant le docker build)
- CQRS strict : write et read ne partagent ni modele ni port
- AlarmDecision en sealed interface -> switch exhaustif verifie a la
  compilation, plutot qu'une chaine de if
- Kafka en KRaft (Zookeeper est supprime depuis Kafka 4.0), cle de
  partition = deviceId pour garantir l'ordre par capteur
- ErrorHandlingDeserializer + DLQ : un message malforme ne bloque pas
  le flux des evenements suivants
- notification WebSocket differee apres commit, pour ne jamais afficher
  une intrusion qui partirait en rollback

31 tests au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-12 17:32:45 +02:00
commit 63ead8a966
81 changed files with 5051 additions and 0 deletions

View file

@ -0,0 +1,79 @@
package me.belkacemi.iotalarm.presentation.advice;
import me.belkacemi.iotalarm.domain.exception.DomainException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.util.stream.Collectors;
/**
* Traduit les exceptions en reponses HTTP, au format RFC 7807 (ProblemDetail).
*
* <p>C'est ici, et nulle part ailleurs, que le domaine rencontre HTTP. Une
* {@link DomainException} ne sait pas ce qu'est un code 409 - et c'est
* precisement ce qui permet de la lever depuis l'agregat sans le coupler au web.</p>
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/**
* Regle metier violee : armer une alarme deja armee, acquitter deux fois.
* 409 et non 400 - la requete est bien formee, c'est l'etat qui s'y oppose.
*/
@ExceptionHandler(DomainException.class)
public ProblemDetail onDomainException(DomainException exception) {
log.info("Regle metier refusee : {}", exception.getMessage());
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, exception.getMessage());
problem.setTitle("Regle metier violee");
problem.setType(URI.create("urn:iot-alarm:domain-rule-violation"));
return problem;
}
/** Donnee invalide a la frontiere : type de capteur inconnu, deviceId malforme. */
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail onIllegalArgument(IllegalArgumentException exception) {
log.info("Requete rejetee : {}", exception.getMessage());
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, exception.getMessage());
problem.setTitle("Requete invalide");
problem.setType(URI.create("urn:iot-alarm:invalid-request"));
return problem;
}
/** Echec de Bean Validation : champs manquants ou hors bornes. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail onValidationError(MethodArgumentNotValidException exception) {
String details = exception.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + " : " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, details);
problem.setTitle("Validation echouee");
problem.setType(URI.create("urn:iot-alarm:validation-failed"));
return problem;
}
/**
* Filet de securite. On journalise la stacktrace mais on ne la renvoie pas :
* l'endpoint d'ingestion est expose publiquement.
*/
@ExceptionHandler(Exception.class)
public ProblemDetail onUnexpectedError(Exception exception) {
log.error("Erreur inattendue", exception);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"Une erreur interne est survenue.");
problem.setTitle("Erreur interne");
return problem;
}
}