16. Proxy (Apoderado) - Patrón Estructural

El patrón Proxy define un intermediario que controla el acceso a un objeto real, ya sea para diferir su creación, agregar verificaciones o realizar optimizaciones transversales. El proxy expone la misma interfaz que el objeto representado, por lo que el cliente desconoce si está interactuando con la instancia real o con su apoderado.

Al incorporar un punto de delegación explícito, el patrón permite encapsular aspectos como la seguridad, la distribución remota, la gestión de recursos o la instrumentación sin invadir la lógica del objeto principal. Es una forma de cumplir el principio de responsabilidad única al aislar funciones de soporte en un colaborador especializado.

16.1 Problema y contexto

En diseños reales surgen situaciones donde la instanciación o el uso directo de un objeto es costoso o riesgoso. Acceder a un documento pesado, establecer conexiones remotas o permitir operaciones sensibles son ejemplos que requieren controles adicionales. Introducir esas preocupaciones dentro del objeto concreto genera código intrusivo, viola la cohesión y dificulta pruebas.

Proxy soluciona el problema interponiendo un representante que decide cuándo y cómo llegar al objeto real. El cliente conserva una interfaz uniforme mientras que el apoderado gestiona la vida útil, el acceso concurrente, los permisos o cualquier lógica transversal sin que el objeto real se entere.

16.2 Intención y motivación

La intención del patrón es proporcionar un substituto controlado de otro objeto para mediar sus solicitudes. Es especialmente útil cuando:

  • Se necesita retrasar la carga de recursos pesados hasta que sean estrictamente necesarios.
  • El objeto vive en otro proceso o servidor y conviene encapsular la comunicación.
  • Existen reglas de seguridad o de auditoría que deben ejecutarse antes de delegar en el objeto real.
  • Se requiere agregar caché, logging o contadores sin modificar la implementación existente.

El apoderado se convierte en un lugar idóneo para centralizar políticas de acceso, instrumentación y optimizaciones, manteniendo el concepto principal limpio y enfocado en su responsabilidad de negocio.

16.3 Participantes y estructura

La estructura clásica del patrón involucra los siguientes participantes:

  • Subject: interfaz o clase abstracta que declara las operaciones esperadas por el cliente.
  • RealSubject: implementación concreta que contiene la lógica de negocio y gestiona el estado verdadero.
  • Proxy: implementación alternativa que mantiene una referencia al objeto real y decide si delega, difiere o reemplaza la llamada.
  • Cliente: interactúa con el Subject sin distinguir si recibe un proxy o el objeto real.

El proxy y el objeto verdadero comparten la misma interfaz, por lo que la sustitución es transparente. El proxy está autorizado a crear el objeto real bajo demanda, a reutilizarlo o incluso a delegar en otro proxy encadenado si se requiere una cadena de responsabilidades.

16.4 Tipos comunes de Proxy

A lo largo de la literatura se describen varias especializaciones del patrón:

  • Virtual Proxy: difiere la creación de objetos costosos hasta que se solicitan, cargando datos incrementalmente.
  • Remote Proxy: representa un objeto ubicado en otra máquina y maneja la serialización, las reconexiones y los tiempos de espera.
  • Protection Proxy: valida permisos, roles o cuotas antes de permitir la operación.
  • Caching Proxy: almacena respuestas para reducir el tiempo de acceso y la carga sobre el objeto real.
  • Smart Reference: ejecuta lógica adicional tras cada acceso (por ejemplo, conteo de referencias o registro de auditoría).

Un mismo apoderado puede mezclar varias motivaciones: cachear resultados, verificar credenciales y llevar métricas en una sola implementación, siempre que mantenga la claridad y no se convierta en un monolito de responsabilidades.

16.5 Ejemplo: control y caché para un catálogo de video

Supongamos una plataforma de streaming que consulta metadatos en un proveedor remoto con latencia variable. Las cuentas gratuitas solo pueden acceder a trailers, mientras que las cuentas premium consultan el catálogo completo. Además, se desea limitar la cantidad de solicitudes consecutivas para prevenir abusos e incorporar caché para evitar costosas llamadas repetidas.

El patrón Proxy es ideal: un apoderado controla a quién se permite el acceso, regula el ritmo de peticiones, reutiliza datos cacheados y delega en el objeto real cuando la información no está disponible localmente.

16.6 Implementación en Java

El siguiente fragmento muestra la estructura completa del ejemplo anterior:

package tutorial.proxy;

import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public interface CatalogoVideos {
    Video obtenerVideo(String id, Usuario solicitante);
}

class CatalogoVideosRemoto implements CatalogoVideos {
    @Override
    public Video obtenerVideo(String id, Usuario solicitante) {
        simularLatencia();
        System.out.println("Consultando proveedor externo para " + id);
        return new Video(id, "Documental " + id, 720);
    }

    private void simularLatencia() {
        try {
            Thread.sleep(Duration.ofMillis(150).toMillis());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Consulta interrumpida", e);
        }
    }
}

class CatalogoVideosProxy implements CatalogoVideos {
    private final CatalogoVideos remoto;
    private final Duration intervaloEntreSolicitudes;
    private final Map<String, Video> cache = new ConcurrentHashMap<>();
    private final Map<String, Instant> ultimoAccesoPorUsuario = new ConcurrentHashMap<>();
    private final Set<String> catalogoPromocional;

    CatalogoVideosProxy(CatalogoVideos remoto,
                        Duration intervaloEntreSolicitudes,
                        Set<String> catalogoPromocional) {
        this.remoto = remoto;
        this.intervaloEntreSolicitudes = intervaloEntreSolicitudes;
        this.catalogoPromocional = Collections.unmodifiableSet(new HashSet<>(catalogoPromocional));
    }

    @Override
    public Video obtenerVideo(String id, Usuario solicitante) {
        validarAcceso(id, solicitante);
        controlarRitmo(solicitante);

        Video video = cache.get(id);
        if (video == null) {
            video = remoto.obtenerVideo(id, solicitante);
            cache.put(id, video);
        }

        registrarMetrica(id, solicitante, video);
        return video;
    }

    private void validarAcceso(String id, Usuario solicitante) {
        if (!solicitante.esPremium() && !catalogoPromocional.contains(id)) {
            throw new AccesoDenegadoException("Actualice su plan para ver el video " + id);
        }
    }

    private void controlarRitmo(Usuario solicitante) {
        Instant ahora = Instant.now();
        Instant ultimoAcceso = ultimoAccesoPorUsuario.put(solicitante.getNombre(), ahora);
        if (!solicitante.esPremium() && ultimoAcceso != null) {
            Duration intervalo = Duration.between(ultimoAcceso, ahora);
            if (intervalo.compareTo(intervaloEntreSolicitudes) < 0) {
                throw new IllegalStateException("Demasiadas solicitudes seguidas de " + solicitante.getNombre());
            }
        }
    }

    private void registrarMetrica(String id, Usuario solicitante, Video video) {
        System.out.printf("Usuario %s recibió %s (%d MB)%n",
                solicitante.getNombre(), video.getTitulo(), video.getPesoMb());
    }
}

class AccesoDenegadoException extends RuntimeException {
    AccesoDenegadoException(String mensaje) {
        super(mensaje);
    }
}

class Video {
    private final String id;
    private final String titulo;
    private final long pesoMb;

    Video(String id, String titulo, long pesoMb) {
        this.id = id;
        this.titulo = titulo;
        this.pesoMb = pesoMb;
    }

    public String getId() {
        return id;
    }

    public String getTitulo() {
        return titulo;
    }

    public long getPesoMb() {
        return pesoMb;
    }

    @Override
    public String toString() {
        return "Video{id='" + id + "', titulo='" + titulo + "', pesoMb=" + pesoMb + '}';
    }
}

class Usuario {
    private final String nombre;
    private final boolean premium;

    Usuario(String nombre, boolean premium) {
        this.nombre = nombre;
        this.premium = premium;
    }

    public String getNombre() {
        return nombre;
    }

    public boolean esPremium() {
        return premium;
    }
}

class Aplicacion {
    public static void main(String[] args) {
        CatalogoVideos servicioRemoto = new CatalogoVideosRemoto();
        Set<String> catalogoPromocional = new HashSet<>();
        catalogoPromocional.add("VID-TRAILER");
        CatalogoVideos proxy = new CatalogoVideosProxy(
                servicioRemoto,
                Duration.ofSeconds(2),
                catalogoPromocional
        );

        Usuario invitada = new Usuario("Laura", false);
        Usuario premium = new Usuario("Sergio", true);

        System.out.println(proxy.obtenerVideo("VID-TRAILER", invitada)); // usa caché
        try {
            proxy.obtenerVideo("VID-ALBUM", invitada); // lanza AccesoDenegadoException
        } catch (AccesoDenegadoException ex) {
            System.out.println("Mensaje para la interfaz: " + ex.getMessage());
        }

        System.out.println(proxy.obtenerVideo("VID-ALBUM", premium)); // delega al remoto
        System.out.println(proxy.obtenerVideo("VID-ALBUM", premium)); // devuelve desde caché
    }
}

16.7 Explicación del flujo

El cliente recibe una referencia de tipo CatalogoVideos e ignora si se trata de un proxy o del objeto concreto. El proxy intercepta cada llamada, valida si el video solicitado está disponible para el tipo de cuenta, comprueba que no se supere la cuota de uso y, solo cuando es necesario, invoca al servicio remoto. Almacena la respuesta en caché y registra una métrica, demostrando cómo encapsular varios aspectos transversales sin recompilar el objeto real.

Este diseño prepara el terreno para reemplazar el servicio remoto por otro proveedor, realizar pruebas unitarias con dobles de prueba o incorporar nuevas reglas de negocio sin tocar la implementación original.

16.8 Integración con el ecosistema Java

Las bibliotecas y plataformas del JDK emplean proxies con frecuencia. La documentación oficial de RMI describe cómo generar proxies remotos que representan objetos en otras JVM. Del mismo modo, la clase Proxy del paquete reflect permite crear proxies dinámicos en tiempo de ejecución para interfaces arbitrarias, recurso ampliamente utilizado por frameworks de inyección de dependencias, ORMs y librerías de seguridad.

Frameworks como Spring, CDI o Hibernate generan proxies para habilitar transacciones declarativas, carga perezosa o verificaciones de seguridad. Comprender el patrón ayuda a interpretar el comportamiento de estos contenedores y a configurar correctamente la serialización, el registro y la propagación de contexto.

16.9 Riesgos y malas prácticas

Un abuso del patrón puede introducir capas innecesarias que degradan el rendimiento o hacen difícil seguir el flujo de llamadas. También es habitual duplicar lógica de negocio dentro del proxy, lo que genera inconsistencias con el objeto real.

Otro riesgo radica en no manejar adecuadamente los fallos del objeto real: si el apoderado no controla reintentos, tiempo de espera o la liberación de recursos, puede transformarse en un cuello de botella. Finalmente, es crucial definir claramente el contrato del Subject; si evoluciona sin mantener compatibilidad, clientes y proxies pueden quedar desincronizados.

16.10 Buenas prácticas para aplicar Proxy

  • Documente el propósito del proxy para que el equipo entienda qué preocupación transversal está encapsulando.
  • Evite mezclar responsabilidades dispares; considere delegar en varios proxies encadenados si la lógica se vuelve compleja.
  • Implemente estrategias de invalidez de caché cuando represente datos mutables.
  • Instrumente el proxy con mediciones de latencia y fallos para detectar anomalías en el objeto real o en la red.

16.11 Relación con otros patrones

Proxy comparte similitudes con Decorator, pero mientras Decorator agrega responsabilidades al objeto real, Proxy controla el acceso. Puede combinarse con Facade para exponer un punto de entrada simplificado y con Chain of Responsibility para encadenar verificaciones. Cuando se necesita adaptar la interfaz de un servicio heredado antes de delegar en el proxy, se recurre a Adapter. Asimismo, usar Proxy junto a Flyweight permite compartir instancias reales costosas y administrarlas desde un intermediario liviano.