CodeGym /Cursos /Spring Core /Límites del proxy: final, private, this

Límites del proxy: final, private, this

Spring Core
Nivel 21, Lección 3
Disponible

1. Límite del proxy: qué se intercepta

Cuando te familiarizas por primera vez con los proxies, surge la tentación de pensar: «Oh, ahora puedo envolver cualquier llamada a cualquier método como quiera». Esta sensación es similar a la de un principiante que coloca un punto de interrupción y cree por un segundo que ahora controla el universo. La realidad es más tranquila: un proxy intercepta solo aquellas llamadas que realmente pasan a través de él, es decir, que cruzan el límite «código que llama → proxy».

Fijemos un modelo simple. Imagina el proxy como un guardia de seguridad en la entrada de una oficina: si entras por la puerta, el guardia te ve. Si, en cambio, te cuelas por la ventilación (una llamada interna dentro del objeto), el guardia puede ser muy bueno en su trabajo, pero literalmente no sabe que ya estás dentro.

sequenceDiagram
    participant C as "Caller (cliente)"
    participant P as Proxy
    participant T as Target

    C->>P: "placeOrder()"
    P->>T: "placeOrder()"
    T->>T: "saveOrder() (this.saveOrder)"
    T-->>P: return
    P-->>C: return

Aquí el proxy solo vio placeOrder(), porque esa llamada llegó «desde fuera». La llamada interna this.saveOrder() ocurrió dentro del target: el proxy no estaba allí, en ese momento «la puerta de la oficina» ni siquiera se usó.

Para una comprensión más profunda, es útil tener en mente una fórmula muy práctica: el proxy solo intercepta llamadas externas a través de una referencia al proxy. Todo lo que está «dentro del target» puede quedar fuera del alcance de la visibilidad.

2. final class y proxy basado en clase

final en Java es como un sello de «no tocar, así lo he decidido». A veces está justificado, pero en las clases de servicio aparece más por costumbre o por estética: «que nadie herede, me gusta el orden». El problema es que el proxy basado en clase (que discutimos en la 2ª lección) a menudo se construye mediante herencia: el proxy se convierte en una subclase del target y sobrescribe los métodos.

Y una clase final en Java no se puede extender. En absoluto. De ninguna manera. Y aquí Spring no tiene «la culpa»: es una limitación del lenguaje. Si el contenedor necesita un proxy precisamente basado en clase (y esto ocurre cuando el bean no tiene interfaz o cuando la inyección depende de una clase concreta), entonces final se convierte en una puerta de hierro sin llave.

Ilustración simple (compila porque no estamos intentando heredar realmente, solo mostramos la idea):

package com.example.contextflow.application.service;

public final class FinalAuditService {
    // Importante: una clase final no se puede extender, por lo que un proxy basado en clase no puede convertirse en subclase.
    public void write(String message) {
        // Aquí cualquier "envoltorio" mediante override es imposible porque la subclase ni siquiera existiría.
        System.out.println("AUDIT: " + message);
    }

    // Un proxy basado en clase no podría hacer esto:
    // class TimedAuditService extends FinalAuditService { ... } // no compilaría
}

En el proyecto ContextFlow esto es importante por una razón muy práctica: los servicios de aplicación (OrderPlacementService, OrderCancellationService, ReportingService) normalmente no crean interfaces «para cada clase», porque una interfaz por la interfaz no es una victoria. Pero la ausencia de interfaz aumenta automáticamente la probabilidad de que, si es necesario «envolver» el servicio, el contenedor se vea obligado a utilizar un proxy basado en clase.

Por lo tanto, un hábito saludable en el mundo Spring suena casi aburrido: no hagas las clases @Service final sin una razón real. Es mucho más útil y seguro hacer final los campos de dependencias y mantener la clase como normal.

3. final method e intercepción en proxy

Si final class es una prohibición de herencia, entonces final method es una prohibición de sobrescribir un método concreto. Y aquí la situación es similar: en un proxy basado en clase, el contenedor añade comportamiento sobrescribiendo el método y envolviendo la llamada con super.method(). Pero un método final no se puede sobrescribir, por lo que tampoco se puede «envolver honestamente mediante override».

Esto a menudo sorprende porque parece un detalle: «Bueno, es solo final en un método». Pero para el modelo de proxy, es literalmente un cartel de «entrada cerrada».

Veamos un mini ejemplo:

package com.example.contextflow.application.reporting;

public class ReportService {
    // Importante: un método final no se puede sobrescribir, por lo que un proxy basado en clase no puede "envolver" la llamada a generate().
    public final String generate() {
        return "daily-report";
    }

    // Un proxy basado en clase no puede sobrescribir generate()
}

Es importante aclarar cuidadosamente un matiz (sin adentrarnos en detalles). Si el proxy está construido basado en interfaz (JDK dynamic proxy), entonces no está obligado a sobrescribir los métodos de la clase target: intercepta la llamada a nivel de la interfaz y la delega al target mediante reflexión. Por lo tanto, final en un método del target no es un «freno de emergencia mágico» para un proxy de interfaz. Pero en una aplicación Spring real, no siempre se puede garantizar que sea un proxy de interfaz: depende de si hay interfaz, de cómo se inyectan las dependencias (por interfaz o por clase), y de qué estrategia de proxy elige la infraestructura.

Para ContextFlow, la conclusión práctica es: no pongas final en los métodos de la capa de servicio a menos que haya una razón muy seria. En aplicaciones educativas típicas (y en la mayoría de las profesionales), esto no aporta un beneficio tangible, pero puede convertirse en una mina inesperada para la mecánica del contenedor.

4. Métodos private y puntos de intercepción

Con private todo es incluso más simple y lógico que con final. Un método privado no es un «contrato», es un detalle interno de la clase. No es accesible desde fuera y no participa en el modelo habitual de polimorfismo: una subclase no puede sobrescribir un método private del padre porque ni siquiera «lo ve» como un punto de extensión. Y de nuevo: no hay mística de Spring, solo reglas de Java.

Construyamos un ejemplo que parece muy humano (y muy de novato): «quiero ocultar los pasos internamente para que el método externo sea bonito».

package com.example.contextflow.application.service;

public class BillingService {
    public void placeOrder() {
        // El método externo puede ser interceptado por el proxy (si la llamada llega a través de una referencia al proxy).
        validate();
        System.out.println("placeOrder done");
    }

    private void validate() {
        // El método privado es la cocina interna: no se puede llamar desde fuera, no es posible sobrescribirlo.
        System.out.println("validate ok");
    }
}

Si tienes un proxy alrededor de placeOrder(), puede interceptar placeOrder() como una llamada externa. Pero no puede «por separado» interceptar validate(), porque validate() se llama dentro del target y además es private. El proxy no puede «meterse dentro» y sustituir esa llamada.

A veces los estudiantes intentan «solucionar» esto haciendo público validate(). Técnicamente, esto crea un punto de entrada, pero casi siempre empeora el diseño: estás exponiendo un método que en realidad no debería ser parte de la API de la clase. El camino de ingeniería normal es otro: si validate o cualquier otra parte de la lógica debe ser envuelta/extensible por separado, la mayoría de las veces es una señal de que su lugar está en un componente separado (por ejemplo, OrderValidator), no en un método private.

En ContextFlow precisamente se acostumbra a mantener la lógica de dominio delgada y a extraer las partes «configurables» en servicios/puertos separados (DiscountPolicy, NotificationSender, AuditWriter). Esta solución no solo es útil para DI, sino también para la transparencia de los límites donde el contenedor puede envolver algo.

5. Llamadas internas a través de this

Ahora, la limitación más engañosa, porque parece «bueno, solo he llamado a un método». La llamada interna this.someMethod() ocurre dentro del objeto target. Y el proxy es un objeto envoltorio externo. Como resultado, la llamada no cruza el límite del proxy y no puede ser interceptada. Este fenómeno a menudo se denomina self-invocation (autollamada), pero en esencia es simplemente «una llamada a un método dentro del mismo objeto».

Para sentir esto en la piel, hagamos un pequeño ejemplo demostrativo en Java puro. No está directamente relacionado con Spring, pero muestra la mecánica de la manera más honesta.

Primero, el contrato y la implementación:

package com.example.contextflow.support.proxydemo;

public interface OrderWorkflow {
    // Contrato: el proxy interceptará precisamente las llamadas a los métodos de la interfaz.
    void create();
    void save();
}
package com.example.contextflow.support.proxydemo;

public class SimpleOrderWorkflow implements OrderWorkflow {
    @Override
    public void create() {
        System.out.println("create: start");
        // Punto clave: esto es una llamada interna dentro del objeto target, el proxy no la verá.
        this.save();
        System.out.println("create: end");
    }

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

Ahora creamos un proxy basado en interfaz y registramos «antes/después»:

package com.example.contextflow.support.proxydemo;

import java.lang.reflect.Proxy;

public class ProxyBoundaryDemo {
    public static void main(String[] args) {
        // Este es el objeto "real" (target) que ejecutará la lógica.
        OrderWorkflow target = new SimpleOrderWorkflow();

        // Este es un JDK dynamic proxy: intercepta las llamadas externas que llegan a través de la referencia al proxy.
        OrderWorkflow proxy = (OrderWorkflow) Proxy.newProxyInstance(
                OrderWorkflow.class.getClassLoader(),
                new Class<?>[]{OrderWorkflow.class},
                (obj, method, a) -> {
                    // Envoltorio "antes" de la llamada.
                    System.out.println("before " + method.getName());
                    // Delegación al target (dentro de este invoke, el target puede llamar a this.* — y eso ya no pasa por el proxy).
                    Object result = method.invoke(target, a);
                    // Envoltorio "después" de la llamada.
                    System.out.println("after " + method.getName());
                    return result;
                }
        );

        // Importante: llamamos a través del proxy, de lo contrario no habría intercepción.
        proxy.create();
    }
}

Si ejecutas mentalmente este código, obtendrás una salida similar a esta:

before create
create: start
save
create: end
after create

Observa lo más importante: no hay before save ni after save. Aunque save() fue realmente llamado. Simplemente fue llamado desde dentro del target, a través de this.save(), por lo que el proxy no vio esa llamada.

Y esto es la clave para entender el «límite del proxy». El proxy no es un «interceptor mágico de todas las llamadas JVM». Solo intercepta lo que pasa a través de él como objeto.

Cómo se ve esto en ContextFlow

En nuestro proyecto, este principio se puede encontrar de una forma muy cotidiana. Por ejemplo, si OrderPlacementService intentara construir una arquitectura interna «bonita» mediante llamadas this.* , entonces cualquier «envoltorio» a nivel del contenedor solo podría ver el método externo, pero no los pasos internos.

Compara dos estilos. El malo no porque no funcione, sino porque hace invisibles los límites:

package com.example.contextflow.application.service;

public class OrderPlacementService {
    public void placeOrder() {
        // Los pasos internos se llaman a través de this -> esto permanece dentro del target y puede ser invisible para el proxy.
        this.calculateTotal();
        this.writeAudit();
    }

    void calculateTotal() { /* ... */ }
    void writeAudit() { /* ... */ }
}

Un enfoque más saludable para DI y el modelo de contenedor es delegar los pasos a componentes separados:

package com.example.contextflow.application.service;

import org.springframework.stereotype.Service;

@Service
public class OrderPlacementService {
    private final OrderPricingService pricingService;

    public OrderPlacementService(OrderPricingService pricingService) {
        // La dependencia es inyectada por el contenedor: en este lugar podría llegar ya un proxy.
        this.pricingService = pricingService;
    }

    public void placeOrder() {
        // La llamada va a través de la referencia pricingService -> esto cruza el límite del proxy (si existe).
        pricingService.price();
    }
}

Aquí, OrderPricingService es un bean separado. Y si (por alguna razón) el contenedor devuelve pricingService ya como un proxy, entonces la llamada pricingService.price() cruza el límite del proxy y es observable. Mientras que this.calculateTotal() no lo hace.

El nombre del mecanismo aquí no es lo principal. Lo importante es otro: el proxy ve las llamadas a través de la referencia, mientras que una llamada interna es una llamada «dentro del objeto».

6. Tabla y mini diagnóstico

Tabla de limitaciones

Después de varios ejemplos, es útil recopilar todo en un mapa compacto para que la cabeza no lo mantenga como «datos» dispersos. La tabla siguiente es deliberadamente simple: no pretende ser un manual completo, pero ayuda al estudiante a no confundir causas y consecuencias.

Limitación Por qué existe esta limitación Afecta principalmente a Qué hacer en ContextFlow
final class no se puede heredar de la clase proxy basado en clase no hagas las clases @Service final sin una necesidad extrema; apóyate en interfaces donde sea natural
final method no se puede sobrescribir el método proxy basado en clase evita final en métodos de la capa de servicio; usa final en campos, no en comportamiento
private method private no es un punto de extensión ni un contrato externo proxy basado en clase y en general «intercepción de pasos internos» no intentes hacer de un método private un «punto de comportamiento de infraestructura»; si es necesario, extrae un componente separado
this.someMethod() la llamada ocurre dentro del target, no a través del proxy cualquier modelo de proxy no construyas pasos importantes del escenario como llamadas internas por «estética»; mejor delega en otros beans/colaboradores

Observa que en todas partes se repite una idea: el proxy vive en el límite, y las limitaciones son simplemente diferentes formas de «no cruzar» ese límite.

Mini diagnóstico

Cuando en tu aplicación aparece un comportamiento que «parecía que debería haber funcionado pero no lo hizo», el diagnóstico más frecuente es que la llamada no pasó por el proxy. Y aquí es útil tener un par de comprobaciones simples que no requieren heroicidades ni leer el código fuente de Spring.

Para los proxies de interfaz, puedes comprobar rápidamente si lo que tienes delante es una clase proxy:

import java.lang.reflect.Proxy;

// bean es tu objeto del contenedor (por ejemplo, un bean Spring inyectado).
boolean isJdkProxy = Proxy.isProxyClass(bean.getClass());
System.out.println("isJdkProxy = " + isJdkProxy); // por ejemplo: true

Y para una llamada interna, la prueba más honesta es registrar «antes/después» alrededor del método externo y luego verificar que el paso interno no se envuelve. Hicimos esto en el ejemplo ProxyBoundaryDemo: save() se llamó, pero los «before/after» a su alrededor no se activaron.

Dentro de ContextFlow esto se convierte en un hábito muy tranquilo: cuando dudas de «si el objeto que tengo en mis manos es el correcto», no discutes con la realidad, sino que le preguntas: getClass(), instanceof, y dónde exactamente se encuentra la llamada — fuera o dentro del target.

7. Errores típicos al trabajar con los límites del proxy

Los errores en este tema casi nunca se deben a «no me aprendí la anotación», sino a las expectativas. La persona ve una idea bonita de «envoltorio alrededor de un método» y luego comienza inconscientemente a esperar que ese envoltorio pueda verlo todo. Por lo tanto, los problemas típicos suelen surgir no a nivel de sintaxis, sino a nivel del modelo mental: dónde está el límite y qué estás llamando exactamente en tiempo de ejecución.

Error nº 1: Hacer los servicios final y luego sorprenderse de que el contenedor no pueda envolverlos.
En el código Java normal, final parece una buena protección contra la herencia. En el mundo de los contenedores, a veces se convierte en una losa de hormigón en la carretera: el proxy basado en clase físicamente no puede aparecer. En la capa de servicio, casi siempre es suficiente hacer final las dependencias (campos), no la clase.

Error nº 2: Poner final en un método «por disciplina», sin entender que el método se vuelve no sobrescribible.
Si el método es un punto potencial donde el contenedor necesita añadir comportamiento alrededor de la llamada, final simplemente prohíbe ese mecanismo. E incluso si ahora mismo «no necesitas nada», estás introduciendo una limitación en la arquitectura sin beneficio. Para servicios educativos (y la mayoría de los profesionales), esto suele ser innecesario.

Error nº 3: Intentar interceptar métodos private o hacerlos públicos para el proxy.
Un método privado es la cocina interna de la clase. El proxy no está obligado (y la mayoría de las veces no puede) «estar en tu cocina y comentar cada movimiento». Hacer público un método private por el proxy es como sacar una olla de paella al pasillo para que el guardia vea que estás removiendo el arroz. Si necesitas un punto de comportamiento separado, extrae un componente separado.

Error nº 4: Construir un escenario a base de llamadas internas this.* y esperar que el proxy vea cada paso.
La llamada interna no cruza el límite del proxy, por lo que los envoltorios alrededor del método interno no funcionarán. Esto no es un error, sino una consecuencia directa de que el proxy es un objeto externo. Si es importante que los pasos sean «externos» y crucen el límite, divide la responsabilidad en beans/clases separados y llámalos a través de dependencias inyectadas.

Error nº 5: Tratar la falta de comprensión del límite del proxy con suposiciones en lugar de diagnóstico.
Cuando algo no funciona, el principiante a veces empieza a cambiarlo todo: modificadores de acceso, anotaciones, orden de métodos. Es mucho más barato primero observar los hechos: qué tipo runtime tiene el objeto, cómo se llama exactamente el método (a través de una referencia al bean o a través de this), y dónde se encuentra el código que llama — fuera o dentro del target.

1
Tarea
Spring Core, nivel 21, lección 3
Bloqueada
Límite para `private` y `final` en una subclase similar a proxy
Límite para `private` y `final` en una subclase similar a proxy
1
Tarea
Spring Core, nivel 21, lección 3
Bloqueada
Llamada interna a través de `this` sortea el proxy
Llamada interna a través de `this` sortea el proxy
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION