Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.
- Créationnels : Singleton et Factory Method (1/2) · Abstract Factory et Builder (2/2)
- Structurels : Adapter et Decorator (1/2) · Facade et Proxy (2/2, cet article)
- Comportementaux, orchestration : Strategy et Observer (1/2) · Template Method et State (2/2)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)
Les patterns structurels décident comment les objets se connectent sans que l'on modifie leur code. Cette seconde partie traite Facade, un point d'entrée unique devant plusieurs sous-systèmes, et Proxy, un intermédiaire qui contrôle l'accès à un objet (cache, transaction, droits). Elle se lit sans la première partie (Adapter et Decorator), à laquelle elle renvoie pour la comparaison Proxy et Decorator et pour l'exemple final.
Le code est illustratif et simplifié : les classes non définies (Order, User, UserRepository, Product, ProductRepository…) sont supposées exister. Les noms suivent les conventions Java et le rôle du pattern en suffixe, comme dans le reste de la série ; les diagrammes UML reprennent les noms du code.. Les exemples ont été compilés et exécutés avec le JDK 21 et Spring Boot 3.5 ; les versions minimales de Java sont indiquées quand une fonctionnalité en dépend.
Facade : simplifier sans cacher
Le problème
Créer un utilisateur dans une application web ne se limite pas à userRepository.save(). Il faut vérifier les doublons, hasher le mot de passe, assigner les rôles, créer le profil, envoyer l'e-mail de bienvenue, journaliser l'événement d'audit et notifier les administrateurs. Si le contrôleur orchestre tout cela, il devient vite long et difficile à tester. Et si la même logique est dupliquée entre le contrôleur REST et un import batch, c'est pire.
La Facade fournit une interface simplifiée à un ensemble de sous-systèmes : un seul point d'entrée, un seul appel, la complexité encapsulée.
Le diagramme UML
L'implémentation
Les sous-systèmes, chacun avec une responsabilité unique :
@Component
public class UserValidator {
private final UserRepository userRepository;
public UserValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void validateUniqueness(String username, String email) {
if (userRepository.existsByUsername(username)) {
throw new BadRequestException("Nom d'utilisateur déjà pris : " + username);
}
if (userRepository.existsByEmail(email)) {
throw new BadRequestException("E-mail déjà utilisé : " + email);
}
}
public void validatePasswordStrength(String password) {
List<String> errors = new ArrayList<>();
if (password.length() < 8) errors.add("Minimum 8 caractères");
if (!password.matches(".*[A-Z].*")) errors.add("Une majuscule requise");
if (!password.matches(".*[a-z].*")) errors.add("Une minuscule requise");
if (!password.matches(".*\\d.*")) errors.add("Un chiffre requis");
if (!password.matches(".*[@#$%^&+=!].*")) errors.add("Un caractère spécial requis");
if (!errors.isEmpty()) {
throw new BadRequestException(
"Mot de passe trop faible : " + String.join(", ", errors));
}
}
}
@Component
public class PasswordService {
private final PasswordEncoder encoder;
public PasswordService(PasswordEncoder encoder) {
this.encoder = encoder;
}
public String hash(String rawPassword) {
return encoder.encode(rawPassword);
}
public boolean verify(String rawPassword, String hashedPassword) {
return encoder.matches(rawPassword, hashedPassword);
}
}
@Component
public class AuditService {
private static final Logger log = LoggerFactory.getLogger(AuditService.class);
private final AuditLogRepository auditLogRepository;
public AuditService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
public void logEvent(String action, String entity, Long entityId) {
AuditLog entry = AuditLog.builder()
.action(action)
.entityType(entity)
.entityId(entityId)
.timestamp(LocalDateTime.now())
.performedBy(SecurityUtils.getCurrentUsername())
.build();
auditLogRepository.save(entry);
log.info("Audit : {} sur {} #{}", action, entity, entityId);
}
}
La Facade, un point d'entrée qui orchestre :
@Service
public class UserRegistrationFacade {
private static final Logger log = LoggerFactory.getLogger(UserRegistrationFacade.class);
private final UserRepository userRepository;
private final UserMapper userMapper;
private final UserValidator validator;
private final PasswordService passwordService;
private final RoleService roleService;
private final ProfileService profileService;
private final NotificationService notificationService;
private final AuditService auditService;
public UserRegistrationFacade(UserRepository userRepository,
UserMapper userMapper,
UserValidator validator,
PasswordService passwordService,
RoleService roleService,
ProfileService profileService,
NotificationService notificationService,
AuditService auditService) {
this.userRepository = userRepository;
this.userMapper = userMapper;
this.validator = validator;
this.passwordService = passwordService;
this.roleService = roleService;
this.profileService = profileService;
this.notificationService = notificationService;
this.auditService = auditService;
}
/**
* Inscription complète d'un utilisateur, en une seule transaction.
*/
@Transactional
public UserResponse registerUser(RegisterRequest request) {
log.info("Inscription en cours : {}", request.username());
// 1. Validation
validator.validateUniqueness(request.username(), request.email());
validator.validatePasswordStrength(request.password());
// 2. Création de l'entité
User user = userMapper.toEntity(request);
user.setPassword(passwordService.hash(request.password()));
// 3. Persistance
User savedUser = userRepository.save(user);
// 4. Rôles
roleService.assignDefaultRoles(savedUser);
// 5. Profil
profileService.createProfile(savedUser);
// 6. Notifications : appels synchrones, exécutés dans la transaction.
// Un échec d'envoi fait échouer l'inscription, et un e-mail peut partir
// alors que la transaction est ensuite annulée.
notificationService.sendWelcomeEmail(savedUser);
notificationService.notifyAdmins("USER_REGISTERED", savedUser);
// 7. Audit
auditService.logEvent("REGISTER", "User", savedUser.getId());
log.info("Inscription réussie : {} (id={})", savedUser.getUsername(), savedUser.getId());
return userMapper.toResponse(savedUser);
}
}
Le point 6 mérite une attention particulière. Pour envoyer l'e-mail sans bloquer l'inscription ni risquer de l'envoyer avant un rollback, on publie plutôt un événement applicatif traité après le commit avec @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT), éventuellement combiné à @Async (voir la partie 1 des patterns d'orchestration, consacrée à l'Observer).
L'import batch vit dans une classe séparée :
@Service
public class UserBatchImporter {
private final UserRegistrationFacade registrationFacade;
public UserBatchImporter(UserRegistrationFacade registrationFacade) {
this.registrationFacade = registrationFacade;
}
/**
* Import batch : même logique, une transaction par utilisateur,
* gestion des erreurs ligne par ligne. Pas de @Transactional ici.
*/
public BatchResult registerBatch(List<RegisterRequest> requests) {
BatchResult result = new BatchResult();
for (RegisterRequest request : requests) {
try {
UserResponse response = registrationFacade.registerUser(request);
result.addSuccess(response);
} catch (RuntimeException e) {
result.addError(request.username(), e.getMessage());
}
}
return result;
}
}
Pourquoi ne pas simplement appeler registerUser depuis une méthode registerBatch de la même classe, en capturant l'exception ? Deux scénarios se présentent, avec deux problèmes différents.
- Sans
@TransactionalsurregisterBatch: l'appel interne (this.registerUser(...)) ne passe pas par le proxy transactionnel, donc l'annotation deregisterUserest ignorée et aucune transaction englobante n'existe. Chaque appel de repository s'exécute alors dans sa propre mini-transaction : une inscription qui échoue à mi-parcours laisse des écritures partielles (un utilisateur enregistré sans profil, par exemple). - Avec
@TransactionalsurregisterBatch: toutes les inscriptions partagent une seule transaction. Dès qu'une exception traverse un proxy transactionnel qui a rejoint cette transaction (le repository Spring Data, ouregisterUsers'il est appelé via un autre bean), la transaction est marquée « rollback-only », même si letry/catchavale l'exception. Le commit final échoue alors avec uneUnexpectedRollbackException, et le lot entier est perdu.
La conclusion est la même dans les deux cas : en passant par un bean distinct, et sans @Transactional sur registerBatch, chaque appel traverse le proxy et ouvre sa propre transaction. L'échec d'une ligne n'affecte pas les autres.
Le contrôleur devient trivial :
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRegistrationFacade registrationFacade;
public AuthController(UserRegistrationFacade registrationFacade) {
this.registrationFacade = registrationFacade;
}
@PostMapping("/register")
public ResponseEntity<ApiResponse<UserResponse>> register(
@Valid @RequestBody RegisterRequest request) {
UserResponse response = registrationFacade.registerUser(request);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ApiResponse.success("Inscription réussie", response));
}
}
Facade vs Service
En Spring, on pourrait soutenir que chaque @Service est une Facade. La distinction : un Service contient de la logique métier, une Facade orchestre des services existants sans logique propre. La UserRegistrationFacade n'a aucun if métier : elle appelle les sous-systèmes dans l'ordre. Si la logique de validation change, c'est UserValidator qui change. La Facade, elle, ne change que si le processus change (ajout d'une étape, changement d'ordre).
Proxy : contrôler l'accès sans modifier l'objet
Le problème
On a un service qui effectue des calculs coûteux ou des appels réseau. On veut ajouter du caching, du logging, du contrôle d'accès ou du chargement différé (lazy loading) sans modifier le service original. Le Proxy place un intermédiaire transparent entre le client et le service réel.
Le diagramme UML
Proxy de caching
public interface ProductCatalog {
Product getProduct(Long id);
List<Product> searchProducts(String query);
int getProductCount();
}
@Service
public class JpaProductCatalog implements ProductCatalog {
private final ProductRepository repository;
public JpaProductCatalog(ProductRepository repository) {
this.repository = repository;
}
@Override
public Product getProduct(Long id) {
return repository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Produit", "id", id));
}
@Override
public List<Product> searchProducts(String query) {
// Requête coûteuse avec full-text search
return repository.fullTextSearch(query);
}
@Override
public int getProductCount() {
return (int) repository.count();
}
}
Le Proxy de caching :
@Service
@Primary
public class CachingProductCatalogProxy implements ProductCatalog {
private static final Logger log = LoggerFactory.getLogger(CachingProductCatalogProxy.class);
private final ProductCatalog target;
private final CacheManager cacheManager;
public CachingProductCatalogProxy(@Qualifier("jpaProductCatalog") ProductCatalog target,
CacheManager cacheManager) {
this.target = target;
this.cacheManager = cacheManager;
}
@Override
public Product getProduct(Long id) {
Cache cache = cacheManager.getCache("products");
if (cache != null) {
Product cached = cache.get(id, Product.class);
if (cached != null) {
log.debug("Cache HIT pour produit #{}", id);
return cached;
}
}
log.debug("Cache MISS pour produit #{}", id);
Product product = target.getProduct(id);
if (cache != null) {
cache.put(id, product);
}
return product;
}
@Override
public List<Product> searchProducts(String query) {
Cache cache = cacheManager.getCache("product-searches");
if (cache != null) {
@SuppressWarnings("unchecked")
List<Product> cached = cache.get(query, List.class);
if (cached != null) {
log.debug("Cache HIT pour recherche '{}'", query);
return cached;
}
}
log.debug("Cache MISS pour recherche '{}'", query);
List<Product> results = target.searchProducts(query);
if (cache != null) {
cache.put(query, results);
}
return results;
}
@Override
public int getProductCount() {
// Pas de cache pour le count : toujours frais
return target.getProductCount();
}
}
Proxy dans Spring : le pattern invisible
Spring recourt massivement au Proxy, souvent sans que l'on s'en rende compte. Chaque bean @Transactional est enveloppé dans un proxy généré à l'exécution qui :
- Ouvre la transaction avant l'appel
- Délègue au bean réel
- Valide (commit) en cas de succès, annule (rollback) en cas d'exception
C'est du Proxy, généré automatiquement par Spring AOP. @Cacheable fonctionne de la même façon :
@Service
public class ProductService {
private static final Long FEATURED_PRODUCT_ID = 1L;
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
// Le proxy vérifie le cache AVANT d'appeler cette méthode
return repository.findById(id).orElseThrow();
}
@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
// Le proxy invalide le cache APRÈS la mise à jour réussie
return repository.save(product);
}
}
C'est le même principe que le CachingProductCatalogProxy écrit à la main, mais généré automatiquement.
Quel type de proxy ? Spring Framework utilise un proxy dynamique JDK quand le bean implémente une interface, et CGLIB sinon. Spring Boot change la valeur par défaut : avec spring.aop.proxy-target-class=true (valeur par défaut), il utilise CGLIB, c'est-à-dire une sous-classe générée, même quand le bean implémente une interface. Conséquence pratique : la classe et les méthodes proxifiées ne doivent pas être final.
Le piège de l'auto-invocation. Le proxy n'intercepte que les appels qui passent par lui. Un appel d'une méthode à une autre du même objet contourne le proxy, et l'annotation est alors ignorée :
@Service
public class ProductService {
// ... getProduct() annoté @Cacheable, comme ci-dessus
public Product getFeaturedProduct() {
// Appel interne (this.getProduct) : le proxy est contourné, pas de cache.
// Le même piège s'applique à @Transactional, @Async, @Retryable...
return getProduct(FEATURED_PRODUCT_ID);
}
}
Pour s'en sortir, on déplace la méthode annotée dans un autre bean, que l'on injecte, ou on passe par TransactionTemplate / CacheManager explicitement.
Proxy vs Decorator : la confusion classique
Structurellement, Proxy et Decorator sont presque identiques : les deux enveloppent un objet qui expose la même interface. Un CachingProductCatalogProxy et un CachingProductDecorator auraient exactement le même code. Ce qui les distingue est l'intention :
- Decorator : ajouter des responsabilités, et pouvoir les empiler et les combiner librement. Chaque décorateur apporte un comportement supplémentaire à l'objet.
- Proxy : contrôler l'accès à un objet (le protéger, le mettre en cache, différer sa création, le rendre distant) sans changer ce qu'il fait. Le proxy gère souvent lui-même le cycle de vie ou l'accès au sujet réel.
Dans les deux cas, le client ne manipule que l'interface commune. Un LoggingDecorator(new CompressionDecorator(new CsvExporter())) relève du Decorator : chaque couche ajoute un comportement et on choisit l'assemblage. Le proxy @Transactional relève du Proxy : il contrôle l'accès au service (ouverture et fermeture de la transaction autour de l'appel) sans enrichir sa logique métier.
Les patterns structurels en synergie
Dans une application réelle, les patterns structurels se combinent naturellement :
// Facade qui orchestre des Adapters (partie 1) et utilise un Proxy de cache
@Service
public class PaymentFacade {
private final PaymentProcessor paymentProcessor; // Adapter (Stripe)
private final CurrencyConverter currencyConverter; // Adapter (API de la BCE)
private final FraudDetectionService fraudService; // Proxy (met les scores en cache)
private final AuditService auditService;
public PaymentFacade(PaymentProcessor paymentProcessor,
CurrencyConverter currencyConverter,
FraudDetectionService fraudService,
AuditService auditService) {
this.paymentProcessor = paymentProcessor;
this.currencyConverter = currencyConverter;
this.fraudService = fraudService;
this.auditService = auditService;
}
public PaymentResult processPayment(PaymentRequest request) {
Currency eur = Currency.getInstance("EUR");
// 1. Convertir la devise si nécessaire (Adapter)
BigDecimal amount = currencyConverter.convert(
request.amount(), request.currency(), eur);
// 2. Vérifier la fraude (Proxy avec cache)
FraudScore score = fraudService.evaluate(request.customerId(), amount);
if (score.isHighRisk()) {
throw new PaymentException("Transaction bloquée : risque de fraude");
}
// 3. Exécuter le paiement (Adapter) avec le montant converti
PaymentRequest converted = new PaymentRequest(
request.orderId(), request.customerId(), amount, eur,
request.paymentMethodToken(), request.description());
PaymentResult result = paymentProcessor.processPayment(converted);
// 4. Audit
auditService.logEvent("PAYMENT", "Order", request.orderId());
return result;
}
}
Ce qu'il faut retenir
| Pattern | Intention | Signal d'utilisation |
|---|---|---|
| Facade | Simplifier un ensemble complexe | Orchestration multi-services |
| Proxy | Contrôler l'accès à un objet | Cache, logging, sécurité, lazy loading |
Les patterns comportementaux (partie 1 : Strategy et Observer) définissent ensuite comment les objets communiquent et réagissent.
Partie précédente : Adapter et Decorator. Partie suivante : Strategy et Observer.
Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.
- Créationnels : Singleton et Factory Method (1/2) · Abstract Factory et Builder (2/2)
- Structurels : Adapter et Decorator (1/2) · Facade et Proxy (2/2, cet article)
- Comportementaux, orchestration : Strategy et Observer (1/2) · Template Method et State (2/2)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)