Dans un service Spring Boot rangé par couches techniques (controller,
service, repository, entity…), la règle métier finit à côté du
code JPA, Kafka et Jackson. Pour la tester, il faut démarrer Spring, ou
simuler une bonne partie du framework.
Cet article montre une autre organisation, l’architecture hexagonale,
sur un petit exemple complet : un service qui lit des mesures de
capteurs dans Kafka, les vérifie et calcule des statistiques. Le
domaine et les cas d’usage sont en Java pur, Spring, Kafka et JPA
restent dans des adaptateurs, et un test fait échouer le build si cette
règle n’est pas respectée. Le code est dans le tag
phase-1
du dépôt public iot-monitoring-platform, présenté dans l’article
Kafka et Spring Boot : un projet complet en local.
L’idée en bref
L’architecture hexagonale vient d’un article d’Alistair Cockburn (2005), qui l’appelle aussi « Ports and Adapters ». L’idée tient en trois mots :
- le cœur (le domaine et les cas d’usage) contient les règles de l’application, en code simple, sans framework ;
- les ports sont des interfaces : ce que le cœur offre (ports d’entrée) et ce dont il a besoin (ports de sortie) ;
- les adaptateurs relient les ports au monde réel : un contrôleur REST, un listener Kafka, un repository JPA.
Les dépendances pointent toujours vers l’intérieur : un adaptateur connaît le cœur, le cœur ne connaît aucun adaptateur.
Le service de l’exemple, iot-backend-processing, est volontairement
petit : il reçoit une ligne de texte depuis Kafka (par exemple
sensor-1 TEMPERATURE 21.5), la vérifie avec une règle métier, stocke
le message, met à jour les statistiques du capteur, et les rend par une
API REST. C’est assez pour voir chaque partie de l’architecture, sans
bruit autour.
Avant, après
Avant ce travail, le service était rangé par couches techniques
(historique public du projet, commit 82b4054) :
processing/
BkProcessingApplication.java
config/ CorsConfig, KafkaConsumerConfig
controller/ StatsController
dto/ SensorEvent, SensorEventEnvelope, SensorStats
entity/ SensorReading
kafka/ SensorEventConsumer
repository/ SensorReadingRepository
service/ AggregationService
util/ PodInfo
Dans le tag phase-1 :
processing/
ProcessingApplication.java
domain/ SensorReading, ParseResult, SensorStatistics,
SensorType, ReceivedMessage, MessageStatus
application/
port/in/ StoreMessageUseCase, ListLastMessagesUseCase,
ListStatisticsUseCase, InvalidLimitException
port/out/ MessageRepository, StatisticsRepository
StoreMessageService, ListLastMessagesService, ListStatisticsService
adapter/
in/kafka/ SensorMessageListener
in/web/ MessageController, StatisticsController + réponses
out/persistence/ entités JPA, repositories Spring Data, adaptateurs
config/ ProcessingConfig
Les deux versions n’ont pas les mêmes fonctions : l’ancienne propageait
des infos de pod et des enveloppes JSON, sans règle métier ; la
nouvelle lit et vérifie chaque ligne. On ne compare donc pas le nombre
de classes, seulement la forme. Avant, le nom d’un package disait une
technologie (kafka, repository) ; maintenant, il dit un rôle.
Le domaine : la règle, en Java pur
Une ligne valide a trois parties : un capteur, un type et une valeur
dans la plage de son type. La règle est dans SensorReading.parse :
public static ParseResult parse(String text) {
if (text == null || text.isBlank()) {
return new ParseResult.Rejected("empty line");
}
String[] parts = text.strip().split("\\s+");
if (parts.length != 3) {
return new ParseResult.Rejected("a line needs 3 parts: sensor, type and value");
}
String sensorId = parts[0];
if (!SENSOR_ID.matcher(sensorId).matches()) {
return new ParseResult.Rejected("invalid sensor id " + sensorId);
}
Optional<SensorType> type = SensorType.fromName(parts[1]);
if (type.isEmpty()) {
return new ParseResult.Rejected("unknown type " + parts[1]);
}
if (!DECIMAL.matcher(parts[2]).matches()) {
return new ParseResult.Rejected("invalid value " + parts[2]);
}
double value = Double.parseDouble(parts[2]);
if (!type.get().accepts(value)) {
return new ParseResult.Rejected(type.get().name() + " must be between "
+ type.get().min() + " and " + type.get().max());
}
return new ParseResult.Valid(new SensorReading(sensorId, type.get(), value));
}
Pour un service qui reçoit des données de capteurs, une ligne invalide
est un cas normal, pas une erreur du programme. La méthode ne lance donc
pas d’exception : elle rend un résultat, l’un des deux cas d’une
interface sealed (Java 17) :
public sealed interface ParseResult {
record Valid(SensorReading reading) implements ParseResult {
}
record Rejected(String reason) implements ParseResult {
}
}
Le compilateur connaît tous les cas : un switch sur un ParseResult
(pattern matching pour switch, Java 21) doit traiter Valid et
Rejected, sinon il ne compile pas.
Les statistiques d’un capteur sont un record immuable. Chaque mesure donne de nouvelles statistiques, sans relire les mesures passées :
public SensorStatistics add(SensorReading reading) {
if (!sensorId.equals(reading.sensorId()) || type != reading.type()) {
throw new IllegalArgumentException("The reading of " + reading.sensorId() + " " + reading.type()
+ " does not belong to the statistics of " + sensorId + " " + type);
}
return new SensorStatistics(sensorId, type, count + 1,
Math.min(min, reading.value()), Math.max(max, reading.value()), sum + reading.value());
}
Ici, l’exception est justifiée : ajouter la mesure d’un autre capteur serait un bug du programme, pas une donnée invalide.
Dans tout le package domain, il n’y a aucun import de Spring, de
Kafka, de JPA ni de Jackson.
Les ports et les cas d’usage
Le port d’entrée dit ce que le service offre :
public interface StoreMessageUseCase {
ReceivedMessage store(String text, int partition, long offset);
}
Les ports de sortie disent ce dont il a besoin, sans dire comment. Par exemple, pour les statistiques :
public interface StatisticsRepository {
Optional<SensorStatistics> find(String sensorId, SensorType type);
void save(SensorStatistics statistics);
List<SensorStatistics> findAll();
}
Le cas d’usage enchaîne la règle et les ports :
public class StoreMessageService implements StoreMessageUseCase {
private final MessageRepository messageRepository;
private final StatisticsRepository statisticsRepository;
private final Clock clock;
// constructor: the three fields above
@Override
public ReceivedMessage store(String text, int partition, long offset) {
ParseResult result = SensorReading.parse(text);
ReceivedMessage stored = messageRepository.save(
ReceivedMessage.notStoredYet(text, partition, offset, Instant.now(clock), result));
if (result instanceof ParseResult.Valid valid) {
updateStatistics(valid.reading());
}
return stored;
}
private void updateStatistics(SensorReading reading) {
SensorStatistics statistics = statisticsRepository.find(reading.sensorId(), reading.type())
.map(current -> current.add(reading))
.orElseGet(() -> SensorStatistics.first(reading));
statisticsRepository.save(statistics);
}
}
Pas de @Service, pas de @Autowired : c’est une classe Java. L’heure
vient d’une Clock passée au constructeur, pour qu’un test puisse la
fixer. Spring la crée dans la classe de configuration, avec les
adaptateurs qui implémentent les ports :
@Bean
public StoreMessageUseCase storeMessageUseCase(MessageRepository messageRepository,
StatisticsRepository statisticsRepository, Clock clock) {
return new StoreMessageService(messageRepository, statisticsRepository, clock);
}
Tout le câblage Spring est dans config/. Le reste du cœur ignore que
Spring existe.
Les adaptateurs
Le listener Kafka est un adaptateur d’entrée : il traduit un message Kafka en appel du port d’entrée.
@KafkaListener(topics = "${iot.kafka.topic}")
@Transactional
public void onMessage(ConsumerRecord<String, String> consumerRecord) {
ReceivedMessage stored = storeMessage.store(
consumerRecord.value(), consumerRecord.partition(), consumerRecord.offset());
// … log of the result
}
La transaction est ouverte ici, dans l’adaptateur : le message et les statistiques sont enregistrés ensemble ou pas du tout, et le cas d’usage reste en Java pur. Cela marche parce que le conteneur Spring Kafka appelle le listener à travers le bean Spring : en mode proxy (le mode par défaut), Spring n’intercepte que les appels qui passent par le proxy.
Les adaptateurs JPA sont des adaptateurs de sortie. L’entité JPA
(StatisticsEntity, avec ses annotations @Entity, @Column…) est une
autre classe que le record du domaine. L’adaptateur fait la traduction :
@Override
public void save(SensorStatistics statistics) {
StatisticsEntity entity = springDataRepository
.findBySensorIdAndSensorType(statistics.sensorId(), statistics.type())
.orElseGet(() -> new StatisticsEntity(statistics.sensorId(), statistics.type()));
entity.update(statistics.count(), statistics.min(), statistics.max(), statistics.sum());
springDataRepository.save(entity);
}
De même, l’API REST rend des records de réponse (StatisticsResponse,
MessageResponse), pas les objets du domaine : la forme du JSON peut
changer sans toucher au domaine.
Les tests que cela permet
Le domaine, sans rien démarrer
Une ligne de test par sorte de ligne invalide (extrait) :
@ParameterizedTest(name = "[{0}] -> {1}")
@CsvSource(delimiter = '|', value = {
"sensor-1 WIND 12 | unknown type WIND",
"sensor-1 TEMPERATURE 21,5 | invalid value 21,5",
"sensor-1 HUMIDITY -1 | HUMIDITY must be between 0 and 100"})
void parse_badLine_returnsRejectionWithReason(String text, String reason) {
assertThat(SensorReading.parse(text)).isEqualTo(new ParseResult.Rejected(reason));
}
Le cas d’usage, avec de faux ports
Les ports sont des interfaces : un test leur donne une version en mémoire, et une horloge fixe :
private final FakeMessageRepository messageRepository = new FakeMessageRepository();
private final FakeStatisticsRepository statisticsRepository = new FakeStatisticsRepository();
private final StoreMessageService service =
new StoreMessageService(messageRepository, statisticsRepository, Clock.fixed(NOW, ZoneOffset.UTC));
@Test
void store_rejectedLine_savesReasonAndKeepsStatistics() {
service.store("sensor-1 TEMPERATURE 20", 0, 0L);
ReceivedMessage stored = service.store("sensor-1 WIND 12", 0, 1L);
assertThat(stored.status()).isEqualTo(MessageStatus.REJECTED);
assertThat(stored.rejectionReason()).isEqualTo("unknown type WIND");
assertThat(statisticsRepository.findAll())
.containsExactly(new SensorStatistics("sensor-1", SensorType.TEMPERATURE, 1, 20, 20, 20));
}
Ni Spring, ni Kafka, ni base de données, ni Mockito. Les tests du domaine et des cas d’usage :
mvn -pl iot-backend-processing test -Dtest='SensorReadingTest,SensorStatisticsTest,StoreMessageServiceTest,ListLastMessagesServiceTest'
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.053 s -- in dev.aymenjamaaoui.iot.processing.application.StoreMessageServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in dev.aymenjamaaoui.iot.processing.application.ListLastMessagesServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 s -- in dev.aymenjamaaoui.iot.processing.domain.SensorStatisticsTest
[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.058 s -- in dev.aymenjamaaoui.iot.processing.domain.SensorReadingTest
[INFO] Tests run: 35, Failures: 0, Errors: 0, Skipped: 0
35 tests, chacun en quelques millisecondes. Les adaptateurs ont leurs
propres tests : un @WebMvcTest pour l’API REST, et un test
d’intégration avec un vrai Kafka et un vrai PostgreSQL (Testcontainers).
La règle, vérifiée par le build
Une règle d’architecture écrite seulement dans un README finit par être oubliée. Le projet la vérifie avec ArchUnit :
@AnalyzeClasses(packages = "dev.aymenjamaaoui.iot.processing", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule DOMAIN_AND_APPLICATION_HAVE_NO_FRAMEWORK = noClasses()
.that().resideInAnyPackage("..domain..", "..application..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "org.apache.kafka..", "jakarta..", "org.hibernate..",
"com.fasterxml.jackson..", "tools.jackson..");
}
Pour le voir échouer, j’ajoute exprès @Component (Spring) sur le
record SensorStatistics, puis je lance mvn -pl iot-backend-processing test -Dtest=ArchitectureTest (sortie raccourcie) :
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in any package ['..domain..', '..application..'] should depend on classes that reside in any package ['org.springframework..', 'org.apache.kafka..', 'jakarta..', 'org.hibernate..', 'com.fasterxml.jackson..', 'tools.jackson..']' was violated (1 times):
Class <dev.aymenjamaaoui.iot.processing.domain.SensorStatistics> is annotated with <org.springframework.stereotype.Component> in (SensorStatistics.java:0)
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Le message dit la règle, la classe et la cause. Le build échoue, donc la règle ne peut pas être cassée sans qu’on le voie.
Le coût, honnêtement
Cette organisation a un prix :
- plus de classes : le service a 28 classes dans
src/main, dont 6 ports (interfaces et exception) et 2 entités JPA à côté des records du domaine ; - du code de traduction : entité JPA ↔ record du domaine ↔ réponse REST ;
- plus de fichiers à ouvrir pour suivre un appel, du listener jusqu’à la base.
Sur un exemple aussi petit, la structure peut sembler lourde : c’est voulu ici, pour que chaque partie soit visible. Elle paie quand le service grandit : quand les règles se multiplient, quand une technologie change (une autre base, une autre façon de recevoir les messages), ou quand on veut tester la règle vite et souvent. Pour un service qui ne fait que copier des données d’un système à un autre, sans vraie règle, une structure plus simple suffit.
Testé avec
Java 25 (Temurin 25.0.4), Spring Boot 4.1.2, ArchUnit 1.5.1, JUnit 5,
AssertJ, Maven 3.10.0. Code : tag
phase-1,
service iot-backend-processing.
Sources
- Alistair Cockburn, « Hexagonal architecture » (l’article de 2005, « Ports and Adapters »)
- JEP 409 : Sealed Classes (Java 17)
- JEP 441 : Pattern Matching for switch (Java 21)
- ArchUnit, guide utilisateur (vérifications des dépendances entre packages)
- Spring Framework : Using @Transactional (le mode proxy)
Pour aller plus loin
- L’article précédent de la série : Kafka et Spring Boot : un projet complet en local.
- Le README du tag
phase-1: la règle métier, l’API et tous les tests.