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.

L'architecture hexagonale du service iot-backend-processing : au centre, le domaine et les cas d'usage en Java pur ; dans le bas de l'hexagone, les ports d'entrée et de sortie ; autour, les adaptateurs Kafka, REST et JPA. Toutes les dépendances pointent vers l'intérieur.Cas d'usageStoreMessageServiceListStatisticsService…DomaineSensorReadingParseResult (sealed)SensorStatisticsports d'entréeStoreMessageUseCase…ports de sortieMessageRepositoryStatisticsRepositoryAdaptateurs d'entréeSensorMessageListenerlit KafkaMessageController…API RESTappellentAdaptateurs de sortieJpaMessageRepositoryJpaStatisticsRepositoryPostgreSQLimplémententLes dépendances pointent vers l'intérieur : 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

Pour aller plus loin