DTOs et Mapping avec Jackson
Guide complet sur l’utilisation des DTOs (Data Transfer Objects) et la conversion avec Jackson dans l’architecture hexagonale.
Règle d’or : Le domain ne doit JAMAIS dépendre de Jackson ou toute autre technologie de sérialisation.
Principe Fondamental
Dans l’architecture hexagonale, le domain doit rester pur et indépendant des frameworks.
❌ Mauvais
Jackson dans le Domain
// domain/model/Country.java
@Value
public class Country {
@JsonProperty("name") // ❌ Jackson dans le domain !
Name name;
@JsonDeserialize(using = CustomDeserializer.class) // ❌
List<Translation> translations;
}Problème : Le domain dépend de Jackson → impossible de changer de librairie sans casser le domain.
Architecture avec DTOs
Structure clé :
domain/model= Entités PURES (pas de Jackson)infrastructure/dto= DTOs avec annotations Jacksoninfrastructure/adapter= Conversion DTO ↔ Domain
Flux de Données
Voyons comment les données traversent les couches.
API Externe renvoie du JSON
{
"name": {
"common": "Lithuania",
"official": "Republic of Lithuania"
},
"translations": {
"ara": { "official": "جمهورية ليتوانيا", "common": "ليتوانيا" },
"fra": { "official": "République de Lituanie", "common": "Lituanie" }
}
}Jackson désérialise vers DTO (automatique)
// infrastructure/dto/CountryApiDto.java
@Data
@NoArgsConstructor
public class CountryApiDto {
@JsonProperty("name")
private NameDto name;
@JsonProperty("translations")
private Map<String, TranslationDto> translations;
}Le HttpClient de Micronaut utilise Jackson automatiquement.
Adaptateur convertit DTO → Domain
// infrastructure/adapter/CountryRepositoryAdapter.java
private Country toDomain(CountryApiDto dto) {
Name name = new Name(
dto.getName().getCommon(),
dto.getName().getOfficial()
);
List<Translation> translations = convertTranslations(
dto.getTranslations()
);
return new Country(name, translations);
}Domain pur utilisé par les Use Cases
// domain/model/countries/Country.java
@Value
public class Country {
Name name;
List<Translation> translations; // ← List (idiomatique)
}À chaque étape, le domain reste pur et indépendant de Jackson.
Conversion dans l’Adaptateur
L’adaptateur est responsable de la traduction entre le monde technique (DTOs) et le domain.
Exemple Complet
@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 → Jackson désérialise automatiquement
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);
}
private List<Translation> convertTranslations(
Map<String, TranslationDto> translationsMap
) {
if (translationsMap == null) {
return List.of();
}
return translationsMap.entrySet().stream()
.map(entry -> new Translation(
entry.getKey(), // languageCode
entry.getValue().getCommon(),
entry.getValue().getOfficial()
))
.collect(Collectors.toList());
}
}Où faire la conversion ?
- ❌ MAUVAIS : Dans le DTO (créerait une dépendance DTO → Domain)
- ✅ BON : Dans l’Adaptateur (responsabilité de traduction)
ObjectMapper dans Micronaut
Configuration par Défaut
Micronaut crée automatiquement un ObjectMapper configuré.
Dans application.yml :
jackson:
serialization:
indentOutput: true # Pretty-print JSON
writeDatesAsTimestamps: false # Dates en ISO-8601
deserialization:
failOnUnknownProperties: false # Ignorer propriétés inconnues
module-scan: true # Scanner modules Jackson autoInjection de l’ObjectMapper
@Controller("/api/trends")
public class TrendController {
private final ObjectMapper objectMapper;
@Inject
public TrendController(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Get
public TrendResponseDto getTrends(@QueryValue String keyword) {
TrendResponseDto response = // ... logique
// Logger avec pretty-print
try {
String json = objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(response);
logger.info("Response:\n{}", json);
} catch (Exception e) {
logger.error("Failed to serialize response", e);
}
return response;
}
}ObjectMapper Custom
Créer un ObjectMapper dédié
Utile pour avoir différentes configurations (logs, API externes, etc.).
@Factory
public class ObjectMapperFactory {
@Bean
@Named("logging")
@Singleton
public ObjectMapper loggingObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT); // Pretty-print
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
@Bean
@Named("compact")
@Singleton
public ObjectMapper compactObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.disable(SerializationFeature.INDENT_OUTPUT); // Compact
return mapper;
}
}Injection d’un ObjectMapper custom
@Controller("/api/trends")
public class TrendController {
private final ObjectMapper loggingMapper;
@Inject
public TrendController(@Named("logging") ObjectMapper loggingMapper) {
this.loggingMapper = loggingMapper;
}
@Get
public TrendResponseDto getTrends(@QueryValue String keyword) {
TrendResponseDto response = // ... logique
// Logger avec l'ObjectMapper dédié (déjà pretty-print)
try {
String json = loggingMapper.writeValueAsString(response);
logger.info("Response:\n{}", json);
} catch (Exception e) {
logger.error("Failed to serialize response", e);
}
return response;
}
}Données JSON Arbitraires
Comment gérer des données JSON non structurées (Redis, sessions, metadata) sans violer l’architecture hexagonale ?
Le Problème
// ❌ MAUVAIS - JsonNode dans le domain
@Value
public class Session {
String sessionId;
JsonNode metadata; // ❌ Jackson dans le domain !
}Solution : Map<String, Object>
Domain
Domain (Pur)
@Value
public class Session {
String sessionId;
String userId;
Map<String, Object> metadata; // ✅ Java pur
Instant createdAt;
}Méthodes ObjectMapper Clés
// Map<String, Object> → JsonNode
JsonNode jsonNode = objectMapper.valueToTree(map);
// JsonNode → Map<String, Object>
Map<String, Object> map = objectMapper.convertValue(
jsonNode,
new TypeReference<Map<String, Object>>() {}
);
// String (JSON brut) → JsonNode
JsonNode jsonNode = objectMapper.readTree(json);
// JsonNode → String (JSON brut)
String json = objectMapper.writeValueAsString(jsonNode);Annotations Jackson Utiles
Pour vos DTOs dans infrastructure/dto/ :
@Data
public class TrendResponseDto {
@JsonProperty("keyword")
private String keyword;
@JsonProperty("interest_score") // Snake case dans le JSON
private Integer interestScore;
@JsonInclude(JsonInclude.Include.NON_NULL) // Ne pas inclure si null
private List<RelatedTopic> relatedTopics;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime queriedAt;
@JsonIgnore // Ne pas sérialiser ce champ
private String internalData;
}Checklist Architecture Hexagonale
Pour vérifier que tu utilises correctement Jackson :
- Le domain n’importe AUCUNE classe Jackson (
@JsonProperty,JsonNode, etc.) - Les DTOs sont dans
infrastructure/dto/ - Les DTOs utilisent
@Data+@NoArgsConstructor(Lombok) - Les DTOs mappent exactement la structure JSON de l’API externe
- La conversion DTO → Domain est dans l’adaptateur (méthode
toDomain) - Les DTOs ne connaissent PAS le domain (pas d’import
org.smoka.domain.*) - Pour les données arbitraires, le domain utilise
Map<String, Object>(pasJsonNode) -
jackson.module-scan: truedansapplication.yml
Résumé
Les DTOs permettent de protéger le domain des dépendances techniques tout en facilitant la communication avec les APIs externes.
| Concept | Emplacement | Utilise Jackson ? | Rôle |
|---|---|---|---|
| DTO | infrastructure/dto/ | ✅ Oui | Mapper le JSON de l’API externe |
| Adaptateur | infrastructure/adapter/ | ✅ Oui (automatique via HttpClient) | Convertir DTO ↔ Domain |
| Domain | domain/model/ | ❌ NON | Entités métier pures |
Règle d’or : Jackson reste confiné dans infrastructure/. Le domain est pur et indépendant.
Cas d’Usage
Cette approche est idéale pour :
- ✅ APIs REST externes (désérialisation JSON)
- ✅ Sessions Redis avec métadonnées dynamiques
- ✅ Cache avec données arbitraires
- ✅ Configuration utilisateur personnalisable
- ✅ Webhooks/intégrations externes avec structure variable
Prochaines étapes
Maintenant que vous maîtrisez les DTOs et Jackson, explorez comment créer des ports et adapters concrets.