Ports & Adapters
Guide complet du pattern Ports & Adapters (Architecture Hexagonale) avec Micronaut.
Ce pattern permet de découpler votre logique métier de l’infrastructure technique (API, BDD, frameworks).
Qu’est-ce qu’un Port ?
Un Port est une interface qui définit un contrat entre deux couches de l’application.
Pensez aux ports comme des prises électriques : peu importe ce que vous branchez (mock, API, BDD), tant que ça respecte le contrat !
Types de Ports
Ports d’Entrée (Input)
Qui l’utilise ? Le monde extérieur (API REST, CLI, GraphQL)
Où ? application/port/input/
Exemple : Controllers REST
@Controller("/api/trends")
@RequiredArgsConstructor
public class TrendController {
private final GetTrendsUseCase getTrendsUseCase;
@Get
public TrendResponseDto getTrends(@QueryValue String keyword) {
return getTrendsUseCase.execute(keyword)
.map(TrendResponseDto::fromDomain)
.orElseThrow(() -> new NotFoundException());
}
}Qu’est-ce qu’un Adaptateur ?
Un Adaptateur est l’implémentation concrète d’un port.
L’adaptateur fait le lien entre votre logique métier (pure) et le monde technique (API, BDD, fichiers).
Exemple : Adaptateur Mock
@Singleton
public class MockTrendRepositoryAdapter implements TrendRepository {
@Override
public Optional<TrendResult> getTrends(TrendQuery query) {
// Génère des données mockées pour le développement
TrendResult result = TrendResult.builder()
.keyword(query.keyword())
.region(query.region())
.interestScore(generateRandomScore())
.build();
return Optional.of(result);
}
private int generateRandomScore() {
return ThreadLocalRandom.current().nextInt(50, 101);
}
}Principe d’Inversion de Dépendances
Règle d’or : Le domain ne dépend JAMAIS de l’infrastructure. C’est l’inverse !
UseCase → TrendRepository (interface)
↑
| implements
Adapter- Le Use Case dépend de l’interface (port de sortie)
- L’Adapter implémente l’interface
- On peut changer d’adapter sans toucher au use case !
Structure des Packages
Comprendre la structure:
domain/port/output= Interfaces que le domain a besoin (repositories, services externes)application/port/input= Points d’entrée (REST controllers, CLI, GraphQL)infrastructure/adapter= Implémentations concrètes (Mock, API, BDD)
Flux Complet d’une Requête
Voyons comment une requête HTTP traverse toutes les couches.
Client envoie une requête
GET /api/trends?keyword=java®ion=USPort d’Entrée (Controller)
Le controller valide les paramètres et appelle le use case :
@Controller("/api/trends")
public class TrendController {
private final GetTrendsUseCase getTrendsUseCase;
@Get
public TrendResponseDto getTrends(
@QueryValue @NotBlank String keyword,
@QueryValue(defaultValue = "US") String region
) {
return getTrendsUseCase.execute(keyword, region)
.map(TrendResponseDto::fromDomain)
.orElseThrow(() -> new ResourceNotFoundException());
}
}Use Case (Logique Métier)
Le use case crée les objets domain et appelle le port de sortie :
@Singleton
public class GetTrendsUseCase {
private final TrendRepository trendRepository;
public Optional<TrendResult> execute(String keyword, String region) {
TrendQuery query = new TrendQuery(keyword, region);
return trendRepository.getTrends(query);
}
}Port de Sortie (Interface)
L’interface définit le contrat sans implémentation :
public interface TrendRepository {
Optional<TrendResult> getTrends(TrendQuery query);
}Adaptateur (Implémentation)
L’adaptateur implémente la logique technique :
@Singleton
public class MockTrendRepositoryAdapter implements TrendRepository {
@Override
public Optional<TrendResult> getTrends(TrendQuery query) {
// Génère des données mockées
return Optional.of(generateMockData());
}
}Retour au Client
Le controller transforme TrendResult → TrendResponseDto et retourne du JSON.
À chaque étape, les responsabilités sont clairement séparées : validation, logique métier, accès technique.
Pourquoi les Output Ports sont dans le Domain ?
C’est une question fréquente ! Voici pourquoi cette décision est architecturalement importante.
Règle Fondamentale
- Output Ports → TOUJOURS dans
domain/port/output/ - Input Ports → TOUJOURS dans
application/port/input/
Le Domain Définit Ses Besoins
Le domain dit : “J’ai besoin d’un repository pour sauvegarder mes trends”
Il ne sait pas et ne veut pas savoir :
- Si c’est une BDD PostgreSQL
- Si c’est un fichier JSON
- Si c’est un appel API externe
- Si c’est juste du mock
Domain (Interface)
// domain/port/output/TrendRepository.java
public interface TrendRepository {
Optional<TrendResult> getTrends(TrendQuery query);
}Le domain exprime son besoin de manière abstraite.
Erreur courante :
Ne JAMAIS mettre un output port dans application/ ! C’est le domain qui définit ses besoins, pas l’application.
Remplacer un Adaptateur
C’est LE GRAND AVANTAGE de l’architecture hexagonale !
Étape 1: Créer Adapter
Créer un nouvel adaptateur
@Singleton
@Requires(property = "app.trend-source", value = "google-api")
public class GoogleTrendsApiAdapter implements TrendRepository {
private final HttpClient httpClient;
@Override
public Optional<TrendResult> getTrends(TrendQuery query) {
// Appelle la vraie API Google Trends
String url = "https://trends.google.com/api/...";
GoogleApiResponse response = httpClient.toBlocking()
.retrieve(HttpRequest.GET(url), GoogleApiResponse.class);
return Optional.of(mapToTrendResult(response));
}
}Adaptateur avec Conversion DTO → Domain
L’adaptateur est responsable de la traduction entre le monde technique (DTOs) et le domain.
Exemple : CountryRepositoryAdapter
@Slf4j
@Singleton
public class CountryRepositoryAdapter implements CountryRepository {
@Inject
@Client(id = "countries", path = "/v3.1")
HttpClient httpClient;
@Override
public List<Country> findAll() {
try {
// 1. Appeler l'API et désérialiser vers DTO (infrastructure)
CountryApiDto[] countriesDto = httpClient.toBlocking()
.retrieve("/all?fields=name,translations", CountryApiDto[].class);
// 2. Convertir DTO → Domain
return Stream.of(countriesDto)
.map(this::toDomain)
.collect(Collectors.toList());
} catch (Exception e) {
log.error("Error retrieving countries", e);
return List.of();
}
}
private Country toDomain(CountryApiDto dto) {
// Conversion DTO (infrastructure) → Domain (pur)
Name name = new Name(
dto.getName().getCommon(),
dto.getName().getOfficial()
);
List<Translation> translations = convertTranslations(
dto.getTranslations()
);
return new Country(name, translations);
}
}Où faire la conversion ?
- ❌ MAUVAIS : Dans le DTO (créerait une dépendance DTO → Domain)
- ✅ BON : Dans l’Adaptateur (responsabilité de traduction)
Testabilité
Tester le domaine devient ultra simple car on peut mocker les ports de sortie !
Tester un Use Case
@Test
void shouldReturnTrendsWhenKeywordIsValid() {
// ARRANGE - Mock du port de sortie
TrendRepository mockRepository = mock(TrendRepository.class);
TrendResult mockResult = TrendResult.builder()
.keyword("java")
.region("US")
.interestScore(85)
.build();
when(mockRepository.getTrends(any())).thenReturn(Optional.of(mockResult));
GetTrendsUseCase useCase = new GetTrendsUseCase(mockRepository);
// ACT
Optional<TrendResult> result = useCase.execute("java", "US");
// ASSERT
assertThat(result).isPresent();
assertThat(result.get().getKeyword()).isEqualTo("java");
}Avantages :
- ✅ Pas besoin d’API réelle
- ✅ Pas besoin de BDD
- ✅ Tests ultra rapides
- ✅ Focus sur la logique métier uniquement
Principes Clés
1. Inversion de Dépendances
❌ Mauvais
UseCase → Adapter (implémentation concrète)Problème : Impossible de changer l’adapter sans modifier le use case.
2. Le Domain au Centre
Le domain ne dépend de RIEN :
- ❌ Pas de dépendance vers
infrastructure/ - ❌ Pas de dépendance vers
application/ - ❌ Pas de framework (Micronaut, Spring, Jackson)
- ✅ Seulement des interfaces (ports de sortie)
3. Séparation des Responsabilités
| Couche | Responsabilité | Exemple |
|---|---|---|
| Port Input | Validation, conversion HTTP → Domain | TrendController |
| Use Case | Logique métier, orchestration | GetTrendsUseCase |
| Port Output | Contrat d’accès aux ressources | TrendRepository |
| Adapter | Implémentation technique | MockTrendRepositoryAdapter |
Variantes d’Organisation des Ports
Il existe 3 approches principales pour organiser les ports. Notre choix actuel est l’Approche Séparée.
Séparée (Notre choix)
Approche Séparée ✅
domain/
└── port/
└── output/ ← Ports de SORTIE
application/
└── port/
└── input/ ← Ports d'ENTRÉEAvantages :
- ✅ Clarté maximale
- ✅ Domain indépendant
- ✅ Tests d’architecture stricts
Quand l’utiliser :
- Le controller EST le port d’entrée (pas d’interface use case)
- Tu veux une séparation forte domain/application
Notre choix (Séparée) privilégie la clarté et le respect strict du DDD, mais les 3 approches sont valides selon ton contexte !
Comparaison : Architecture Classique vs Hexagonale
| Aspect | Architecture Classique | Architecture Hexagonale |
|---|---|---|
| Structure | Controller → Service → Repository → BDD | Port In → UseCase → Port Out → Adapter |
| Dépendances | Couplage fort | Couplage faible (interfaces) |
| Testabilité | Difficile (besoin de BDD/API) | Facile (mock des interfaces) |
| Flexibilité | Difficile de changer de BDD/API | Facile (juste changer l’adapter) |
| Complexité | Plus simple pour petits projets | Plus de fichiers mais mieux organisé |
| Indépendance | Couplé au framework | Domain indépendant |
Cas d’Usage Pratiques
Ajouter un Cache
Créer un Cached Adapter
@Singleton
@Primary
public class CachedTrendRepositoryAdapter implements TrendRepository {
private final TrendRepository delegate;
private final Map<String, TrendResult> cache = new ConcurrentHashMap<>();
public CachedTrendRepositoryAdapter(
@Named("mock") TrendRepository delegate
) {
this.delegate = delegate;
}
@Override
public Optional<TrendResult> getTrends(TrendQuery query) {
String cacheKey = query.keyword() + "_" + query.region();
// Check cache
if (cache.containsKey(cacheKey)) {
return Optional.of(cache.get(cacheKey));
}
// Sinon, déléguer
Optional<TrendResult> result = delegate.getTrends(query);
result.ifPresent(r -> cache.put(cacheKey, r));
return result;
}
}Profit !
Aucun changement dans le code métier. Le cache est transparent.
Basculer entre Mock et API selon l’environnement
# application-dev.yml
app:
trend-source: mock
# application-prod.yml
app:
trend-source: google-api@Singleton
@Requires(property = "app.trend-source", value = "mock")
public class MockTrendRepositoryAdapter implements TrendRepository { }
@Singleton
@Requires(property = "app.trend-source", value = "google-api")
public class GoogleTrendsApiAdapter implements TrendRepository { }Micronaut injecte automatiquement le bon adapter !
Checklist Architecture Hexagonale
Pour vérifier que ton architecture est bien hexagonale :
- Le domain ne dépend de rien (pas d’import depuis infrastructure ou application)
- Les ports de sortie sont des interfaces dans le domain
- Les adaptateurs implémentent les ports de sortie
- Les use cases dépendent des interfaces (pas des implémentations)
- On peut remplacer un adapter sans toucher au domaine
- Les tests du domaine n’ont pas besoin d’infrastructure réelle
Résumé
Le pattern Ports & Adapters permet de protéger votre logique métier et de la rendre testable et flexible.
| Concept | Emplacement | Rôle | Exemple |
|---|---|---|---|
| Port d’entrée | application/port/input/ | Recevoir les requêtes externes | TrendController |
| Port de sortie | domain/port/output/ | Interface vers ressources externes | TrendRepository |
| Adaptateur | infrastructure/adapter/ | Implémentation technique | MockTrendRepositoryAdapter |
| Use Case | application/usecase/ | Logique métier orchestrée | GetTrendsUseCase |
| Domaine | domain/model/ | Entités métier pures | TrendQuery, TrendResult |
Règle d’or : Le domaine est au centre et ne dépend de rien. Tout dépend du domaine (inversion de dépendances).
Ressources
Prochaines étapes
Maintenant que vous comprenez le pattern Ports & Adapters, explorez comment organiser chaque couche de l’architecture.