1. Overview
In this lesson, we’ll explore the Proxy pattern: what problem it solves, how to implement a protection proxy in Java, and when to use it. The Proxy pattern lets us control access to an object by placing a surrogate between the client and the real object, without modifying the real object itself.
The relevant module we need to import when starting this lesson is: the-proxy-pattern-start.
If we want to reference the fully implemented lesson, we can import: the-proxy-pattern-end.
2. The Problem: Uncontrolled Access to Sensitive Operations
Let’s look at the start project. We have a Task class with an id (Long), a name (String), and a status (TaskStatus enum). We also have a TaskRepository interface with three methods: save(Task), findAll() returning a List<Task>, and deleteTask(Long). Finally, there’s an InMemoryTaskRepository that implements TaskRepository using a HashMap<Long, Task>:
The project also includes a UserRole enum with two values (ADMIN and USER) and a UserContextHolder class that stores the current user’s role in a ThreadLocal.
Now, suppose we need to restrict who can delete tasks. Right now, anyone can call deleteTask(), and there’s no concept of authorization. We want only administrators to be able to delete tasks.
The naive approach would be to add a role check directly inside InMemoryTaskRepository.deleteTask(): if the user isn’t an admin, throw an exception. This works in the short term, but it mixes storage responsibility with access-control logic. InMemoryTaskRepository now has two reasons to change: when storage logic changes, and when access rules change. If we later need read restrictions, write restrictions, or audit logging, the repository fills up with non-storage concerns. And every new access rule means modifying the existing class.
There’s also a scaling issue. If we add a second repository implementation later, we’d have to duplicate the same role-checking logic inside that class as well. The access rules aren’t tied to a specific storage mechanism; they’re a cross-cutting concern that should live in its own place.
We need a way to control access to the repository without modifying the repository itself. The solution is to put a surrogate between the client and the real object.
3. The Proxy Pattern
The Proxy pattern is a structural pattern that places a surrogate between the client and the real object. Let’s look at its purpose, participants, and the different forms it can take.
3.1. Purpose and Intent
The Proxy pattern provides a surrogate or placeholder for another object to control access to it. The client interacts with the proxy as if it were the real object, and the proxy decides whether and how to forward the request.
This is where the Proxy’s intent becomes important. Both Proxy and Decorator wrap an object behind the same interface, but their purposes differ fundamentally. A Proxy controls whether the client can access the object. A Decorator changes what the object does when accessed. This distinction will come up again when we look at related patterns.
3.2. GoF Participants
The Proxy pattern defines four participants:
- Subject – the interface that both the real object and proxy implement (TaskRepository)
- RealSubject – the actual implementation that does the work (InMemoryTaskRepository)
- Proxy – implements Subject, holds a reference to RealSubject, and controls access to it (SecurityProxyRepository)
- Client – uses the Subject interface without knowing whether it’s talking to the real object or the proxy
3.3. Types of Proxies
The Proxy pattern takes several forms, each with a different purpose:
- Protection Proxy – restricts access based on permissions or roles; the proxy checks authorization before forwarding the request
- Virtual Proxy – defers creation or loading of an expensive object until it’s actually needed (the proxy stands in as a lightweight placeholder and triggers the real initialization only on first use)
- Remote Proxy – represents an object in a different address space; hides the network communication from the client
- Smart Proxy – adds housekeeping operations such as reference counting, locking, or logging when the object is accessed
Our implementation will be a protection proxy.
4. Implementing a Protection Proxy
Now that we understand the pattern and its participants, let’s build a protection proxy step by step, test it, and then reflect on the SOLID principles it leverages.
4.1. Supporting Types
The start project already includes two supporting types. UserRole is an enum with two values: ADMIN and USER. UserContextHolder is a utility class that stores the current user’s role in a ThreadLocal<UserRole> with static setRole(UserRole) and getRole() methods:
These are just enough to represent who’s making the request.
UserContextHolder is a deliberate simplification. In a real application, the current user’s identity would typically come from a request-scoped security context (such as Spring Security’s SecurityContextHolder), but a thread-local holder keeps the example self-contained while staying closer to how production code actually resolves the current user than constructor injection would.
4.2. The SecurityProxyRepository
Let’s create a SecurityProxyRepository class in the com.baeldung.ldp.proxy package:
public class SecurityProxyRepository implements TaskRepository {
private final TaskRepository realRepository;
public SecurityProxyRepository(TaskRepository realRepository) {
this.realRepository = realRepository;
}
@Override
public void save(Task task) {
realRepository.save(task);
}
@Override
public List<Task> findAll() {
return realRepository.findAll();
}
@Override
public void deleteTask(Long id) {
if (UserContextHolder.getRole() != UserRole.ADMIN) {
throw new SecurityException("Only ADMIN users can delete tasks");
}
realRepository.deleteTask(id);
}
}
Let’s walk through the mechanism. The constructor takes a TaskRepository (the Subject interface). Notice that the field is typed as TaskRepository, not InMemoryTaskRepository. The proxy doesn’t know or care which concrete repository it wraps. At runtime, it will be an InMemoryTaskRepository, but programming to the interface means we could swap in a different implementation without touching the proxy.
The save() and findAll() methods delegate directly to the real repository with no restrictions. Any client calling these methods gets the same behavior as if it were talking to InMemoryTaskRepository directly.
The deleteTask() method is where the proxy earns its name. It reads the current user’s role from UserContextHolder.getRole() before doing anything. If the user is an admin, it delegates to the real repository. Otherwise, it throws a SecurityException with a descriptive message. The real repository’s deleteTask() is never reached for unauthorized users.
The proxy implements the same TaskRepository interface, so the client code doesn’t know or care whether it’s talking to InMemoryTaskRepository or SecurityProxyRepository. Access control lives entirely in the proxy. Notice that InMemoryTaskRepository has no knowledge of roles, permissions, or access control; it just stores and retrieves tasks. The proxy handles the “who is allowed to do what” concern.
4.3. Testing the Protection Proxy
Let’s add a test class, ProxyPatternUnitTest, in the com.baeldung.ldp.proxy package. We’ll start with a test that verifies an admin can delete tasks:
class ProxyPatternUnitTest {
@Test
void givenAdminUser_whenDeleteTask_thenTaskIsRemoved() {
TaskRepository realRepo = new InMemoryTaskRepository();
UserContextHolder.setRole(UserRole.ADMIN);
TaskRepository proxy = new SecurityProxyRepository(realRepo);
proxy.save(new Task(1L, "Design review", TaskStatus.TO_DO));
proxy.deleteTask(1L);
assertTrue(proxy.findAll().isEmpty());
}
}
The test creates an InMemoryTaskRepository, sets the current role to ADMIN via UserContextHolder, wraps the repository in a SecurityProxyRepository, saves a task, deletes it, and verifies that the task is gone. The key point is that the test interacts entirely through the TaskRepository interface. The proxy is transparent.
Now let’s add a second test that verifies a non-admin user is blocked from deleting:
@Test
void givenNonAdminUser_whenDeleteTask_thenThrowsSecurityException() {
TaskRepository realRepo = new InMemoryTaskRepository();
UserContextHolder.setRole(UserRole.USER);
TaskRepository proxy = new SecurityProxyRepository(realRepo);
proxy.save(new Task(1L, "Design review", TaskStatus.TO_DO));
assertThrows(SecurityException.class, () -> proxy.deleteTask(1L));
}
This time, the role is set to USER. Calling deleteTask() throws a SecurityException.
Finally, let’s confirm that non-restricted operations work normally for all users:
@Test
void givenNonAdminUser_whenSaveAndFindAll_thenOperationsSucceed() {
TaskRepository realRepo = new InMemoryTaskRepository();
UserContextHolder.setRole(UserRole.USER);
TaskRepository proxy = new SecurityProxyRepository(realRepo);
proxy.save(new Task(1L, "Design review", TaskStatus.TO_DO));
assertEquals(1, proxy.findAll().size());
}
A non-admin user can still save and read tasks. Only deleteTask() is restricted. This confirms the proxy is selective: it controls access to the sensitive operation, not to everything.
Let’s run mvn test to verify all three tests pass.
4.4. SOLID Principles at Work
The protection proxy aligns well with two SOLID principles.
The Open/Closed Principle (OCP) is at work because we added access control to TaskRepository without modifying InMemoryTaskRepository. The real repository is closed for modification but open for extension through proxies. If we later need audit logging or rate limiting, we write a new proxy. The existing classes remain untouched.
The Single Responsibility Principle (SRP) is satisfied because InMemoryTaskRepository is responsible for storage only, while SecurityProxyRepository is responsible for access control only. We can compare this with the naive approach from Section 2, where one class handled both concerns. Now each class has one clear reason to change: InMemoryTaskRepository changes when storage logic changes, and SecurityProxyRepository changes when access rules change.
This separation also makes each class independently testable. We can verify storage logic without worrying about roles, and access-control logic without a real database.
5. When to Use and When Not To
With the implementation complete, let’s look at when this pattern is a good fit and when it isn’t.
5.1. When to Use
The Proxy pattern fits when we need to add a layer of control between a client and an object without modifying the object itself:
- When access depends on permissions, roles, or other authorization criteria (protection proxy)
- When we want to defer expensive object creation until it’s actually needed (virtual proxy)
- When we need to represent a remote object locally and hide the network communication (remote proxy)
- When we want to add housekeeping logic such as counting references or thread-safety checks without modifying the real object (smart proxy)
Our SecurityProxyRepository restricts only deleteTask(), but the same approach extends naturally to other methods. We could restrict save() for read-only users, or restrict findAll() based on data-visibility rules. Whether to add those checks to the existing proxy or create separate proxy classes depends on whether the access rules share the same concern. Role-based restrictions naturally belong in one proxy, while a separate concern like audit logging would justify a separate proxy.
5.2. When Not to Use
A proxy isn’t always the right tool. When the access control is trivial and a single if check in the calling code is simpler and clearer, introducing a proxy class adds unnecessary indirection. Similarly, when there’s no meaningful separation of concerns, splitting proxy logic and real logic into separate classes creates artificial complexity. For example, if the only consumer of deleteTask() is a single service method that already validates the user’s role as part of its workflow, wrapping the repository in a proxy just to repeat that same check adds a class with no independent value.
Performance overhead is worth considering, though it varies by proxy type. For protection proxies with simple role checks, the overhead is negligible. But for virtual proxies, lazy loading adds initialization latency on first access. For remote proxies, network round-trips introduce real delay. The overhead is proportional to what the proxy does, not to the pattern itself.
6. Real-World Usage
The Proxy pattern appears throughout the Java ecosystem. Let’s examine three examples from different layers.
6.1. Collections.unmodifiableList
Collections.unmodifiableList(list) returns a view of the given list that blocks all modification operations. This is a protection proxy that restricts write access while allowing reads. It’s a clean, accessible example because the pattern is visible directly in the JDK, without any framework overhead.
Let’s map the participants. The Subject is the List<E> interface. The RealSubject is the original list passed to unmodifiableList(), such as an ArrayList. The Proxy is the UnmodifiableList inner class that the method returns. This class is package-private, so we can’t instantiate it directly. The only way to get one is through the Collections.unmodifiableList() factory method, which hides the proxy behind the List interface.
The proxy delegates read operations directly to the wrapped list. get(index), size(), contains(), and iterator() all pass through without modification. Write operations are a different story: add(), remove(), set(), and clear() all throw UnsupportedOperationException. The proxy doesn’t add behavior to these methods; it blocks them entirely.
Here’s a brief usage example:
List<String> original = new ArrayList<>(List.of("A", "B", "C"));
List<String> readOnly = Collections.unmodifiableList(original);
readOnly.get(0); // works: delegates to original
readOnly.add("D"); // throws UnsupportedOperationException
The key insight is that this is exactly the protection proxy pattern. Same interface, selective access control, and transparent delegation for allowed operations. The parallel with our implementation is clear: SecurityProxyRepository checks the user‘s role before allowing deleteTask(), while UnmodifiableList checks the operation type before allowing mutation. Both are protection proxies. They differ in what they protect against, but the structural pattern is identical.
6.2. Hibernate Lazy-Loading Proxies
When Hibernate loads an entity with a lazy association (@ManyToOne(fetch = FetchType.LAZY)), it doesn’t hit the database immediately. Instead, it returns a proxy subclass (generated at runtime via bytecode libraries) that extends the entity class. The proxy holds only the entity’s ID initially. When any other property is accessed, the proxy intercepts the call, executes the database query, populates the real entity, and delegates the method call.
This is a virtual proxy. The defining characteristic is deferred creation until first real access, which maps directly to the virtual proxy type from Section 3.3. Unlike our protection proxy that decides whether to allow a call, the Hibernate proxy decides when to load the data.
6.3. Spring Security Method Security
Spring Security’s method security annotations (@PreAuthorize, @Secured) work on the same principle as our SecurityProxyRepository. Spring generates a proxy around the annotated bean and checks the security expression before forwarding the call to the real method. If the check fails, it throws an AccessDeniedException, just as our proxy throws a SecurityException. The mechanism is more sophisticated (expression-based rules, integration with the authentication context), but the core idea is the same: a proxy that decides whether a method call is allowed to proceed.
7. Related Patterns
Both Proxy and Decorator wrap an object behind the same interface and delegate to it. The difference is intent. A Proxy controls whether the client can access the wrapped object. It may block the call, defer it, or redirect it. A Decorator changes what the object does when accessed. It adds behavior such as caching, timestamping, or buffering to calls that always go through.
In practice, the structural similarity means the line can blur. The intent behind the wrapper is what determines which pattern it is.
8. Conclusion
In this lesson, we’ve explored the Proxy pattern, which provides a surrogate that controls access to another object. The client works with the proxy through the same interface, unaware of the indirection.
We built a SecurityProxyRepository that restricts deleteTask() to admin users without modifying InMemoryTaskRepository. The proxy checks the user’s role and either forwards the request or throws a SecurityException, keeping access control and storage logic in separate classes.
The pattern takes several forms: protection proxies restrict operations, virtual proxies defer creation, remote proxies hide network boundaries, and smart proxies add housekeeping. The structural pattern is the same across all of them; the intent differs. The trade-offs are real but manageable: additional indirection, extra classes, and performance considerations that vary by proxy type. The core benefit is clear: the Proxy pattern separates “who can do what” from “how it’s done.”