I Dynamic Proxy in Java permettono di intercettare le chiamate effettuate verso un oggetto e di aggiungere comportamenti prima o dopo l’esecuzione del metodo originale. Possono essere utilizzati per implementare logging, misurazione delle prestazioni, controlli di sicurezza, validazione, cache, caricamento differito e molte altre funzionalità trasversali.

La piattaforma Java mette a disposizione un’implementazione nativa dei proxy dinamici attraverso le classi del package java.lang.reflect. La classe principale è Proxy, mentre ogni chiamata intercettata viene gestita da un oggetto che implementa l’interfaccia InvocationHandler.

💡 In questa guida vedremo:

  • che cos’è il pattern Proxy;
  • come funzionano i Dynamic Proxy di Java;
  • come utilizzare Proxy.newProxyInstance();
  • come implementare un InvocationHandler;
  • come intercettare parametri, risultati ed eccezioni;
  • come aggiungere logging e misurazione dei tempi;
  • come creare proxy con cache e lazy loading;
  • come gestire i metodi equals(), hashCode() e toString();
  • come invocare i metodi di default delle interfacce;
  • quali sono i limiti dei proxy dinamici JDK.

🧱 Che cos’è il pattern Proxy

Il Proxy è un design pattern strutturale nel quale un oggetto intermedio controlla l’accesso a un altro oggetto. Il chiamante non utilizza direttamente l’implementazione reale, ma comunica con un oggetto proxy che espone la stessa interfaccia.

Possiamo rappresentare il flusso in questo modo:

Client
  │
  ▼
Proxy
  │
  ├── Esegue controlli aggiuntivi
  ├── Registra la chiamata
  ├── Modifica gli argomenti
  ├── Utilizza una cache
  └── Invoca l'oggetto reale
          │
          ▼
     Implementazione

Il proxy può limitarsi a inoltrare la chiamata oppure può aggiungere una quantità significativa di logica applicativa.

Tipologie comuni di proxy

Tipologia Scopo Esempio
Virtual proxy Ritardare la creazione di un oggetto costoso Caricamento di un file solo al primo utilizzo
Protection proxy Controllare permessi e autorizzazioni Verifica del ruolo prima di eseguire un metodo
Logging proxy Registrare chiamate, argomenti e risultati Audit delle operazioni di pagamento
Caching proxy Riutilizzare risultati già calcolati Cache delle ricerche sul database
Remote proxy Rappresentare localmente una risorsa remota Client di un servizio remoto
Monitoring proxy Misurare tempi ed errori Raccolta di metriche applicative

⚙️ Che cos’è un Dynamic Proxy in Java

Un proxy tradizionale può essere scritto manualmente creando una classe che implementa la stessa interfaccia dell’oggetto originale. Questo approccio è semplice, ma richiede di implementare esplicitamente tutti i metodi.

Supponiamo di avere questa interfaccia:

public interface NotificationService {

    void send(String recipient, String message);

    boolean isAvailable();
}

Un proxy statico dovrebbe dichiarare manualmente entrambi i metodi:

public final class StaticNotificationProxy
        implements NotificationService {

    private final NotificationService target;

    public StaticNotificationProxy(
            NotificationService target
    ) {
        this.target = target;
    }

    @Override
    public void send(
            String recipient,
            String message
    ) {
        System.out.println("Invio della notifica");

        target.send(recipient, message);
    }

    @Override
    public boolean isAvailable() {
        return target.isAvailable();
    }
}

Con un Dynamic Proxy, invece, la JVM genera a runtime una classe che implementa una o più interfacce. Tutte le chiamate vengono inoltrate a un unico oggetto InvocationHandler.

⚠️ Punto importante

I Dynamic Proxy forniti da java.lang.reflect.Proxy non estendono la classe originale. La classe generata estende internamente java.lang.reflect.Proxy e implementa le interfacce specificate durante la creazione.

Proxy statico e proxy dinamico a confronto

Caratteristica Proxy statico Dynamic Proxy JDK
Creazione Classe scritta manualmente Classe generata a runtime
Metodi Implementati uno per uno Intercettati da un handler comune
Interfaccia richiesta Non necessariamente
Flessibilità Limitata alla classe scritta Lo stesso handler può gestire più servizi
Debug Immediato Richiede familiarità con reflection e handler

🧩 Proxy, interfaccia e InvocationHandler

Per creare un Dynamic Proxy servono normalmente tre componenti:

  1. una o più interfacce;
  2. un oggetto InvocationHandler;
  3. un class loader capace di vedere le interfacce.

La creazione avviene attraverso il metodo:

Proxy.newProxyInstance(
    classLoader,
    interfaces,
    invocationHandler
);

I parametri di newProxyInstance()

Parametro Descrizione
ClassLoader loader Il class loader utilizzato per definire la nuova classe proxy
Class<?>[] interfaces Le interfacce che il proxy dovrà implementare
InvocationHandler h L’oggetto che riceverà tutte le invocazioni

Il metodo restituisce un Object, che deve essere convertito nel tipo dell’interfaccia implementata:

MyService proxy = (MyService) Proxy.newProxyInstance(
        MyService.class.getClassLoader(),
        new Class<?>[]{MyService.class},
        handler
);

È possibile evitare un cast esplicito utilizzando Class.cast() all’interno di una factory generica.

✅ API consigliata

Nelle applicazioni moderne è preferibile creare direttamente l’istanza con Proxy.newProxyInstance(). Il vecchio metodo Proxy.getProxyClass() è deprecato.

🚀 Primo esempio completo di Java Dynamic Proxy

Creiamo un servizio per la gestione dei pagamenti. Il proxy registrerà automaticamente l’inizio, la fine e l’eventuale errore di ogni operazione.

1. Definire l’interfaccia

package it.esempio.proxy;

import java.math.BigDecimal;

public interface PaymentService {

    PaymentReceipt pay(
            String orderId,
            BigDecimal amount
    ) throws PaymentException;

    void refund(
            String paymentId
    ) throws PaymentException;

    default String providerName() {
        return "Provider generico";
    }
}

2. Creare il tipo restituito

package it.esempio.proxy;

import java.math.BigDecimal;
import java.time.Instant;

public record PaymentReceipt(
        String paymentId,
        String orderId,
        BigDecimal amount,
        Instant createdAt
) {
}

3. Definire l’eccezione applicativa

package it.esempio.proxy;

public class PaymentException extends Exception {

    public PaymentException(String message) {
        super(message);
    }

    public PaymentException(
            String message,
            Throwable cause
    ) {
        super(message, cause);
    }
}

4. Implementare il servizio reale

package it.esempio.proxy;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

public final class PaymentServiceImpl
        implements PaymentService {

    @Override
    public PaymentReceipt pay(
            String orderId,
            BigDecimal amount
    ) throws PaymentException {

        if (orderId == null || orderId.isBlank()) {
            throw new PaymentException(
                    "L'ordine non può essere vuoto"
            );
        }

        if (
            amount == null
            || amount.signum() <= 0
        ) {
            throw new PaymentException(
                    "L'importo deve essere maggiore di zero"
            );
        }

        String paymentId =
                UUID.randomUUID().toString();

        return new PaymentReceipt(
                paymentId,
                orderId,
                amount,
                Instant.now()
        );
    }

    @Override
    public void refund(
            String paymentId
    ) throws PaymentException {

        if (
            paymentId == null
            || paymentId.isBlank()
        ) {
            throw new PaymentException(
                    "Identificativo di pagamento non valido"
            );
        }

        System.out.println(
                "Rimborso completato: " + paymentId
        );
    }
}

5. Creare l’InvocationHandler

package it.esempio.proxy;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Objects;

public final class LoggingInvocationHandler
        implements InvocationHandler {

    private final Object target;

    public LoggingInvocationHandler(Object target) {
        this.target = Objects.requireNonNull(
                target,
                "Il target non può essere null"
        );
    }

    @Override
    public Object invoke(
            Object proxy,
            Method method,
            Object[] args
    ) throws Throwable {

        /*
         * I metodi dichiarati da Object richiedono
         * una gestione specifica.
         */
        if (method.getDeclaringClass() == Object.class) {
            return handleObjectMethod(
                    proxy,
                    method,
                    args
            );
        }

        String arguments = Arrays.toString(
                args == null
                        ? new Object[0]
                        : args
        );

        long start = System.nanoTime();

        System.out.printf(
                "[START] metodo=%s, argomenti=%s%n",
                method.getName(),
                arguments
        );

        try {
            /*
             * Invochiamo il metodo sull'oggetto reale.
             */
            Object result = method.invoke(target, args);

            long duration =
                    System.nanoTime() - start;

            System.out.printf(
                    "[SUCCESS] metodo=%s, durata=%d µs, risultato=%s%n",
                    method.getName(),
                    duration / 1_000,
                    result
            );

            return result;

        } catch (InvocationTargetException exception) {

            /*
             * Method.invoke() racchiude l'eccezione
             * originale in InvocationTargetException.
             * La estraiamo prima di rilanciarla.
             */
            Throwable cause = exception.getCause();

            long duration =
                    System.nanoTime() - start;

            System.err.printf(
                    "[ERROR] metodo=%s, durata=%d µs, errore=%s%n",
                    method.getName(),
                    duration / 1_000,
                    cause.getMessage()
            );

            throw cause;
        }
    }

    private Object handleObjectMethod(
            Object proxy,
            Method method,
            Object[] args
    ) {
        return switch (method.getName()) {
            case "toString" ->
                    "DynamicProxy[target=" + target + "]";

            case "hashCode" ->
                    System.identityHashCode(proxy);

            case "equals" ->
                    proxy == args[0];

            default ->
                    throw new UnsupportedOperationException(
                            "Metodo Object non supportato: "
                            + method.getName()
                    );
        };
    }
}

6. Creare una factory generica

package it.esempio.proxy;

import java.lang.reflect.Proxy;
import java.util.Objects;

public final class DynamicProxyFactory {

    private DynamicProxyFactory() {
        /*
         * Classe di utilità:
         * il costruttore non deve essere utilizzato.
         */
    }

    public static <T> T createLoggingProxy(
            Class<T> interfaceType,
            T target
    ) {
        Objects.requireNonNull(
                interfaceType,
                "Il tipo non può essere null"
        );

        Objects.requireNonNull(
                target,
                "Il target non può essere null"
        );

        if (!interfaceType.isInterface()) {
            throw new IllegalArgumentException(
                    interfaceType.getName()
                    + " non è un'interfaccia"
            );
        }

        if (!interfaceType.isInstance(target)) {
            throw new IllegalArgumentException(
                    "Il target non implementa "
                    + interfaceType.getName()
            );
        }

        Object proxy = Proxy.newProxyInstance(
                interfaceType.getClassLoader(),
                new Class<?>[]{interfaceType},
                new LoggingInvocationHandler(target)
        );

        return interfaceType.cast(proxy);
    }
}

7. Utilizzare il proxy

package it.esempio.proxy;

import java.math.BigDecimal;

public class Application {

    public static void main(String[] args)
            throws PaymentException {

        PaymentService realService =
                new PaymentServiceImpl();

        PaymentService paymentService =
                DynamicProxyFactory.createLoggingProxy(
                        PaymentService.class,
                        realService
                );

        PaymentReceipt receipt =
                paymentService.pay(
                        "ORD-2026-001",
                        new BigDecimal("89.90")
                );

        System.out.println(
                "Pagamento creato: "
                + receipt.paymentId()
        );

        paymentService.refund(
                receipt.paymentId()
        );

        System.out.println(
                paymentService.providerName()
        );
    }
}

Il client utilizza il proxy come se fosse una normale implementazione di PaymentService. Non è necessario conoscere il funzionamento interno dell’handler.

🔍 Come funziona il metodo invoke()

L’interfaccia InvocationHandler dichiara un solo metodo:

Object invoke(
    Object proxy,
    Method method,
    Object[] args
) throws Throwable;

Ogni volta che viene invocato un metodo su un’istanza proxy, la JVM richiama questo metodo.

Parametro Contenuto
proxy L’istanza proxy sulla quale è stato chiamato il metodo
method L’oggetto reflection che descrive il metodo invocato
args Gli argomenti della chiamata oppure null se il metodo non ha parametri

È importante non confondere proxy con l’oggetto reale. Il primo parametro è il proxy generato dalla JVM, non il target sul quale vogliamo delegare la chiamata.

🛑 Attenzione alla ricorsione infinita

Questo codice è quasi sempre errato:

return method.invoke(proxy, args);

Invocando nuovamente il metodo sul proxy, la chiamata torna nell’handler e continua fino a provocare uno StackOverflowError. Per delegare l’operazione bisogna normalmente utilizzare l’oggetto target.

Ispezionare il metodo intercettato

L’oggetto Method permette di conoscere numerose informazioni:

String name = method.getName();

Class<?> returnType =
        method.getReturnType();

Class<?>[] parameterTypes =
        method.getParameterTypes();

Class<?> declaringClass =
        method.getDeclaringClass();

boolean defaultMethod =
        method.isDefault();

boolean varArgs =
        method.isVarArgs();

È inoltre possibile cercare annotazioni dichiarate sul metodo:

Audit audit =
        method.getAnnotation(Audit.class);

if (audit != null) {
    System.out.println(
            "Operazione sottoposta ad audit: "
            + audit.value()
    );
}

🚨 Gestire correttamente le eccezioni

Quando si utilizza Method.invoke(), un’eccezione generata dal metodo originale viene racchiusa in una InvocationTargetException.

Un handler robusto dovrebbe estrarre e rilanciare la causa originale:

private Object invokeTarget(
        Object target,
        Method method,
        Object[] args
) throws Throwable {

    try {
        return method.invoke(target, args);

    } catch (InvocationTargetException exception) {

        Throwable cause = exception.getCause();

        if (cause == null) {
            throw exception;
        }

        throw cause;
    }
}

Senza questa operazione, il chiamante potrebbe ricevere un’eccezione reflection al posto dell’eccezione applicativa dichiarata nell’interfaccia.

UndeclaredThrowableException

Il metodo invoke() può tecnicamente lanciare qualsiasi Throwable. Tuttavia, il proxy deve rispettare il contratto dell’interfaccia.

Se l’handler lancia un’eccezione checked che non è dichiarata dal metodo dell’interfaccia, la chiamata sul proxy genera una UndeclaredThrowableException.

Consideriamo questo metodo:

public interface ReportService {

    String generateReport();
}

Il metodo non dichiara eccezioni checked. Questo handler non dovrebbe quindi lanciare direttamente una IOException:

InvocationHandler handler =
        (proxy, method, args) -> {

    /*
     * IOException è checked, ma generateReport()
     * non la dichiara nel proprio contratto.
     */
    throw new java.io.IOException(
            "File non disponibile"
    );
};

Le possibili soluzioni sono:

  • dichiarare l’eccezione nell’interfaccia;
  • convertirla in un’eccezione unchecked appropriata;
  • gestirla internamente all’handler;
  • restituire un risultato applicativo che rappresenti l’errore.

⏱️ Logging e misurazione dei tempi

Uno degli utilizzi più frequenti dei proxy dinamici è l’aggiunta di logging tecnico e metriche senza modificare le classi applicative.

Possiamo creare un’annotazione per indicare quali metodi devono essere misurati:

package it.esempio.proxy;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Timed {

    String value() default "";
}

L’annotazione viene inserita sul metodo dell’interfaccia:

public interface ProductService {

    @Timed("product.search")
    Product findById(long id);
}

Il relativo handler può registrare la durata:

package it.esempio.proxy;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;

public final class TimingInvocationHandler
        implements InvocationHandler {

    private final Object target;

    public TimingInvocationHandler(Object target) {
        this.target = Objects.requireNonNull(target);
    }

    @Override
    public Object invoke(
            Object proxy,
            Method method,
            Object[] args
    ) throws Throwable {

        Timed timed =
                method.getAnnotation(Timed.class);

        /*
         * I metodi senza annotazione vengono
         * semplicemente inoltrati al target.
         */
        if (timed == null) {
            return invokeTarget(method, args);
        }

        long start = System.nanoTime();
        boolean success = false;

        try {
            Object result =
                    invokeTarget(method, args);

            success = true;

            return result;

        } finally {
            long duration =
                    System.nanoTime() - start;

            String metricName =
                    timed.value().isBlank()
                            ? method.getName()
                            : timed.value();

            System.out.printf(
                    "metric=%s, success=%s, duration=%d µs%n",
                    metricName,
                    success,
                    duration / 1_000
            );
        }
    }

    private Object invokeTarget(
            Method method,
            Object[] args
    ) throws Throwable {

        try {
            return method.invoke(target, args);

        } catch (InvocationTargetException exception) {
            throw exception.getCause();
        }
    }
}

L’utilizzo del blocco finally garantisce che il tempo venga registrato anche quando il metodo termina con un errore.

🔗 Creare una catena di interceptor

Un singolo InvocationHandler può diventare difficile da mantenere se deve occuparsi contemporaneamente di logging, autorizzazione, validazione, metriche e cache.

Una soluzione consiste nel creare una catena di piccoli interceptor, ciascuno responsabile di una sola funzionalità.

Definire l’interfaccia dell’interceptor

package it.esempio.proxy.chain;

@FunctionalInterface
public interface MethodInterceptor {

    Object intercept(
            InvocationContext context
    ) throws Throwable;
}

Creare il contesto dell’invocazione

package it.esempio.proxy.chain;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Objects;

public final class InvocationContext {

    private final Object proxy;
    private final Object target;
    private final Method method;
    private final Object[] arguments;
    private final List<MethodInterceptor> interceptors;

    private int currentIndex;

    public InvocationContext(
            Object proxy,
            Object target,
            Method method,
            Object[] arguments,
            List<MethodInterceptor> interceptors
    ) {
        this.proxy = Objects.requireNonNull(proxy);
        this.target = Objects.requireNonNull(target);
        this.method = Objects.requireNonNull(method);

        this.arguments = arguments == null
                ? new Object[0]
                : arguments.clone();

        this.interceptors = List.copyOf(
                interceptors
        );
    }

    public Object proceed() throws Throwable {

        /*
         * Finché esistono interceptor,
         * eseguiamo il successivo.
         */
        if (currentIndex < interceptors.size()) {
            MethodInterceptor interceptor =
                    interceptors.get(currentIndex++);

            return interceptor.intercept(this);
        }

        /*
         * Terminata la catena, invochiamo
         * il metodo sull'oggetto reale.
         */
        try {
            return method.invoke(
                    target,
                    arguments
            );

        } catch (InvocationTargetException exception) {
            throw exception.getCause();
        }
    }

    public Object proxy() {
        return proxy;
    }

    public Object target() {
        return target;
    }

    public Method method() {
        return method;
    }

    public Object[] arguments() {
        return arguments.clone();
    }
}

Creare l’handler della catena

package it.esempio.proxy.chain;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Objects;

public final class InterceptorInvocationHandler
        implements InvocationHandler {

    private final Object target;
    private final List<MethodInterceptor> interceptors;

    public InterceptorInvocationHandler(
            Object target,
            List<MethodInterceptor> interceptors
    ) {
        this.target =
                Objects.requireNonNull(target);

        this.interceptors =
                List.copyOf(interceptors);
    }

    @Override
    public Object invoke(
            Object proxy,
            Method method,
            Object[] args
    ) throws Throwable {

        InvocationContext context =
                new InvocationContext(
                        proxy,
                        target,
                        method,
                        args,
                        interceptors
                );

        return context.proceed();
    }
}

Interceptor di logging

package it.esempio.proxy.chain;

import java.util.Arrays;

public final class LoggingInterceptor
        implements MethodInterceptor {

    @Override
    public Object intercept(
            InvocationContext context
    ) throws Throwable {

        System.out.printf(
                "Chiamata %s con %s%n",
                context.method().getName(),
                Arrays.toString(
                        context.arguments()
                )
        );

        Object result = context.proceed();

        System.out.printf(
                "Risultato di %s: %s%n",
                context.method().getName(),
                result
        );

        return result;
    }
}

Interceptor per le prestazioni

package it.esempio.proxy.chain;

public final class PerformanceInterceptor
        implements MethodInterceptor {

    @Override
    public Object intercept(
            InvocationContext context
    ) throws Throwable {

        long start = System.nanoTime();

        try {
            return context.proceed();

        } finally {
            long duration =
                    System.nanoTime() - start;

            System.out.printf(
                    "Metodo %s completato in %d µs%n",
                    context.method().getName(),
                    duration / 1_000
            );
        }
    }
}

Creare il proxy con più interceptor

List<MethodInterceptor> interceptors =
        List.of(
            new LoggingInterceptor(),
            new PerformanceInterceptor()
        );

PaymentService proxy =
        (PaymentService) Proxy.newProxyInstance(
                PaymentService.class.getClassLoader(),
                new Class<?>[]{
                    PaymentService.class
                },
                new InterceptorInvocationHandler(
                        new PaymentServiceImpl(),
                        interceptors
                )
        );

L’ordine degli interceptor è importante. Nel nostro esempio il logging contiene l’intera esecuzione del performance interceptor e del metodo reale.

😴 Implementare un proxy con lazy loading

Un virtual proxy può rimandare la creazione dell’oggetto reale fino alla prima chiamata. Questo approccio è utile quando l’inizializzazione richiede tempo o molte risorse.

package it.esempio.proxy;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.function.Supplier;

public final class LazyInvocationHandler<T>
        implements InvocationHandler {

    private final Supplier<? extends T> supplier;

    /*
     * volatile rende visibile il valore
     * inizializzato anche agli altri thread.
     */
    private volatile T target;

    public LazyInvocationHandler(
            Supplier<? extends T> supplier
    ) {
        this.supplier =
                Objects.requireNonNull(supplier);
    }

    @Override
    public Object invoke(
            Object proxy,
            Method method,
            Object[] args
    ) throws Throwable {

        T realTarget = getOrCreateTarget();

        try {
            return method.invoke(
                    realTarget,
                    args
            );

        } catch (InvocationTargetException exception) {
            throw exception.getCause();
        }
    }

    private T getOrCreateTarget() {

        T current = target;

        if (current == null) {
            synchronized (this) {
                current = target;

                if (current == null) {
                    System.out.println(
                            "Creazione dell'oggetto reale"
                    );

                    current = Objects.requireNonNull(
                            supplier.get(),
                            "Il supplier ha restituito null"
                    );

                    target = current;
                }
            }
        }

        return current;
    }
}

Creiamo una factory per il proxy lazy:

public static <T> T createLazyProxy(
        Class<T> interfaceType,
        Supplier<? extends T> supplier
) {
    Object proxy = Proxy.newProxyInstance(
            interfaceType.getClassLoader(),
            new Class<?>[]{interfaceType},
            new LazyInvocationHandler<>(supplier)
    );

    return interfaceType.cast(proxy);
}

Utilizzo:

DocumentService documentService =
        createLazyProxy(
                DocumentService.class,
                () -> new ExpensiveDocumentService()
        );

/*
 * ExpensiveDocumentService non è ancora stato creato.
 */

documentService.loadDocument("manuale.pdf");

/*
 * L'implementazione reale viene creata
 * durante la prima invocazione.
 */

🗄️ Creare un Dynamic Proxy con cache

Un caching proxy può memorizzare il risultato di determinati metodi ed evitare esecuzioni ripetute.

Creare l’annotazione Cacheable

package it.esempio.proxy.cache;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Cacheable {
}

Creare la chiave della cache

package it.esempio.proxy.cache;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public record CacheKey(
        Method method,
        List<Object> arguments
) {
    public static CacheKey of(
            Method method,
            Object[] args
    ) {
        Object[] copy = args == null
                ? new Object[0]
                : args.clone();

        /*
         * Collections.unmodifiableList accetta anche
         * eventuali valori null negli argomenti.
         */
        List<Object> arguments =
                Collections.unmodifiableList(
                        Arrays.asList(copy)
                );

        return new CacheKey(
                method,
                arguments
        );
    }
}

Implementare il caching handler

package it.esempio.proxy.cache;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public final class CachingInvocationHandler
        implements InvocationHandler {

    /*
     * ConcurrentHashMap non consente valori null.
     * Utilizziamo quindi un oggetto speciale.
     */
    private static final Object NULL_VALUE =
            new Object();

    private final Object target;

    private final ConcurrentMap<CacheKey, Object> cache =
            new ConcurrentHashMap<>();

    public CachingInvocationHandler(Object target) {
        this.target =
                Objects.requireNonNull(target);
    }

    @Override
    public Object invoke(
            Object proxy,
            Method method,
            Object[] args
    ) throws Throwable {

        /*
         * I metodi senza @Cacheable vengono
         * eseguiti normalmente.
         */
        if (!method.isAnnotationPresent(
                Cacheable.class
        )) {
            return invokeTarget(method, args);
        }

        CacheKey key =
                CacheKey.of(method, args);

        Object cached = cache.get(key);

        if (cached != null) {
            System.out.println(
                    "Cache HIT: "
                    + method.getName()
            );

            return cached == NULL_VALUE
                    ? null
                    : cached;
        }

        System.out.println(
                "Cache MISS: "
                + method.getName()
        );

        Object result =
                invokeTarget(method, args);

        cache.put(
                key,
                result == null
                        ? NULL_VALUE
                        : result
        );

        return result;
    }

    private Object invokeTarget(
            Method method,
            Object[] args
    ) throws Throwable {

        try {
            return method.invoke(target, args);

        } catch (InvocationTargetException exception) {
            throw exception.getCause();
        }
    }

    public void clearCache() {
        cache.clear();
    }
}

La relativa interfaccia può essere definita così:

public interface CustomerService {

    @Cacheable
    Customer findById(long customerId);

    void update(Customer customer);
}
⚠️ Una cache reale richiede ulteriori decisioni

  • durata dei valori;
  • numero massimo di elementi;
  • invalidazione dopo un aggiornamento;
  • gestione di array e oggetti mutabili negli argomenti;
  • esecuzioni concorrenti della stessa operazione;
  • gestione di risultati molto grandi;
  • eventuale cache distribuita.

🧬 Gestire i metodi default delle interfacce

Un’interfaccia Java può contenere metodi default con un’implementazione concreta. Anche queste chiamate vengono inoltrate all’InvocationHandler.

Quando il proxy non dispone di un normale oggetto target, l’handler può invocare l’implementazione di default con InvocationHandler.invokeDefault().

package it.esempio.proxy;

public interface WelcomeService {

    default String welcome(String name) {
        return "Benvenuto, " + name + "!";
    }

    String version();
}

Creiamo un proxy senza implementazione reale:

WelcomeService service =
        (WelcomeService) Proxy.newProxyInstance(
                WelcomeService.class.getClassLoader(),
                new Class<?>[]{
                    WelcomeService.class
                },
                (proxy, method, args) -> {

                    if (
                        method.getDeclaringClass()
                        == Object.class
                    ) {
                        return switch (
                                method.getName()
                        ) {
                            case "toString" ->
                                    "WelcomeServiceProxy";

                            case "hashCode" ->
                                    System.identityHashCode(
                                            proxy
                                    );

                            case "equals" ->
                                    proxy == args[0];

                            default ->
                                    throw new UnsupportedOperationException(
                                            method.getName()
                                    );
                        };
                    }

                    /*
                     * Eseguiamo l'implementazione default
                     * dichiarata dall'interfaccia.
                     */
                    if (method.isDefault()) {
                        return InvocationHandler.invokeDefault(
                                proxy,
                                method,
                                args
                        );
                    }

                    if (
                        method.getName()
                                .equals("version")
                    ) {
                        return "1.0.0";
                    }

                    throw new UnsupportedOperationException(
                            "Metodo non gestito: "
                            + method.getName()
                    );
                }
        );

System.out.println(
        service.welcome("Luca")
);

System.out.println(
        service.version()
);

Questa funzionalità è utile per creare implementazioni dinamiche di interfacce senza un oggetto concreto al quale delegare tutti i metodi.

🪞 Gestire equals(), hashCode() e toString()

Le chiamate ai metodi pubblici equals(), hashCode() e toString() vengono intercettate dall’handler come le chiamate ai metodi delle interfacce.

Ignorare questi metodi può generare comportamenti inattesi quando il proxy viene:

  • stampato nei log;
  • inserito in un HashSet;
  • utilizzato come chiave di una HashMap;
  • confrontato con un altro oggetto;
  • analizzato da un framework.

Una possibile strategia basata sull’identità del proxy è:

private Object handleObjectMethod(
        Object proxy,
        Method method,
        Object[] args
) {
    return switch (method.getName()) {

        case "equals" ->
                proxy == args[0];

        case "hashCode" ->
                System.identityHashCode(proxy);

        case "toString" ->
                "Proxy["
                + target.getClass().getName()
                + "]";

        default ->
                throw new UnsupportedOperationException(
                        method.getName()
                );
    };
}

Un’altra possibilità consiste nel delegare questi metodi al target. La scelta dipende dal significato che si vuole attribuire al proxy.

È però importante mantenere coerenti equals() e hashCode(): se due proxy sono considerati uguali, devono restituire lo stesso hash code.

🧰 Implementare più interfacce con lo stesso proxy

Un Dynamic Proxy può implementare più interfacce contemporaneamente.

public interface HealthCheck {

    boolean isHealthy();
}

Creiamo un proxy che implementa sia PaymentService sia HealthCheck:

PaymentService target =
        new PaymentServiceImpl();

Object multiProxy =
        Proxy.newProxyInstance(
                PaymentService.class.getClassLoader(),
                new Class<?>[]{
                    PaymentService.class,
                    HealthCheck.class
                },
                (proxy, method, args) -> {

                    if (
                        method.getDeclaringClass()
                        == Object.class
                    ) {
                        return switch (
                                method.getName()
                        ) {
                            case "toString" ->
                                    "PaymentHealthProxy";

                            case "hashCode" ->
                                    System.identityHashCode(
                                            proxy
                                    );

                            case "equals" ->
                                    proxy == args[0];

                            default ->
                                    throw new UnsupportedOperationException(
                                            method.getName()
                                    );
                        };
                    }

                    if (
                        method.getDeclaringClass()
                        == HealthCheck.class
                    ) {
                        return true;
                    }

                    try {
                        return method.invoke(
                                target,
                                args
                        );

                    } catch (
                        InvocationTargetException exception
                    ) {
                        throw exception.getCause();
                    }
                }
        );

PaymentService payments =
        (PaymentService) multiProxy;

HealthCheck healthCheck =
        (HealthCheck) multiProxy;

System.out.println(
        healthCheck.isHealthy()
);

Entrambi i cast sono validi perché la classe generata implementa entrambe le interfacce.

⚠️ L’ordine delle interfacce può essere significativo

Se più interfacce dichiarano metodi con lo stesso nome e gli stessi parametri, il Method ricevuto dall’handler dipende dall’ordine con cui le interfacce sono state passate a newProxyInstance().

🔬 Ispezionare un proxy a runtime

La classe Proxy offre alcuni metodi utili per analizzare le istanze generate.

Verificare se una classe è un proxy

boolean proxyClass =
        Proxy.isProxyClass(
                paymentService.getClass()
        );

System.out.println(proxyClass);

Recuperare l’InvocationHandler

InvocationHandler handler =
        Proxy.getInvocationHandler(
                paymentService
        );

System.out.println(
        handler.getClass().getName()
);

Visualizzare la classe generata

Class<?> generatedClass =
        paymentService.getClass();

System.out.println(
        generatedClass.getName()
);

System.out.println(
        generatedClass.getSuperclass()
);

for (
    Class<?> implementedInterface
    : generatedClass.getInterfaces()
) {
    System.out.println(
            implementedInterface.getName()
    );
}

Il nome concreto della classe proxy è un dettaglio di implementazione e non dovrebbe essere utilizzato nella logica applicativa.

Metodi principali della classe Proxy

Metodo Funzione
newProxyInstance() Crea una nuova istanza proxy
isProxyClass() Verifica se una classe è stata generata come proxy JDK
getInvocationHandler() Recupera l’handler associato a un proxy
getProxyClass() Restituisce la classe proxy, ma è deprecato

🚧 Limiti dei Dynamic Proxy JDK

I proxy dinamici della libreria standard sono molto utili, ma presentano alcuni limiti importanti.

1. Possono implementare soltanto interfacce

Non è possibile passare una classe concreta nell’array delle interfacce:

// Errato: PaymentServiceImpl è una classe.
Proxy.newProxyInstance(
        PaymentServiceImpl.class.getClassLoader(),
        new Class<?>[]{
            PaymentServiceImpl.class
        },
        handler
);

La chiamata genera una IllegalArgumentException. Bisogna passare PaymentService.class.

2. Le chiamate interne non attraversano automaticamente il proxy

Consideriamo questa implementazione:

public final class OrderServiceImpl
        implements OrderService {

    @Override
    public void createOrder() {
        /*
         * Chiamata interna sulla stessa istanza.
         */
        validateOrder();
    }

    @Override
    public void validateOrder() {
        System.out.println("Validazione");
    }
}

Se il client chiama proxy.createOrder(), l’handler intercetta createOrder(). Tuttavia, la successiva chiamata interna a validateOrder() viene eseguita direttamente sull’oggetto reale e non torna automaticamente attraverso il proxy.

3. Reflection aggiunge complessità

L’utilizzo della reflection comporta:

  • stack trace più articolati;
  • possibili errori individuati soltanto a runtime;
  • gestione specifica delle eccezioni;
  • maggiore difficoltà durante il debug;
  • un costo aggiuntivo per ogni invocazione.

Nella maggior parte delle applicazioni il costo è accettabile, ma non bisogna utilizzare un proxy dinamico senza una reale necessità.

4. Class loader e moduli devono essere compatibili

Tutte le interfacce devono essere visibili attraverso il class loader utilizzato per creare il proxy. In applicazioni modulari, server applicativi o sistemi di plugin, la scelta del class loader può essere particolarmente importante.

Come regola generale, è spesso appropriato utilizzare il class loader dell’interfaccia principale:

ClassLoader loader =
        ServiceInterface.class.getClassLoader();

5. Le interfacce sealed non possono essere utilizzate

L’array passato a newProxyInstance() deve contenere interfacce compatibili con la generazione dinamica. Le interfacce hidden o sealed non possono essere utilizzate come normali interfacce proxy JDK.

🧯 Errori comuni e soluzioni

Errore Causa probabile Soluzione
IllegalArgumentException: ... is not an interface È stata passata una classe concreta Passare esclusivamente interfacce
ClassCastException Il proxy non implementa l’interfaccia del cast Aggiungere l’interfaccia a newProxyInstance()
StackOverflowError L’handler invoca nuovamente il metodo sul proxy Invocare il target o un metodo default appropriato
InvocationTargetException Il metodo originale ha generato un errore Estrarre e rilanciare getCause()
UndeclaredThrowableException L’handler ha lanciato un’eccezione checked non dichiarata Rispettare il contratto dell’interfaccia
NullPointerException sul valore restituito L’handler ha restituito null per un metodo con tipo primitivo Restituire il wrapper corretto, per esempio Integer o Boolean
Il metodo non viene intercettato La chiamata avviene direttamente sul target o internamente alla stessa classe Assicurarsi che il client utilizzi l’istanza proxy

🎯 Quando utilizzare un Dynamic Proxy

Un proxy dinamico è particolarmente utile quando la stessa logica deve essere applicata a molti servizi o metodi.

Utilizzi adatti

  • logging uniforme delle chiamate;
  • misurazione dei tempi di risposta;
  • raccolta di metriche;
  • controlli di autorizzazione;
  • audit delle operazioni sensibili;
  • validazione degli argomenti;
  • cache dei risultati;
  • gestione centralizzata delle eccezioni;
  • retry di operazioni temporaneamente fallite;
  • lazy loading di servizi costosi;
  • creazione di mock e test double;
  • client dinamici per servizi remoti;
  • implementazione di funzionalità simili alla programmazione orientata agli aspetti.

Quando è preferibile una soluzione più semplice

Un proxy dinamico potrebbe non essere necessario quando:

  • deve essere decorata una sola classe con pochi metodi;
  • non è disponibile un’interfaccia;
  • il comportamento aggiuntivo è specifico di un unico metodo;
  • la massima velocità di invocazione è fondamentale;
  • il proxy renderebbe il flusso applicativo difficile da comprendere;
  • un normale decorator esplicito sarebbe più leggibile.

✅ Best practice per i Java Dynamic Proxy

1. Utilizzare interfacce chiare

Il contratto dell’interfaccia deve descrivere correttamente:

  • tipi restituiti;
  • eccezioni checked;
  • valori eventualmente null;
  • effetti collaterali;
  • responsabilità dei singoli metodi.

2. Mantenere l’handler piccolo

Un handler con molte condizioni basate sul nome del metodo diventa rapidamente fragile:

if (method.getName().equals("save")) {
    // ...
} else if (method.getName().equals("delete")) {
    // ...
} else if (method.getName().equals("find")) {
    // ...
}

È preferibile suddividere le responsabilità in interceptor, strategie o componenti dedicati.

3. Evitare confronti basati soltanto sul nome

Due metodi sovraccaricati possono avere lo stesso nome. Quando necessario, bisogna controllare anche i tipi dei parametri:

if (
    method.getName().equals("find")
    && Arrays.equals(
        method.getParameterTypes(),
        new Class<?>[]{long.class}
    )
) {
    // Metodo find(long)
}

4. Non registrare dati sensibili

Un logging proxy non dovrebbe stampare indiscriminatamente:

  • password;
  • token di autenticazione;
  • numeri completi di carte;
  • dati sanitari;
  • documenti personali;
  • informazioni riservate.

È opportuno mascherare o escludere gli argomenti sensibili prima di registrarli.

5. Rilanciare la causa originale

Quando si delega attraverso Method.invoke(), bisogna gestire InvocationTargetException ed estrarre la causa.

6. Gestire esplicitamente i metodi Object

Definire una politica chiara per equals(), hashCode() e toString() evita problemi difficili da diagnosticare.

7. Validare la factory

Una factory generica dovrebbe verificare:

  • che il tipo ricevuto sia un’interfaccia;
  • che il target implementi l’interfaccia;
  • che nessun parametro obbligatorio sia null;
  • che il class loader sia appropriato.

8. Documentare che l’oggetto è un proxy

Gli sviluppatori che utilizzano il servizio devono sapere che alcune chiamate possono essere intercettate, memorizzate, ripetute o sottoposte a controlli.

9. Scrivere test specifici

Non bisogna testare soltanto il target reale. È necessario verificare anche il comportamento del proxy:

import static org.junit.jupiter.api.Assertions.*;

@Test
void shouldCreateProxyAndDelegatePayment()
        throws PaymentException {

    PaymentService target =
            new PaymentServiceImpl();

    PaymentService proxy =
            DynamicProxyFactory.createLoggingProxy(
                    PaymentService.class,
                    target
            );

    assertTrue(
            Proxy.isProxyClass(
                    proxy.getClass()
            )
    );

    PaymentReceipt result =
            proxy.pay(
                    "ORD-001",
                    new BigDecimal("25.00")
            );

    assertNotNull(result);
    assertEquals(
            "ORD-001",
            result.orderId()
    );
}

🏁 Conclusioni

I Java Dynamic Proxy consentono di creare a runtime oggetti che implementano una o più interfacce e inoltrano tutte le chiamate a un InvocationHandler. Questo meccanismo permette di aggiungere funzionalità trasversali senza modificare direttamente le implementazioni applicative.

La creazione del proxy avviene normalmente attraverso Proxy.newProxyInstance(). L’handler riceve il proxy, il metodo invocato e i relativi argomenti, potendo decidere se delegare la chiamata, modificarla, bloccarla o restituire un risultato alternativo.

Per utilizzare correttamente questa tecnologia è però necessario prestare attenzione alla gestione delle eccezioni, ai metodi ereditati da Object, alla visibilità delle interfacce, ai class loader e alle chiamate interne che non attraversano automaticamente il proxy.

I proxy dinamici risultano particolarmente efficaci per logging, monitoraggio, sicurezza, cache, lazy loading e altre funzionalità che devono essere applicate in modo uniforme a più servizi. Per casi semplici, invece, un decorator esplicito può essere più leggibile e immediato.

🚀 Percorso di apprendimento consigliato

  1. creare un’interfaccia semplice;
  2. implementare un handler che registra le chiamate;
  3. delegare i metodi a un oggetto reale;
  4. gestire correttamente le eccezioni;
  5. aggiungere una factory generica;
  6. separare le funzionalità in una catena di interceptor;
  7. aggiungere cache, autorizzazione o lazy loading soltanto quando necessario.

📖 Documentazione ufficiale

Categorie: J2EEJava