Retrofit's Proxy pattern: how can the instance actually be created from an interface? The magic is revealed
Retrofit's Proxy pattern: how can the instance actually be created from an interface? The magic is revealed
REST APIs form the backbone of modern Android applications, yet the question of how Retrofit creates concrete implementations from plain interface definitions has puzzled many developers. When you call retrofit.create(GitHubApi.class) and receive a working API client, something remarkable happens under the hood, an entire implementation class is generated at runtime, complete with HTTP logic, parameter handling, and response parsing. This seemingly magical transformation is powered by Java's dynamic proxy mechanism combined with Retrofit's sophisticated annotation parsing and caching strategies.
In this article, you'll dive deep into the internal mechanisms of Retrofit's proxy system, exploring how Java's Proxy.newProxyInstance() creates implementation classes at runtime, how the InvocationHandler intercepts method calls and routes them to the correct execution path, how Retrofit's three-state cache ensures thread-safe lazy initialization with lock-free fast paths, and how annotation metadata is transformed into executable HTTP requests. This isn't a guide on using Retrofit, it's a deep dive into how the instance creation magic actually works.
Understanding the fundamental challenge: From interfaces to instances
At its core, Retrofit faces a seemingly impossible task: creating instances from interfaces. In standard Java, you cannot instantiate an interface:
interface GitHubApi {
@GET("users/{user}/repos")
fun listRepos(@Path("user") user: String): Call<List<Repo>>
}
// This won't compile
val api = GitHubApi() // ERROR: Cannot create an instance of an interface
Interfaces define contracts, not implementations. Yet Retrofit allows you to write:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.github.com/")
.build()
val api = retrofit.create(GitHubApi::class.java)
val repos = api.listRepos("octocat").execute()
The api object behaves as if someone manually wrote an implementation class that handles HTTP requests, parameter encoding, and response parsing. How does Retrofit generate this implementation without any explicit code?
The answer lies in Java's dynamic proxy pattern, a runtime code generation mechanism that allows creating interface implementations on the fly. Understanding this pattern is key to understanding Retrofit's architecture.
The dynamic proxy pattern: Runtime class generation
Java's java.lang.reflect.Proxy class provides a mechanism to create proxy instances that implement specified interfaces at runtime. Let's examine how this works before diving into Retrofit's specific implementation.
The basic proxy mechanism
The Proxy.newProxyInstance() method takes three parameters and returns an object that implements your interface:
public static Object newProxyInstance(
ClassLoader loader, // Which ClassLoader to define the proxy class in
Class<?>[] interfaces, // The list of interfaces to implement
InvocationHandler h // The handler that processes method calls
)
When you call any method on the proxy instance, Java automatically routes the call to the InvocationHandler.invoke() method, which receives:
- The proxy instance itself
- The
Methodobject representing the called method - The array of arguments passed to the method
This is powerful because you can implement one handler that processes all methods on the interface. The handler can inspect the method's annotations, parameters, and return type to determine what to do.
Here's a minimal example:
interface HelloService {
String sayHello(String name);
String sayGoodbye(String name);
}
HelloService service = (HelloService) Proxy.newProxyInstance(
HelloService.class.getClassLoader(),
new Class<?>[] { HelloService.class },
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
String methodName = method.getName();
String name = (String) args[0];
return methodName + " called with: " + name;
}
}
);
service.sayHello("Alice"); // Returns: "sayHello called with: Alice"
service.sayGoodbye("Bob"); // Returns: "sayGoodbye called with: Bob"
The same invoke() method handles both sayHello() and sayGoodbye(). It inspects the method name and arguments to determine the behavior.
This is exactly how Retrofit works: it creates a proxy instance where every method call is routed to an InvocationHandler that parses annotations and constructs HTTP requests.
Retrofit's proxy creation: The create() method
Let's examine Retrofit's actual implementation of create() in the Retrofit class:
@SuppressWarnings("unchecked")
public <T> T create(final Class<T> service) {
validateServiceInterface(service);
return (T)
Proxy.newProxyInstance(
service.getClassLoader(),
new Class<?>[] {service},
new InvocationHandler() {
private final Object[] emptyArgs = new Object[0];
@Override
public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args)
throws Throwable {
// If the method is a method from Object then defer to normal invocation.
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
}
args = args != null ? args : emptyArgs;
Reflection reflection = Platform.reflection;
return reflection.isDefaultMethod(method)
? reflection.invokeDefaultMethod(method, service, proxy, args)
: loadServiceMethod(service, method).invoke(proxy, args);
}
});
}
This code reveals Retrofit's elegant architecture. Let's break down each component.
Phase 1: Interface validation
Before creating the proxy, Retrofit validates the interface in the validateServiceInterface() method:
private void validateServiceInterface(Class<?> service) {
if (!service.isInterface()) {
throw new IllegalArgumentException("API declarations must be interfaces.");
}
Deque<Class<?>> check = new ArrayDeque<>(1);
check.add(service);
while (!check.isEmpty()) {
Class<?> candidate = check.removeFirst();
if (candidate.getTypeParameters().length != 0) {
StringBuilder message =
new StringBuilder("Type parameters are unsupported on ").append(candidate.getName());
if (candidate != service) {
message.append(" which is an interface of ").append(service.getName());
}
throw new IllegalArgumentException(message.toString());
}
Collections.addAll(check, candidate.getInterfaces());
}
// ...
}
This validation enforces two critical constraints:
1. Must be an interface: JDK dynamic proxies only work with interfaces. If you try to pass a class, you get an immediate error. This is a JVM limitation, proxying concrete classes requires bytecode generation libraries like CGLIB or ByteBuddy, which Retrofit intentionally avoids to keep the runtime dependency footprint small.
2. No generic type parameters: Interfaces like interface Api<T> are forbidden. Why? Type parameters are erased at runtime due to Java's type erasure. If you had Api<User>, the runtime Class object would just be Api with no knowledge of User. This would make it impossible to correctly parse return types and generate converters. By enforcing this constraint at validation time, Retrofit catches the error early rather than producing confusing runtime behavior.
The breadth-first search through the interface hierarchy (Collections.addAll(check, candidate.getInterfaces())) ensures that even inherited interfaces don't violate these rules. If GitHubApi extends BaseApi<User>, the validator catches the generic parameter on BaseApi.
Phase 2: Proxy instance creation
After validation, Retrofit creates the proxy using Proxy.newProxyInstance(). The InvocationHandler implementation contains the core routing logic with three distinct dispatch paths:
Dispatch path 1: Object methods
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
}
This handles methods inherited from Object: equals(), hashCode(), toString(), getClass(), etc. These methods are delegated to the handler itself, not processed as HTTP endpoints.
Why is this necessary? Without this check, calling api.toString() would trigger Retrofit's annotation parsing logic, fail (because toString() has no HTTP annotations), and throw an exception. By delegating to this, the proxy instance behaves like a normal Java object.
This also means that two proxy instances created by the same Retrofit instance are not equal (api1.equals(api2) returns false) because each InvocationHandler is a distinct anonymous class instance.
Dispatch path 2: Default interface methods
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor