ObjectMapper Avancé
Guide avancé pour configurer et utiliser ObjectMapper de Jackson dans Micronaut : configuration custom, pretty-printing, et gestion des données JSON arbitraires.
Ce guide complète DTOs et Mapping en explorant les cas d’usage avancés de ObjectMapper.
Créer un ObjectMapper Custom
Par défaut, Micronaut fournit un ObjectMapper configuré. Mais vous pouvez en créer des personnalisés pour des usages spécifiques.
Créer une Factory
// infrastructure/config/ObjectMapperFactory.java
package org.smoka.infrastructure.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import io.micronaut.context.annotation.Bean;
import io.micronaut.context.annotation.Factory;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
@Factory
public class ObjectMapperFactory {
/**
* ObjectMapper pour les logs (pretty-print activé).
*/
@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;
}
/**
* ObjectMapper compact pour les API externes.
*/
@Bean
@Named("compact")
@Singleton
public ObjectMapper compactObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.disable(SerializationFeature.INDENT_OUTPUT); // Compact
return mapper;
}
}Injecter dans vos Composants
ObjectMapper par Défaut
Injection par Défaut
@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 temporaire
try {
String json = objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(response);
logger.info("Response:\n{}", json);
} catch (Exception e) {
logger.error("Failed to serialize response", e);
}
return response;
}
}Créer une Classe Utilitaire
Pour éviter la répétition, créez un helper de logging JSON.
// infrastructure/util/JsonLogger.java
package org.smoka.infrastructure.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
@Singleton
public class JsonLogger {
private final ObjectMapper loggingMapper;
@Inject
public JsonLogger(@Named("logging") ObjectMapper loggingMapper) {
this.loggingMapper = loggingMapper;
}
public void logInfo(Logger logger, String message, Object object) {
try {
String json = loggingMapper.writeValueAsString(object);
logger.info("{}\n{}", message, json);
} catch (Exception e) {
logger.error("Failed to serialize object for logging", e);
}
}
public void logDebug(Logger logger, String message, Object object) {
try {
String json = loggingMapper.writeValueAsString(object);
logger.debug("{}\n{}", message, json);
} catch (Exception e) {
logger.error("Failed to serialize object for logging", e);
}
}
}Utilisation :
@Inject
private JsonLogger jsonLogger;
@Get
public TrendResponseDto getTrends(@QueryValue String keyword) {
TrendResponseDto response = // ...
jsonLogger.logInfo(logger, "Trend response:", response);
return response;
}Données JSON Arbitraires
Comment gérer des données JSON non structurées (sessions Redis, metadata, cache) tout en respectant l’architecture hexagonale ?
Problème : Le domain ne doit PAS dépendre de Jackson (JsonNode = Jackson), mais on veut stocker du JSON flexible.
Le Problème
Mauvais
JsonNode dans le Domain (INTERDIT)
// domain/model/session/Session.java
@Value
public class Session {
String sessionId;
JsonNode metadata; // ❌ Jackson dans le domain !
}Problèmes :
- Le domain dépend de Jackson
- Viole l’architecture hexagonale
- Difficile à tester sans Jackson
Exemple Complet : Session Redis
Voyons comment gérer des sessions avec metadata flexible en respectant l’architecture hexagonale.
Architecture
- SessionRedisDto.java
- SessionRepositoryAdapter.java
Flux de Données
Client REST (envoie JSON)
↓
SessionController
↓ SessionDto { metadata: Map<String, Object> }
↓ Conversion DTO → Domain
↓
CreateSessionUseCase
↓ Session { metadata: Map<String, Object> }
↓
SessionRepository (interface)
↓
SessionRepositoryAdapter
↓ fromDomain() : Session → SessionRedisDto
↓ objectMapper.valueToTree() : Map → JsonNode
↓
Redis (stockage avec JsonNode)Implémentation
Domain Layer (PUR)
// domain/model/session/Session.java
package org.smoka.domain.model.session;
import lombok.Value;
import java.time.Instant;
import java.util.Map;
@Value
public class Session {
String sessionId;
String userId;
Map<String, Object> metadata; // ✅ Pas de Jackson
Instant createdAt;
}// domain/port/output/SessionRepository.java
package org.smoka.domain.port.output;
import org.smoka.domain.model.session.Session;
import java.util.Optional;
public interface SessionRepository {
void save(Session session);
Optional<Session> findById(String sessionId);
void deleteById(String sessionId);
}Application Layer
// application/port/input/dto/SessionDto.java
package org.smoka.application.port.input.dto;
import lombok.Data;
import java.time.Instant;
import java.util.Map;
@Data
public class SessionDto {
private String sessionId;
private String userId;
private Map<String, Object> metadata; // ← Reçu depuis le client REST
private Instant createdAt;
}// application/port/input/SessionController.java
@Controller("/api/sessions")
public class SessionController {
private final CreateSessionUseCase createSessionUseCase;
@Post
public SessionDto createSession(@Body SessionDto dto) {
// DTO → Domain
Session session = new Session(
dto.getSessionId(),
dto.getUserId(),
dto.getMetadata(), // Map<String, Object>
dto.getCreatedAt()
);
// Exécuter le use case
Session created = createSessionUseCase.execute(session);
// Domain → DTO
SessionDto response = new SessionDto();
response.setSessionId(created.getSessionId());
response.setUserId(created.getUserId());
response.setMetadata(created.getMetadata());
response.setCreatedAt(created.getCreatedAt());
return response;
}
}Infrastructure Layer
// infrastructure/dto/SessionRedisDto.java
package org.smoka.infrastructure.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SessionRedisDto {
@JsonProperty("sessionId")
private String sessionId;
@JsonProperty("userId")
private String userId;
@JsonProperty("metadata")
private JsonNode metadata; // ✅ Jackson dans l'infrastructure
@JsonProperty("createdAt")
private String createdAt;
}Adapter avec Conversions
// infrastructure/adapter/SessionRepositoryAdapter.java
package org.smoka.infrastructure.adapter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import org.smoka.domain.model.session.Session;
import org.smoka.domain.port.output.SessionRepository;
import org.smoka.infrastructure.dto.SessionRedisDto;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
@Singleton
public class SessionRepositoryAdapter implements SessionRepository {
private final ObjectMapper objectMapper;
@Inject
public SessionRepositoryAdapter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void save(Session session) {
// Domain → DTO
SessionRedisDto dto = fromDomain(session);
// Sérialiser et stocker dans Redis
try {
String json = objectMapper.writeValueAsString(dto);
// redisClient.set(session.getSessionId(), json);
} catch (Exception e) {
throw new RuntimeException("Failed to save session", e);
}
}
@Override
public Optional<Session> findById(String sessionId) {
try {
// String json = redisClient.get(sessionId);
// if (json == null) return Optional.empty();
// Désérialiser depuis Redis
// SessionRedisDto dto = objectMapper.readValue(json, SessionRedisDto.class);
// DTO → Domain
// return Optional.of(toDomain(dto));
return Optional.empty();
} catch (Exception e) {
throw new RuntimeException("Failed to find session", e);
}
}
@Override
public void deleteById(String sessionId) {
// redisClient.delete(sessionId);
}
/**
* Convertit Session (domain) en SessionRedisDto (infrastructure).
* Conversion : Map<String, Object> → JsonNode
*/
private SessionRedisDto fromDomain(Session session) {
SessionRedisDto dto = new SessionRedisDto();
dto.setSessionId(session.getSessionId());
dto.setUserId(session.getUserId());
dto.setCreatedAt(session.getCreatedAt().toString());
// ✅ Map<String, Object> → JsonNode
dto.setMetadata(objectMapper.valueToTree(session.getMetadata()));
return dto;
}
/**
* Convertit SessionRedisDto (infrastructure) en Session (domain).
* Conversion : JsonNode → Map<String, Object>
*/
private Session toDomain(SessionRedisDto dto) {
// ✅ JsonNode → Map<String, Object>
Map<String, Object> metadata = objectMapper.convertValue(
dto.getMetadata(),
new TypeReference<Map<String, Object>>() {}
);
return new Session(
dto.getSessionId(),
dto.getUserId(),
metadata,
Instant.parse(dto.getCreatedAt())
);
}
}Méthodes ObjectMapper Clés
Map vers JsonNode
Convertir Map vers JsonNode
Map<String, Object> map = Map.of(
"key", "value",
"count", 42,
"active", true
);
JsonNode jsonNode = objectMapper.valueToTree(map);Cas d’usage : Convertir les metadata du domain vers le DTO infrastructure.
Autres Approches
Map Recommandé
Approche Map (Recommandée)
Domain :
@Value
public class Session {
String sessionId;
Map<String, Object> metadata; // ✅ Flexible et pur
}Avantages :
- ✅ Domain indépendant de Jackson
- ✅ Haute flexibilité
- ✅ Facile à manipuler
- ✅ Type-safety limitée mais acceptable
Inconvénients :
- ⚠️ Pas de validation de structure
- ⚠️ Cast nécessaire pour typer les valeurs
Quand l’utiliser :
- Metadata dynamiques
- Configuration utilisateur
- Payload flexible
Comparaison
| Approche | Flexibilité | Type-Safety | Complexité | Indépendance Domain |
|---|---|---|---|---|
| Map<String, Object> ⭐ | ✅ Haute | ⚠️ Limitée | ⭐ Simple | ✅ Totale |
| String (JSON brut) | ✅ Haute | ❌ Aucune | ⭐ Très simple | ✅ Totale |
| Type dédié | ❌ Faible | ✅ Forte | ⭐⭐⭐ Complexe | ✅ Totale |
Recommandation : Utilisez Map<String, Object> pour un bon compromis entre flexibilité et pureté du domain.
Cas d’Usage Pratiques
Cette approche avec Map<String, Object> est idéale pour :
Sessions Redis
Sessions Redis
@Value
public class Session {
String sessionId;
String userId;
Map<String, Object> metadata; // Theme, langue, préférences
Instant createdAt;
}Exemple metadata :
{
"theme": "dark",
"language": "fr",
"notifications": true,
"lastActivity": "2025-10-18T16:30:00Z"
}Configuration via application.yml
Configurez l’ObjectMapper global directement dans application.yml.
jackson:
serialization:
indentOutput: true # Pretty-print
writeDatesAsTimestamps: false # Dates en ISO-8601
failOnEmptyBeans: false # Ne pas échouer si objet vide
deserialization:
failOnUnknownProperties: false # Ignorer propriétés inconnues
acceptSingleValueAsArray: true # "value" → ["value"]
module-scan: true # Scanner modules Jackson auto
defaultPropertyInclusion: non_null # N'inclure que les non-nullCette configuration s’applique à tous les ObjectMapper créés par Micronaut (sauf ceux créés manuellement).
Checklist Architecture Hexagonale
Pour vérifier que vous utilisez ObjectMapper correctement :
- Le domain n’importe AUCUNE classe Jackson
- Les entités du domain utilisent
Map<String, Object>ouString(pasJsonNode) - Les DTOs infrastructure sont dans
infrastructure/dto/ - Les DTOs application sont dans
application/port/input/dto/ - La conversion DTO infrastructure ↔ Domain est dans l’adaptateur
- L’adaptateur utilise
objectMapper.valueToTree()pourMap → JsonNode - L’adaptateur utilise
objectMapper.convertValue()pourJsonNode → Map - Le domain peut être testé sans Jackson
-
jackson.module-scan: truedansapplication.yml
Résumé
Utilisez des ObjectMapper custom pour des besoins spécifiques (logs, API externes). Pour les données JSON arbitraires, préférez Map<String, Object> dans le domain au lieu de JsonNode.
| Concept | Emplacement | Utilise Jackson ? | Format Données |
|---|---|---|---|
| Domain | domain/model/ | ❌ NON | Map<String, Object> |
| DTO Application | application/port/input/dto/ | ❌ NON | Map<String, Object> |
| DTO Infrastructure | infrastructure/dto/ | ✅ OUI | JsonNode |
| Adapter | infrastructure/adapter/ | ✅ OUI | Conversions Map ↔ JsonNode |
Règle d’or : Jackson reste confiné dans infrastructure/. Le domain est pur et indépendant.
Prochaines étapes
Vous avez maintenant terminé la section Implementation ! Passez aux bonnes pratiques pour écrire du code production-ready.