Skip to main content
David Le Montagner Home Page David Le Montagner

Architecture Hexagonale, de Spring Boot à Quarkus

Introduction & projet de départ #

Lors de mes recherches sur l’architecture hexagonale, l’un des talks qui m’a le plus éclairé est celui de Julien Topçu. Si vous ne l’avez pas encore regardé, je vous invite à arrêter immédiatement la lecture de cet article pour foncer sur YouTube.

Pendant ce talk, Julien explique que les principes de l’ArchHex permettent, par exemple, de garantir une montée de version de framework sans risquer de casser la logique métier. Pour pousser cette affirmation à l’extrême, je me suis dit qu’il serait intéressant de réécrire la partie infrastructure en Quarkus et ainsi prouver qu'il n'y a aucun couplage entre le métier et le framework.

Cet article n’ira pas dans le détail de l’implémentation Quarkus mais va se concentrer sur les ajustements qui ont nécessité d’aller au delà de la simple adaptation de code.

Le point de départ va donc être le code source de Julien:

git clone https://gitlab.com/beyondxscratch/hexagonal-architecture-java-springboot.git

Injection du domaine métier #

ComponentScan Spring vs CDI Quarkus #

Le ComponentScan de Spring permet de détecter et d’injecter les beans du domaine lors de l’exécution de l’application. Quarkus ne propose pas cette fonctionnalité et a une manière très différente de créer et d'injecter des beans. Il utilise une "découverte simplifiée", ce qui signifie que les beans sont analysés pendant la compilation et seuls ceux faisant partie de son index et qui ont des annotations considérées comme "découverte" sont pris en compte.

Nous pourrions créer les beans du domaine via une classe de configuration pour qu'ils soient disponibles lors de l'injection. Nous perdons alors l'aspect dynamique du ComponentScan mais il faut avouer que ceci parait suffisant pour un domaine simple avec peu de beans à instancier comme c'est le cas pour notre application d'exemple:

public class DomainConfiguration {

    @ApplicationScoped
    public AssembleAFleet assembleAFleet(StarShipInventory starShipsInventory, Fleets fleets){
        return new FleetAssembler(starShipsInventory, fleets);
    }

    @ApplicationScoped
    public Fleets fleets(){
        return new InMemoryFleets();
    }
}

Toutefois, si nous souhaitons reproduire le ComponentScan, il va être nécessaire de résoudre deux problèmes lors de la compilation:

  1. Ajouter les classes du domaine dans l’index Jandex de Quarkus. En effet, ces classes étant dans une dépendance externe à notre infrastructure Quarkus, elles ne seront pas présentes dans l'index initial.
  2. Faire en sorte que les annotations spécifiques du domaine (ddd.DomainService & ddd.Stub) soient prises en compte par Quarkus

Pour ce faire, nous allons devoir créer une extension Quarkus qui se déclenchera lors de la compilation.

Création de l’extension #

Commençons par créer le boilerplate de l’extension avec le CLI Quarkus:

quarkus create extension beyondxscratch:domain-beans-configuration

Dans le pom.xml de l’extension, ajouter le projet parent:

<parent>
    <groupId>beyondxscratch</groupId>
    <artifactId>starwars-rebels-rescue</artifactId>
    <version>1.0-SNAPSHOT</version>
</parent>

Puis, dans le pom.xml de la partie deployment de l’extension, ajouter la dépendance vers le domaine:

<dependency>
      <groupId>beyondxscratch</groupId>
      <artifactId>starwars-rebels-rescue-domain</artifactId>
      <version>1.0-SNAPSHOT</version>
</dependency>

Enfin, dans le pom.xml parent du projet, ajouter le module correspondant à l’extension:

<module>domain-beans-configuration</module>

Rendre l’extension configurable #

J’aimerais pouvoir configurer l’extension via l’application.properties de l’infrastructure avec les éléments suivants:

domain.configuration.domain-group-id=beyondxscratch
domain.configuration.domain-artifact-id=starwars-rebels-rescue-domain
domain.configuration.domain-annotations=ddd.DomainService,ddd.Stub
domain.configuration.package-start-with=rebelsrescue.
domain.configuration.excluded-classes=rebelsrescue.fleet.spi.stubs.StarShipInventoryStub

Pour ce faire, nous allons définir une interface de configuration au sein de l’extension:

package beyondxscratch.domain.beans.configuration.deployment;

import io.quarkus.runtime.annotations.ConfigPhase;
import io.quarkus.runtime.annotations.ConfigRoot;
import io.smallrye.config.ConfigMapping;

import java.util.List;
import java.util.Optional;

/**
 * Domain configuration.
 */
@ConfigMapping(prefix = "domain.configuration")
@ConfigRoot(phase = ConfigPhase.BUILD_TIME)
public interface DomainBeansConfiguration {
    /**
     * Domain GroupId to be scanned
     */
    String domainGroupId();
    /**
     * Domain ArtifactId to be scanned
     */
    String domainArtifactId();

    /**
     * Domain annotations to be scanned
     */
    List<String> domainAnnotations();

    /**
     * Filter classes contained in package starting with
     */
    String packageStartWith();

    /**
     * Excluded classes named
     */
    Optional<List<String>> excludedClasses();
}

Je n'ai pas encore trouvé la bonne manière d'automatiser le test de cette extension. Toutefois, pour éviter le plantage des tests par défaut, il faut ajouter le chargement du fichier de properties à DomainBeansConfigurationDevModeTest et DomainBeansConfigurationTest:

@RegisterExtension
    static final QuarkusDevModeTest devModeTest = new QuarkusDevModeTest()
            .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class))
+          .withApplicationRoot((jar) -> jar.addAsResource("application.properties"));

Injection du domaine métier à la compilation #

S’agissant de la compilation, nous allons devoir ajouter notre fonctionnalité dans la classe DomainBeansConfigurationProcessor du package beyondxscratch.domain.beans.configuration.deployment. Le package runtime ne nous sera pas utile car il concerne les extensions souhaitant agir lors de l’exécution.

Pour commencer, je définis l’étape indexExternalDependency qui va me permettre l’ajout des classes du domaine au sein de l’index Jandex de Quarkus:

package beyondxscratch.domain.beans.configuration.deployment;

import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
import io.quarkus.arc.processor.DotNames;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.FeatureBuildItem;
import io.quarkus.deployment.builditem.IndexDependencyBuildItem;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.DotName;
import org.jboss.logging.Logger;

import java.lang.reflect.Modifier;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

class DomainBeansConfigurationProcessor {

    private static final String FEATURE = "domain-beans-configuration";

    DomainBeansConfiguration domainBeansConfiguration;

    private static final Logger LOG = Logger.getLogger(DomainBeansConfigurationProcessor.class);

    @BuildStep
    FeatureBuildItem feature() {
        return new FeatureBuildItem(FEATURE);
    }

    @BuildStep
    IndexDependencyBuildItem indexExternalDependency() {
        return new IndexDependencyBuildItem(domainBeansConfiguration.domainGroupId(), domainBeansConfiguration.domainArtifactId());
    }

}

Ensuite, j’ajoute l’étape addDomainBeans qui va parcourir les classes de l’index pour détecter celles ayant les annotations du domaine et correspondant à ma configuration afin de les ajouter en tant que Beans avec un scope APPLICATION_SCOPED:

    @BuildStep
    void addDomainBeans(CombinedIndexBuildItem index,
                        BuildProducer<AdditionalBeanBuildItem> additionalBeans) {
        List<String> domainBeans = this.getDomainBeansFromIndex(index);
        this.addBeanClassesWithScope(additionalBeans, domainBeans, DotNames.APPLICATION_SCOPED);
    }

    private List<String> getDomainBeansFromIndex(CombinedIndexBuildItem index){
        return index.getIndex().getKnownClasses().stream()
                .filter(this::isNotAbstract)
                .filter(this::hasAnyDomainAnnotation)
                .map(this::toClassNameStr)
                .filter(this::isInTheRightPackage)
                .filter(this::isNotAnExcludedClass)
                .peek(c -> LOG.info(c+" will be added as bean"))
                .collect(Collectors.toList());
    }

    private boolean isNotAbstract(ClassInfo ci){
        return !Modifier.isAbstract(ci.flags());
    }

    private boolean hasAnyDomainAnnotation(ClassInfo ci){
        return domainBeansConfiguration.domainAnnotations()
                .stream()
                .anyMatch(annotationStr -> ci.hasAnnotation(annotationStr));
    }

    private String toClassNameStr(ClassInfo ci){
        return ci.name().toString();
    }

    private boolean isInTheRightPackage(String candidateClass){
        return candidateClass.startsWith(domainBeansConfiguration.packageStartWith());
    }

    private boolean isNotAnExcludedClass(String candidateClass){
        Optional<List<String>> excludedClasses = domainBeansConfiguration.excludedClasses();

        if(excludedClasses.isEmpty())
            return true;

        return excludedClasses.get().stream()
                .noneMatch(excludedClass -> candidateClass.equals(excludedClass));
    }

    private void addBeanClassesWithScope(BuildProducer<AdditionalBeanBuildItem> additionalBeans, List<String> classes, DotName scope){
        additionalBeans.produce(new AdditionalBeanBuildItem.Builder()
                .addBeanClasses(classes)
                .setUnremovable()
                .setDefaultScope(scope)
                .build());
    }

Mise à disposition de l’extension #

Pour que Quarkus puisse utiliser l’extension, il faut d’abord la compiler et la mettre à disposition:

mvn clean install

Suite à cela, pour que notre extension soit utilisée par l’infrastructure Quarkus, il faut ajouter la dépendance vers l’extension ainsi que vers le domaine au sein du pom.xml de la partie infrastructure Quarkus:

<dependency>
      <groupId>beyondxscratch</groupId>
      <artifactId>domain-beans-configuration</artifactId>
      <version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
      <groupId>beyondxscratch</groupId>
      <artifactId>starwars-rebels-rescue-domain</artifactId>
      <version>1.0-SNAPSHOT</version>
</dependency>

Ainsi que les valeurs suivantes dans le fichier application.properties de notre infrastructure Quarkus:

domain.configuration.domain-group-id=beyondxscratch
domain.configuration.domain-artifact-id=starwars-rebels-rescue-domain
domain.configuration.domain-annotations=ddd.DomainService,ddd.Stub
domain.configuration.package-start-with=rebelsrescue.
#domain.configuration.excluded-classes=rebelsrescue.fleet.spi.stubs.StarShipInventoryStub

Notez que j’ai commenté l’exclusion du StarShipInventoryStub car, à ce stade, nous n’avons pas encore développé l’implémentation de Swapi.

Lors de la compilation de l’infrastructure Quarkus, vous verrez désormais apparaitre ces logs qui indiqueront la bonne prise en compte des annotations métier.

[INFO] [beyondxscratch.domain.beans.configuration.deployment.DomainBeansConfigurationProcessor] rebelsrescue.fleet.FleetAssembler will be added as bean
[INFO] [beyondxscratch.domain.beans.configuration.deployment.DomainBeansConfigurationProcessor] rebelsrescue.fleet.spi.stubs.InMemoryFleets will be added as bean
[INFO] [beyondxscratch.domain.beans.configuration.deployment.DomainBeansConfigurationProcessor] rebelsrescue.fleet.spi.stubs.StarShipInventoryStub will be added as bean

De RestTemplate à RestClient #

Une autre particularité a été l’adaptation du RestTemplate. L’approche du client Rest de Quarkus est moins souple dans la mesure où il n’est pas possible d’utiliser le client en utilisant une URL complète. Il faut impérativement créer une interface déclarant l’url de base (via le fichier de configuration) puis le chemin des APIs que l’on souhaite atteindre.

J’ai donc eu recours à une solution qui ne me satisfait pas totalement mais qui répond au besoin faute d’être élégante: au sein de l’interface, écrire une méthode par défaut get(String url) qui se charge de valider le format de l’URL puis de faire l’appel à la bonne méthode:

package beyondxscratch.starwars.rebels.rescue.swapi;

import beyondxscratch.starwars.rebels.rescue.model.SwapiResponse;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

import java.net.MalformedURLException;

@RegisterRestClient(configKey = "swapi-client")
public interface SwapiClient {

    final static String STARSHIPS_FIRST_PAGE_URL = "/api/starships/?page=1";

    @GET
    @Path("/api/starships")
    public SwapiResponse getStarships(@QueryParam("page") int page);

    default SwapiResponse get(String url) throws MalformedURLException {
        try {
            int page = Integer.parseInt(url.replaceAll(".*\\/api\\/starships\\/\\?page=", ""));
            return this.getStarships(page);
        }catch(NumberFormatException numberFormatException){
            throw  new MalformedURLException();
        }
    }
}

Conclusion #

Le reste des ajustements n’a pas présenté de difficultés particulières et je vous invite à aller voir le code source complet pour vous en rendre compte.

Bien que le fonctionnel soit modeste, il est impressionnant de voir qu’il est possible de changer de framework de façon aussi aisée. L’architecture hexagonale est séduisante à bien des égards et j’ai très envie de continuer à creuser cette thématique dans le futur. 🙂

Encore merci à Julien Topçu pour son talk et l’inspiration qui en découle! 👍