CodeGym /Courses /Spring Core /Proxy boundaries: final, private, this

Proxy boundaries: final, private, this

Spring Core
Level 21, Lesson 3
Available

1. Proxy boundary: what is intercepted

When you first get acquainted with proxy, it is tempting to think: “Oh, now I can wrap any method calls however I want.” That feeling is similar to a beginner setting a breakpoint and, for a second, believing they now control the universe. Reality is calmer: proxy intercepts only those calls that actually pass through it, that is, cross the “calling code → proxy” boundary.

Let us establish a simple model. Imagine proxy as a security guard at the office entrance: if you come in through the door, the guard sees you. If you sneaked in through the ventilation shaft (an internal call inside the object), the guard may be a very good specialist — but they literally have no idea that you are already inside.

sequenceDiagram
    participant C as "Caller (client)"
    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

Here the proxy saw only placeOrder(), because that is the call that came “from outside.” And the internal this.saveOrder() happened inside the target: the proxy was not standing there, and at that moment the “office door” was not used at all.

For the rest of the discussion, it is useful to keep one very practical formula in mind: proxy intercepts only external calls through the proxy reference. Anything that is “inside the target” may end up outside the field of view.

2. final class and class-based proxy

final in Java is like a seal saying “do not touch, I decided this.” Sometimes that is justified, but in service classes it more often appears out of habit or aesthetics: “let nobody inherit from it, I like order.” The problem is that a class-based proxy (the one we discussed in lecture 2) is very often built through inheritance: the proxy becomes a subclass of the target and overrides methods.

And a final class in Java cannot be extended. At all. Not in any way. And here Spring is no longer to “blame”: this is a language limitation. If the container needs a proxy by class specifically (and that does happen when a bean has no interface or when wiring is tied to a concrete class), then final turns into an iron door with no key.

Here is the simplest illustration (it compiles because we are not actually trying to inherit, just showing the idea):

package com.example.contextflow.application.service;

public final class FinalAuditService {
    // Important: a final class cannot be extended, so a class-based proxy physically cannot become a subclass.
    public void write(String message) {
        // Here any "wrapper" through override is impossible, because there will be no subclass at all.
        System.out.println("AUDIT: " + message);
    }

    // A class-based proxy would not be able to do this:
    // class TimedAuditService extends FinalAuditService { ... } // will not compile
}

In the ContextFlow project, this matters for a very down-to-earth reason: application services (OrderPlacementService, OrderCancellationService, ReportingService) usually do not create interfaces “for every class,” because an interface for the sake of an interface is not a victory. But the absence of an interface automatically increases the chance that, when the container needs to “wrap” a service, it will be forced to use a class-based proxy.

Therefore, a healthy habit in the Spring world sounds almost boring: do not make @Service classes final without a real reason. It is much more useful and safer to make dependency fields final and keep the class ordinary.

3. final method and proxy interception

If a final class is a ban on inheritance, then a final method is a ban on overriding a specific method. And the situation here is similar: in a class-based proxy, the container adds behavior by overriding the method and wrapping the super.method() call. But a final method cannot be overridden, which means it also cannot be wrapped “properly through override.”

This often surprises people, because it looks like a small detail: “So what, final on one method.” But for the proxy model, it is literally a sign that says “entry closed.”

Let us look at a mini example:

package com.example.contextflow.application.reporting;

public class ReportService {
    // Important: a final method cannot be overridden, so a class-based proxy cannot "wrap" the generate() call.
    public final String generate() {
        return "daily-report";
    }

    // a class-based proxy will not be able to override generate()
}

It is important to explain the nuance carefully. If the proxy is built by interface (JDK dynamic proxy), then it does not have to override target class methods at all: it intercepts the call at the interface level and delegates to the target via reflection. Therefore, final on a target method is not, by itself, a magical kill switch for an interface proxy. But in a real Spring application, you cannot always guarantee that it will be an interface proxy: much depends on whether there is an interface, how you inject dependencies (by interface or by class), and which proxy strategy the infrastructure chooses.

For ContextFlow, the practical takeaway is this: do not put final on service layer methods unless you have a very serious reason. In a normal educational application (and in most production applications), this does not provide any noticeable benefit, but it can become an unexpected landmine under container mechanics.

4. private methods and interception points

With private, everything is even simpler and more logical than with final. A private method is not a “contract”; it is an internal detail of the class. It is not accessible from outside and does not participate in the usual polymorphism model: a subclass cannot override a parent’s private method, because it does not even “see” it as an extension point. And again: no Spring mysticism, just Java rules.

Let us assemble an example that looks very human (and very junior): “I want to hide the steps inside so that the external method is clean.”

package com.example.contextflow.application.service;

public class BillingService {
    public void placeOrder() {
        // The outer method can be intercepted by a proxy (if the call came through the proxy reference).
        validate();
        System.out.println("placeOrder done");
    }

    private void validate() {
        // Private method: internal business, cannot be called from outside, override is impossible.
        System.out.println("validate ok");
    }
}

If you have a proxy around placeOrder(), it can intercept placeOrder() as an external call. But it cannot “separately” intercept validate(), because validate() is called inside the target and is also private. The proxy cannot “crawl inside” and substitute that call.

Sometimes students try to “fix” this by making validate() public. Technically, that does create an entry point, but it almost always worsens the design: you expose a method that should not really be part of the class API. The normal engineering path here is different: if validate or any other part of the logic should be separately wrappable/extendable, then most often that is a sign that it belongs in a separate component (for example, OrderValidator), not in a private method.

In ContextFlow, it is exactly the norm to keep domain logic thin and move “configurable” parts into separate services/ports (DiscountPolicy, NotificationSender, AuditWriter). This solution is useful not only for DI, but also for the transparency of the boundaries where the container can wrap something.

5. Internal calls through this

Now for the trickiest limitation, because it looks like “well, I just called a method.” An internal call this.someMethod() happens inside the target object. And the proxy is an external wrapper object. As a result, the call does not cross the proxy boundary and cannot be intercepted. This phenomenon is often called self-invocation, but in essence it is just “a method call inside the same object.”

To feel this in practice, let us make a small demonstration example in plain Java. It is not directly about Spring, but it shows the mechanics as honestly as possible.

First, the contract and implementation:

package com.example.contextflow.support.proxydemo;

public interface OrderWorkflow {
    // Contract: the proxy will intercept exactly interface method calls.
    void create();
    void save();
}
package com.example.contextflow.support.proxydemo;

public class SimpleOrderWorkflow implements OrderWorkflow {
    @Override
    public void create() {
        System.out.println("create: start");
        // Key point: this is an internal call inside the target object, the proxy will not see it.
        this.save();
        System.out.println("create: end");
    }

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

Now we create an interface proxy and log “before/after”:

package com.example.contextflow.support.proxydemo;

import java.lang.reflect.Proxy;

public class ProxyBoundaryDemo {
    public static void main(String[] args) {
        // This is the "real" object (target) that will execute the logic.
        OrderWorkflow target = new SimpleOrderWorkflow();

        // This is a JDK dynamic proxy: it intercepts external calls that come through the proxy reference.
        OrderWorkflow proxy = (OrderWorkflow) Proxy.newProxyInstance(
                OrderWorkflow.class.getClassLoader(),
                new Class<?>[]{OrderWorkflow.class},
                (obj, method, a) -> {
                    // Wrapper "before" the call.
                    System.out.println("before " + method.getName());
                    // Delegation to the target (inside this invoke, the target may call this.* — and that is no longer through the proxy).
                    Object result = method.invoke(target, a);
                    // Wrapper "after" the call.
                    System.out.println("after " + method.getName());
                    return result;
                }
        );

        // Important: call through the proxy, otherwise there will be no interception at all.
        proxy.create();
    }
}

If you mentally execute this code, you get something like the following output:

before create
create: start
save
create: end
after create

Pay attention to the most important part: there is no before save and after save. Even though save() was actually called. It was simply called from inside the target, through this.save(), so the proxy did not see that call.

And that is the key to understanding the “proxy boundary.” A proxy is not a magical interceptor of all JVM calls. It intercepts only what passes through it as an object.

What this looks like in ContextFlow

In our project, this principle can be seen in a very everyday form. For example, if OrderPlacementService tried to build itself a “beautiful” internal architecture through this.* calls, then any “wrappers” at the container level might see only the outer method, but not the internal steps.

Compare two styles. The bad one not because it does not work, but because it makes the boundaries invisible:

package com.example.contextflow.application.service;

public class OrderPlacementService {
    public void placeOrder() {
        // Internal steps are called through this -> this stays inside the target and may be invisible to the proxy.
        this.calculateTotal();
        this.writeAudit();
    }

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

A healthier approach for DI and the container model is to delegate steps to separate components:

package com.example.contextflow.application.service;

import org.springframework.stereotype.Service;

@Service
public class OrderPlacementService {
    private final OrderPricingService pricingService;

    public OrderPlacementService(OrderPricingService pricingService) {
        // The dependency is injected by the container: at this point, a proxy may already be here.
        this.pricingService = pricingService;
    }

    public void placeOrder() {
        // The call goes through the pricingService reference -> this crosses the proxy boundary (if there is one).
        pricingService.price();
    }
}

Here OrderPricingService is a separate bean. And if, for some reason, the container returns pricingService to you already as a proxy, then the call pricingService.price() will cross the boundary of the proxy and be observable. But this.calculateTotal() will not.

The name of the mechanism here is not the main thing. What matters is this: proxy sees calls through a reference, while an internal call is a call “inside the object.”

6. Table and mini-diagnostics

Table of limitations

After several examples, it is useful to gather everything into one compact map so the mind does not keep it as scattered “facts.” The table below is deliberately simple: it does not claim to be a complete reference, but it is excellent at helping a student avoid mixing up causes and effects.

Limitation Why this limitation exists at all What it hits hardest What to do in ContextFlow
final class you cannot inherit from the class class-based proxy do not make @Service classes final without an extreme need; rely on interfaces where that is natural
final method you cannot override the method class-based proxy avoid final on service layer methods; use final on fields, not on behavior
private method private is not an extension point and not an external contract class-based proxy and, in general, “intercepting internal steps” do not try to make a private method a “point of infrastructure behavior”; if needed, extract a separate component
this.someMethod() the call happens inside the target, not through the proxy any proxy model do not build important scenario steps as internal calls for the sake of “beauty”; it is better to delegate to other beans/collaborators

Notice that the same thought repeats everywhere: proxy lives on the boundary, and limitations are just different ways of “not crossing” that boundary.

Mini-diagnostics

When behavior appears in your application that “should have worked, but did not,” the most common diagnosis is that the call did not pass through the proxy. And here it is useful to have a couple of simple checks that require neither heroism nor reading Spring’s source code.

For interface proxies, you can quickly check whether you are dealing with a proxy class at all:

import java.lang.reflect.Proxy;

// bean is your object from the container (for example, a Spring bean that was injected).
boolean isJdkProxy = Proxy.isProxyClass(bean.getClass());
System.out.println("isJdkProxy = " + isJdkProxy); // for example: true

And for an internal call, the most honest test is to print “before/after” logs around the external method and then verify that the internal step is not wrapped. We did that in the ProxyBoundaryDemo example: save() was called, but “before/after” around it did not fire.

Inside ContextFlow, this turns into a very calm habit: when you doubt “whether the object in your hands is the right one,” you do not argue with reality, you ask it: getClass(), instanceof, and exactly where the call is happening — outside or inside the target.

7. Typical mistakes when working with proxy boundaries

Mistakes in this topic are almost always not about “I did not learn the annotation,” but about expectations. A person sees the beautiful idea of a “wrapper around a method,” and then begins to unconsciously expect that this wrapper can see absolutely everything. Therefore, typical problems usually arise not at the level of syntax, but at the level of the mental model: where the boundary runs and exactly what you are calling at runtime.

Mistake #1: making services final and then wondering why the container cannot wrap them.
In ordinary Java code, final seems like a good defense against inheritance. In a container world, it sometimes turns into a concrete slab in the road: a class-based proxy physically cannot appear. In the service layer, it is almost always enough to make dependencies (fields) final, not the class.

Mistake #2: putting final on a method “for discipline” without understanding that the method becomes non-overridable.
If a method is a potential point where the container needs to add behavior around a call, final simply forbids that mechanism. And even if right now you “do not need anything,” you are baking a limitation into the architecture without benefit. For educational (and most production) services, this is usually unnecessary.

Mistake #3: trying to intercept private methods or making them public just for proxy.
A private method is the internal business of the class. A proxy is not required to (and most often cannot) “stand in your kitchen and comment on every movement.” Making a private method public for the sake of proxy is like moving a pot of pilaf into the hallway so the guard can see that you are stirring rice. If you need a separate behavior point, extract a separate component.

Mistake #4: building a scenario out of internal this.* calls and expecting the proxy to see every step.
An internal call does not cross the proxy boundary, so wrappers around the internal method will not work. That is not a bug, but a direct consequence of the fact that the proxy is an external object. If it is important to you that steps be “external” and pass through the boundary, split responsibility into separate beans/classes and call them through injected dependencies.

Mistake #5: treating proxy boundary confusion with guesses instead of diagnostics.
When something does not work, a beginner sometimes starts changing everything at once: access modifiers, annotations, method order. It is much cheaper to first look at the facts: what the runtime type of the object is, how exactly the method is called (through a bean reference or through this), and where the calling code is — outside or inside the target.

1
Task
Spring Core, level 21, lesson 3
Locked
Boundary for `private` and `final` in a proxy-like subclass
Boundary for `private` and `final` in a proxy-like subclass
1
Task
Spring Core, level 21, lesson 3
Locked
A self-call through `this` bypasses proxy
A self-call through `this` bypasses proxy
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION