Design Patterns Structurels en Java (partie 2/2) : Facade et Proxy

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
8 min de lecture
4 vues

Écouter l'article

Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.


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

Diagramme de classes (diagramme 1)
Diagramme de classes (diagramme 1)

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 @Transactional sur registerBatch : l'appel interne (this.registerUser(...)) ne passe pas par le proxy transactionnel, donc l'annotation de registerUser est 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 @Transactional sur registerBatch : 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, ou registerUser s'il est appelé via un autre bean), la transaction est marquée « rollback-only », même si le try/catch avale l'exception. Le commit final échoue alors avec une UnexpectedRollbackException, 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

Diagramme de classes (diagramme 2)
Diagramme de classes (diagramme 2)

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 :

  1. Ouvre la transaction avant l'appel
  2. Délègue au bean réel
  3. 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.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.