Lors de l’application d’une architecture hexagonale (ports et adaptateurs), l’accès aux éléments d’infrastructure tels que les bases de données se fait au moyen d’adaptateurs, qui ne sont que des implémentations d’interfaces (ports) définies par le domaine. Dans cet article, nous allons fournir deux implémentations du même port de référentiel, une en mémoire et une autre basée sur JPA, en nous concentrant sur la façon de tester les deux implémentations avec le même ensemble de tests.
Contexte
De nombreuses solutions logicielles généralement développées dans le contexte de l’entreprise ont un état qui doit être conservé dans un magasin durable pour un accès ultérieur. Selon les exigences fonctionnelles et non fonctionnelles spécifiques, la sélection de la bonne solution de persistance peut être difficile à faire et nécessite très probablement un dossier de décision d’architecture (ADR) où la justification de la sélection, y compris les alternatives et les compromis, est détaillée. Pour conserver l’état de votre application, vous examinerez très probablement le théorème CAP pour prendre la décision la plus adéquate.
Ce processus de décision ne doit pas retarder la conception et le développement du modèle de domaine de votre application. Les équipes d’ingénierie doivent se concentrer sur la création de valeur (commerciale), et non sur la maintenance d’un tas de scripts DDL et l’évolution d’un schéma de base de données très changeant, pour quelques semaines (ou mois) plus tard, réaliser qu’il aurait été préférable d’utiliser une base de données de documents au lieu d’un base de données relationnelle.
De plus, se concentrer sur la valeur du domaine de livraison empêche l’équipe de prendre une décision liée au domaine basée sur les contraintes d’une décision technique et/ou liée à l’infrastructure prise trop tôt (c’est-à-dire la technologie de base de données dans ce cas). Comme oncle Bob l’a dit dans ce tweeterl’architecture doit permettre de différer les décisions d’encadrement (et d’infrastructure).
Différer les décisions liées aux infrastructures
Pour en revenir à l’exemple de la technologie de base de données, un moyen de différer la décision d’infrastructure concernant la technologie de base de données à utiliser serait de commencer par une simple implémentation en mémoire de votre référentiel où les entités de domaine peuvent être stockées dans une liste en mémoire. Cette approche accélère la découverte, la conception et la mise en œuvre des fonctionnalités et des cas d’utilisation de domaine, permettant des cycles de rétroaction rapides avec vos parties prenantes sur ce qui compte : Valeur du domaine.
Maintenant, vous pensez peut-être, « mais ensuite, je ne propose pas de fonctionnalité de travail e2e », ou « comment vérifier la fonctionnalité avec un adaptateur en mémoire de mon référentiel ? » Ici, des modèles d’architecture comme Hexagonal Architecture (également appelés ports et adaptateurs) et des méthodologies comme DDD (non obligatoire pour avoir une architecture propre et finalement un code propre) entrent en action.
Architecture hexagonale
De nombreuses applications sont conçues selon l’architecture classique à trois couches :
- Présentation/contrôleur
- Service (logique métier)
- Couches de persistance
Cette architecture a tendance à mélanger la définition de domaine (par exemple, des entités de domaine et des objets de valeur) avec des tables (par exemple, des entités ORM), généralement représentées comme de simples objets de transfert de données. Ceci est illustré ci-dessous :
Au contraire, avec une architecture hexagonale, les classes liées à la persistance réelle sont toutes définies sur la base du modèle de domaine.
En utilisant le port (interface) du référentiel (qui est défini dans le cadre du modèle de domaine), il est possible de définir des définitions de test d’intégration indépendantes de la technologie sous-jacente, qui vérifient les attentes du domaine vis-à-vis du référentiel. Voyons à quoi cela ressemble dans le code dans un modèle de domaine simple pour la gestion des étudiants.
Montrez-moi le code
Alors, à quoi ressemble ce port de référentiel dans le cadre du domaine ? Il définit essentiellement les attentes du domaine envers le référentiel, ayant toutes les méthodes définies en termes de langage ubiquitaire du domaine :
public interface StudentRepository {
Student save(Student student);
Optional<Student> retrieveStudentWithEmail(ContactInfo contactInfo);
Publisher<Student> saveReactive(Student student);
}
Sur la base de la spécification du port du référentiel, il est possible de créer la définition de test d’intégration, qui dépend uniquement du port et est indépendante de toute décision technologique sous-jacente prise pour conserver l’état du domaine. Cette classe de test aura une propriété en tant qu’instance de l’interface de référentiel (port) sur laquelle les attentes sont vérifiées. L’image suivante montre à quoi ressemblent ces tests :
public class StudentRepositoryTest {
StudentRepository studentRepository;
@Test
public void shouldCreateStudent() {
Student expected = randomNewStudent();
Student actual = studentRepository.save(expected);
assertAll("Create Student",
() -> assertEquals(0L, actual.getVersion()),
() -> assertEquals(expected.getStudentName(), actual.getStudentName()),
() -> assertNotNull(actual.getStudentId())
);
}
@Test
public void shouldUpdateExistingStudent() {
Student expected = randomExistingStudent();
Student actual = studentRepository.save(expected);
assertAll("Update Student",
() -> assertEquals(expected.getVersion()+1, actual.getVersion()),
() -> assertEquals(expected.getStudentName(), actual.getStudentName()),
() -> assertEquals(expected.getStudentId(), actual.getStudentId())
);
}
}
Une fois la définition du test du référentiel terminée, nous pouvons créer un runtime de test (test d’intégration) pour le référentiel en mémoire :
public class StudentRepositoryInMemoryIT extends StudentRepositoryTest {
@BeforeEach
public void setup() {
super.studentRepository = new StudentRepositoryInMemory();
}
}
Ou un test d’intégration un peu plus élaboré pour JPA avec Postgres :
@Testcontainers
@ContextConfiguration(classes = {PersistenceConfig.class})
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class StudentRepositoryJpaIT extends StudentRepositoryTest{
@Autowired
public StudentRepository studentRepository;
@Container
public static PostgreSQLContainer container = new PostgreSQLContainer("postgres:latest")
.withDatabaseName("students_db")
.withUsername("sa")
.withPassword("sa");
@DynamicPropertySource
public static void overrideProperties(DynamicPropertyRegistry registry){
registry.add("spring.datasource.url", container::getJdbcUrl);
registry.add("spring.datasource.username", container::getUsername);
registry.add("spring.datasource.password", container::getPassword);
registry.add("spring.datasource.driver-class-name", container::getDriverClassName);
}
@BeforeEach
public void setup() {
super.studentRepository = studentRepository;
}
}
Les deux exécutions de test étendent la même définition de test, nous pouvons donc être sûrs que, lors du passage de l’adaptateur en mémoire à la persistance complète JPA finale, aucun test ne sera affecté car il suffit de configurer l’exécution de test correspondante .
Cette approche nous permettra de définir les tests du portage du référentiel sans aucune dépendance aux frameworks et de réutiliser ces tests une fois que le domaine sera mieux défini, étant plus stable, et que l’équipe décidera d’aller de l’avant avec la technologie de base de données qui répond mieux à la qualité de la solution les attributs.
La structure globale du projet est illustrée dans l’image suivante :
Où:
student-domain
: Module avec la définition du domaine, y compris les entités, les objets de valeur, les événements de domaine, les ports, etc. Ce module n’a aucune dépendance avec les frameworks, étant aussi pur Java que possible.student-application
: Actuellement, ce module n’a pas de code car il était hors de portée de l’article. Suivant une architecture hexagonale, ce module orchestre les invocations au modèle de domaine, étant le point d’entrée des cas d’utilisation du domaine. Les prochains articles entreront plus de détails.student-repository-test
: Ce module contient les définitions de test du référentiel, sans dépendances sur les frameworks, et vérifie uniquement l’attente du port de référentiel fourni.student-repository-inmemory
: Implémentation en mémoire du port du référentiel défini par le domaine. Il contient également le test d’intégration, qui fournit l’adaptateur en mémoire du port à la définition de test dustudent-repository-test
.student-repository-jpa
: Implémentation JPA du port du référentiel défini par le domaine. Il contient également le test d’intégration, qui fournit l’adaptateur en mémoire du port à la définition de test dustudent-repository-test
. Cette configuration de test d’intégration est un peu plus complexe car elle crée un contexte Spring de base avec un conteneur Postgres.student-shared-kernel
: Ce module est hors de portée de l’article ; il fournit des classes utilitaires et des interfaces pour concevoir le reste du projet.
Conclusion
L’utilisation de ce style architectural pour vos projets favorise une bonne séparation entre le modèle de domaine et les éléments d’infrastructure qui l’entourent, garantissant que ce dernier n’influencera pas le premier tout en favorisant une bonne qualité de code (code propre) et une maintenabilité élevée.
Le code de cet article se trouve dans mon perso Référentiel GitHub.