Design Patterns Structurels en Java (partie 1/2) : Adapter et Decorator

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

Écouter l'article

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


On résume souvent la différence entre les familles ainsi : le code de création est l'usine, le code structurel est l'architecture du bâtiment. Les patterns créationnels (partie 1 et partie 2) décident comment fabriquer les objets. Les patterns structurels décident comment ces objets se connectent, s'adaptent et coopèrent sans que l'on modifie leur code source.

On les utilise souvent sans les nommer : un wrapper autour d'une API externe est un Adapter, du logging ajouté autour d'un service sans le modifier relève du Decorator, et une classe PaymentService qui orchestre cinq sous-systèmes est une Facade.

Cette première partie traite Adapter et Decorator ; la seconde traite Facade et Proxy. Chaque pattern est présenté avec un diagramme UML, du code complet et ses manifestations dans Spring et dans le JDK.

Le code est illustratif et simplifié : les classes non définies (Order, Customer, ExportException…) sont supposées exister, et Order est supposée avec des accesseurs et des modificateurs (contrairement à la version immuable construite par le Builder de la partie 2 des patterns créationnels). 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.


Adapter : faire cohabiter l'incompatible

Le problème

Soit une application dont le code métier appelle directement un service de paiement « maison », à de nombreux endroits. Le jour où l'on décide de passer à Stripe, il faudrait modifier chacun de ces appels, ce qui est long et risqué. En introduisant une interface PaymentProcessor que le code métier utilise, et un Adapter qui traduit ces appels vers l'API Stripe, on branche le nouveau fournisseur sans toucher à la logique métier.

L'Adapter convertit l'interface d'une classe existante (ici, l'API du fournisseur) en l'interface attendue par le client. Il fait le pont entre deux interfaces incompatibles.

Le diagramme UML

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

L'implémentation complète

L'interface cible, c'est-à-dire ce que le code métier utilise :

public interface PaymentProcessor {

    PaymentResult processPayment(PaymentRequest request);

    RefundResult refund(String transactionId, BigDecimal amount, Currency currency);

    TransactionStatus getTransactionStatus(String transactionId);
}

public record PaymentRequest(
    Long orderId,
    String customerId,
    BigDecimal amount,
    Currency currency,
    String paymentMethodToken,
    String description
) {}

public record PaymentResult(
    boolean success,
    String transactionId,
    String message,
    LocalDateTime processedAt
) {}

public record RefundResult(
    boolean success,
    String refundId,
    BigDecimal refundedAmount
) {}

public enum TransactionStatus {
    PENDING, COMPLETED, FAILED, REFUNDED, CANCELLED
}

L'adaptateur Stripe traduit chaque appel. Le PayPalPaymentAdapter du diagramme suivrait le même schéma ; il n'est pas détaillé ici.

@Service
@Primary
@Profile("stripe")
public class StripePaymentAdapter implements PaymentProcessor {

    private static final Logger log = LoggerFactory.getLogger(StripePaymentAdapter.class);

    private final StripeClient stripeClient;

    public StripePaymentAdapter(@Value("${stripe.api.key}") String apiKey) {
        this.stripeClient = new StripeClient(apiKey);
    }

    @Override
    public PaymentResult processPayment(PaymentRequest request) {
        try {
            PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
                .setAmount(toMinorUnits(request.amount(), request.currency()))
                .setCurrency(request.currency().getCurrencyCode().toLowerCase(Locale.ROOT))
                .setCustomer(request.customerId())
                .setPaymentMethod(request.paymentMethodToken())
                .setDescription(request.description())
                .setConfirm(true)
                .build();

            PaymentIntent intent = stripeClient.paymentIntents().create(params);

            log.info("Paiement Stripe créé : {}", intent.getId());

            return new PaymentResult(
                "succeeded".equals(intent.getStatus()),
                intent.getId(),
                mapStripeStatus(intent.getStatus()),
                LocalDateTime.now()
            );
        } catch (StripeException e) {
            log.error("Erreur Stripe : {}", e.getMessage());
            return new PaymentResult(false, null, e.getMessage(), LocalDateTime.now());
        }
    }

    @Override
    public RefundResult refund(String transactionId, BigDecimal amount, Currency currency) {
        try {
            RefundCreateParams params = RefundCreateParams.builder()
                .setPaymentIntent(transactionId)
                .setAmount(toMinorUnits(amount, currency))
                .build();

            Refund refund = stripeClient.refunds().create(params);

            return new RefundResult(
                "succeeded".equals(refund.getStatus()),
                refund.getId(),
                amount
            );
        } catch (StripeException e) {
            log.error("Erreur de remboursement Stripe : {}", e.getMessage());
            return new RefundResult(false, null, BigDecimal.ZERO);
        }
    }

    @Override
    public TransactionStatus getTransactionStatus(String transactionId) {
        try {
            PaymentIntent intent = stripeClient.paymentIntents()
                .retrieve(transactionId);
            return mapToInternalStatus(intent.getStatus());
        } catch (StripeException e) {
            log.error("Erreur de statut Stripe : {}", e.getMessage());
            return TransactionStatus.FAILED;
        }
    }

    /**
     * Stripe attend le montant dans la plus petite unité de la devise.
     * Multiplier par 100 est faux pour le yen (0 décimale) ou le dinar koweïtien (3 décimales).
     */
    private long toMinorUnits(BigDecimal amount, Currency currency) {
        int digits = Math.max(currency.getDefaultFractionDigits(), 0);
        return amount.movePointRight(digits)
            .setScale(0, RoundingMode.HALF_UP)
            .longValueExact();
    }

    private TransactionStatus mapToInternalStatus(String stripeStatus) {
        return switch (stripeStatus) {
            case "succeeded" -> TransactionStatus.COMPLETED;
            case "processing", "requires_action" -> TransactionStatus.PENDING;
            case "canceled" -> TransactionStatus.CANCELLED;
            default -> TransactionStatus.FAILED;
        };
    }

    private String mapStripeStatus(String status) {
        return switch (status) {
            case "succeeded" -> "Paiement accepté";
            case "processing" -> "Paiement en cours de traitement";
            case "requires_action" -> "Action requise (3D Secure)";
            default -> "Échec du paiement";
        };
    }
}

La conversion en unités mineures s'appuie sur Currency.getDefaultFractionDigits() : 2 pour l'euro, 0 pour le yen, 3 pour le dinar koweïtien. Quelques devises ont des règles particulières côté Stripe (certaines à trois décimales exigent un multiple de 10, d'autres sont traitées comme sans décimale), il faut donc vérifier la documentation du fournisseur pour les devises que l'on supporte réellement.

Le code métier reste intact : il ne connaît que PaymentProcessor.

@Service
public class OrderPaymentService {

    private final PaymentProcessor paymentProcessor;  // Interface, pas implémentation

    public OrderPaymentService(PaymentProcessor paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }

    public void processOrderPayment(Order order) {
        PaymentResult result = paymentProcessor.processPayment(
            new PaymentRequest(
                order.getId(),
                order.getCustomer().getStripeId(),
                order.getTotal(),
                Currency.getInstance("EUR"),
                order.getPaymentToken(),
                "Commande " + order.getOrderNumber()
            )
        );

        if (!result.success()) {
            throw new PaymentException("Paiement refusé : " + result.message());
        }

        order.setTransactionId(result.transactionId());
        order.setStatus(OrderStatus.PAID);
    }
}

Adapter dans le JDK et Spring

On retrouve l'Adapter à de nombreux endroits du JDK et de Spring :

  • Arrays.asList() adapte un tableau en List
  • InputStreamReader adapte un InputStream (octets) en Reader (caractères)
  • Collections.enumeration() adapte une Collection en Enumeration
  • Executors.callable(Runnable) adapte un Runnable en Callable
  • Dans Spring : HandlerAdapter adapte différents types de handlers pour le DispatcherServlet

Le pattern est particulièrement utile pour les intégrations tierces. Chaque API externe (Stripe, SendGrid, AWS S3, Twilio) a sa propre interface. L'Adapter isole ces spécificités derrière une interface stable : un changement de fournisseur ne touche qu'une seule classe.


Decorator : enrichir sans modifier

Le problème

Soit un service d'export de données. On veut pouvoir lui ajouter de la compression, du chiffrement, du logging ou du buffering, et combiner ces fonctionnalités librement. Avec l'héritage, il faudrait une classe par combinaison : CompressedEncryptedLoggedCsvExporter, CompressedCsvExporter, EncryptedLoggedCsvExporter... C'est l'explosion combinatoire.

Le Decorator ajoute des responsabilités à un objet dynamiquement, en l'enveloppant dans un autre objet qui expose la même interface.

Le diagramme UML

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

L'implémentation avec un exemple concret d'export

L'interface commune :

public interface DataExporter {

    byte[] export(List<Map<String, Object>> data);

    String getFormat();

    default String getDescription() {
        return getFormat();
    }
}

L'implémentation de base, un export CSV brut :

public class CsvExporter implements DataExporter {

    private final String separator;
    private final Charset charset;

    public CsvExporter() {
        this(",", StandardCharsets.UTF_8);
    }

    public CsvExporter(String separator, Charset charset) {
        this.separator = separator;
        this.charset = charset;
    }

    @Override
    public byte[] export(List<Map<String, Object>> data) {
        if (data == null || data.isEmpty()) return new byte[0];

        StringBuilder sb = new StringBuilder();

        // En-têtes (get(0) : getFirst() n'existe qu'à partir de Java 21)
        Set<String> headers = data.get(0).keySet();
        sb.append(String.join(separator, headers)).append("\n");

        // Lignes
        for (Map<String, Object> row : data) {
            String line = headers.stream()
                .map(h -> escapeValue(String.valueOf(row.getOrDefault(h, ""))))
                .collect(Collectors.joining(separator));
            sb.append(line).append("\n");
        }

        return sb.toString().getBytes(charset);
    }

    private String escapeValue(String value) {
        if (value.contains(separator) || value.contains("\"") || value.contains("\n")) {
            return "\"" + value.replace("\"", "\"\"") + "\"";
        }
        return value;
    }

    @Override
    public String getFormat() {
        return "CSV";
    }
}

Si le fichier est destiné à être ouvert dans un tableur, les valeurs qui commencent par =, +, - ou @ peuvent être interprétées comme des formules. Il faut alors les neutraliser (par exemple en les préfixant d'une apostrophe) avant l'échappement.

La classe abstraite décorateur :

public abstract class ExporterDecorator implements DataExporter {

    protected final DataExporter wrapped;

    protected ExporterDecorator(DataExporter wrapped) {
        this.wrapped = Objects.requireNonNull(wrapped, "L'exporteur décoré ne peut pas être null");
    }

    @Override
    public byte[] export(List<Map<String, Object>> data) {
        return wrapped.export(data);
    }

    @Override
    public String getFormat() {
        return wrapped.getFormat();
    }
}

Les décorateurs concrets, chacun ajoutant une responsabilité :

/**
 * Ajoute la compression GZIP à n'importe quel exporteur.
 */
public class CompressionDecorator extends ExporterDecorator {

    public CompressionDecorator(DataExporter wrapped) {
        super(wrapped);
    }

    @Override
    public byte[] export(List<Map<String, Object>> data) {
        byte[] raw = super.export(data);

        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
             GZIPOutputStream gzip = new GZIPOutputStream(baos)) {
            gzip.write(raw);
            gzip.finish();
            return baos.toByteArray();
        } catch (IOException e) {
            throw new ExportException("Erreur de compression", e);
        }
    }

    @Override
    public String getFormat() {
        return super.getFormat() + "+GZIP";
    }
}

/**
 * Ajoute le chiffrement AES-GCM à n'importe quel exporteur.
 */
public class EncryptionDecorator extends ExporterDecorator {

    // SecureRandom par défaut : non bloquant et adapté à la génération d'IV.
    // SecureRandom.getInstanceStrong() peut bloquer sur Linux (source /dev/random
    // selon la configuration), ce qui gèlerait l'export en attente d'entropie.
    private static final SecureRandom RANDOM = new SecureRandom();

    private final SecretKey secretKey;

    public EncryptionDecorator(DataExporter wrapped, SecretKey secretKey) {
        super(wrapped);
        this.secretKey = secretKey;
    }

    @Override
    public byte[] export(List<Map<String, Object>> data) {
        byte[] raw = super.export(data);

        try {
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            byte[] iv = new byte[12];          // un IV unique par chiffrement
            RANDOM.nextBytes(iv);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey,
                new GCMParameterSpec(128, iv));

            byte[] encrypted = cipher.doFinal(raw);

            // Préfixer l'IV pour le déchiffrement
            byte[] result = new byte[iv.length + encrypted.length];
            System.arraycopy(iv, 0, result, 0, iv.length);
            System.arraycopy(encrypted, 0, result, iv.length, encrypted.length);

            return result;
        } catch (GeneralSecurityException e) {
            throw new ExportException("Erreur de chiffrement", e);
        }
    }

    @Override
    public String getFormat() {
        return super.getFormat() + "+AES";
    }
}

/**
 * Ajoute du logging autour de n'importe quel exporteur.
 */
public class LoggingDecorator extends ExporterDecorator {

    private static final Logger log = LoggerFactory.getLogger(LoggingDecorator.class);

    public LoggingDecorator(DataExporter wrapped) {
        super(wrapped);
    }

    @Override
    public byte[] export(List<Map<String, Object>> data) {
        log.info("Export démarré : {} lignes, format {}", data.size(), getFormat());

        long start = System.nanoTime();
        byte[] result = super.export(data);
        long duration = (System.nanoTime() - start) / 1_000_000;

        log.info("Export terminé : {} octets en {} ms", result.length, duration);
        return result;
    }
}

Combiner les décorateurs

// Export CSV simple
DataExporter exporter = new CsvExporter();

// CSV + compression
DataExporter compressed = new CompressionDecorator(new CsvExporter());

// CSV + compression + chiffrement
DataExporter secure = new EncryptionDecorator(
    new CompressionDecorator(new CsvExporter()),
    secretKey
);

// CSV + compression + chiffrement + logging
DataExporter full = new LoggingDecorator(
    new EncryptionDecorator(
        new CompressionDecorator(new CsvExporter()),
        secretKey
    )
);

// Chaque combinaison est créée sans modifier aucune classe existante
byte[] data = full.export(records);
System.out.println(full.getFormat());  // "CSV+GZIP+AES"

L'ordre des décorateurs compte. Ici, le CSV est d'abord compressé puis chiffré. C'est cohérent : des données chiffrées ressemblent à du bruit aléatoire et ne se compressent plus, alors que du texte CSV se compresse très bien. Inverser l'ordre (chiffrer puis compresser) produit un fichier à peu près aussi gros que l'original. À l'inverse, le déchiffrement doit se faire avant la décompression, dans l'ordre inverse de l'empilement. Le décorateur de logging se place à l'extérieur pour mesurer le coût de l'ensemble de la chaîne.

C'est le même principe que les flux Java I/O :

// BufferedInputStream décore GZIPInputStream, qui décore FileInputStream
InputStream in = new BufferedInputStream(
    new GZIPInputStream(
        new FileInputStream("data.csv.gz")
    )
);

// Pour lire du texte, InputStreamReader adapte les octets en caractères (Adapter),
// puis BufferedReader décore ce Reader (Decorator).
BufferedReader reader = new BufferedReader(
    new InputStreamReader(in, StandardCharsets.UTF_8)
);

Configuration Spring avec une fabrique de décorateurs

Pour éviter l'empilement manuel de constructeurs, on peut centraliser l'assemblage dans une fabrique :

@Component
public class ExporterFactory {

    private final SecretKey encryptionKey;

    public ExporterFactory(@Value("${export.encryption.key}") String key) {
        byte[] keyBytes = Base64.getDecoder().decode(key);
        this.encryptionKey = new SecretKeySpec(keyBytes, "AES");
    }

    public DataExporter create(ExportOptions options) {
        // JsonExporter et XmlExporter sont supposés exister, sur le modèle de CsvExporter
        DataExporter exporter = switch (options.format()) {
            case CSV -> new CsvExporter();
            case JSON -> new JsonExporter();
            case XML -> new XmlExporter();
        };

        if (options.compress()) {
            exporter = new CompressionDecorator(exporter);   // avant le chiffrement
        }
        if (options.encrypt()) {
            exporter = new EncryptionDecorator(exporter, encryptionKey);
        }
        if (options.log()) {
            exporter = new LoggingDecorator(exporter);
        }

        return exporter;
    }
}

public record ExportOptions(
    ExportFormat format,
    boolean compress,
    boolean encrypt,
    boolean log
) {
    public enum ExportFormat { CSV, JSON, XML }
}

La clé AES est lue depuis la configuration (variable d'environnement ou gestionnaire de secrets), jamais écrite dans le code source.

Decorator vs héritage

Pourquoi ne pas simplement hériter de CsvExporter et ajouter la compression dans une sous-classe ? Parce que l'héritage est statique et linéaire. Avec 3 fonctionnalités optionnelles (compression, chiffrement, logging), l'héritage conduit à 2^3 = 8 combinaisons, soit la classe de base et 7 sous-classes. Avec 5 fonctionnalités, il en faut 32 au total. Le Decorator permet n'importe quelle combinaison avec seulement une classe par fonctionnalité.


Ce qu'il faut retenir

Pattern Intention Signal d'utilisation
Adapter Convertir une interface en une autre Intégration d'API tierce, migration
Decorator Ajouter des responsabilités dynamiquement Fonctionnalités combinables à la carte

La partie 2 traite Facade, qui simplifie l'accès à un ensemble de sous-systèmes, et Proxy, qui contrôle l'accès à un objet. Elle revient aussi sur la distinction entre Proxy et Decorator.


Partie précédente : Abstract Factory et Builder. Partie suivante : Facade et Proxy.

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.