How to Build a Lightweight Java RPC Framework from Scratch: Protocol Design, Dynamic Proxies, and Service Dispatch

This article breaks down the minimum viable end-to-end flow of a lightweight RPC framework: a custom TCP protocol solves packet sticking, dynamic proxies hide remote invocation details, and the server uses a registry plus reflection for request dispatch. It is ideal for understanding RPC internals. Keywords: RPC, dynamic proxy, communication protocol.

Technical specifications are summarized at a glance

Parameter Value
Implementation Language Java
Transport Protocol TCP / Socket
Protocol Structure Magic Number + Length Field + Body
Proxy Mechanism JDK Dynamic Proxy
Service Dispatch Map Registry + Reflection Invocation
Default Serialization Native Java Serialization
Replaceable Serialization JSON / Hessian2 / Protobuf
GitHub Stars Not provided in the original article
Core Dependencies java.net.Socket, ObjectInputStream, Reflection, ConcurrentHashMap

RPC architecture diagram AI Visual Insight: The image presents an RPC-themed architectural entry point that highlights the abstract relationship among the client, network communication, and service-processing layers. It works well as a conceptual guide for the “request encoding → network transport → service execution” model.

Invocation flow animation AI Visual Insight: The animation emphasizes the round-trip path of a request: it starts on the client, is packaged for network transport, reaches the server, and then returns a result. It maps well to RPC’s synchronous invocation sequence and the serialization/deserialization boundaries.

This article fully explains the minimum end-to-end RPC invocation flow

The essence of RPC is not simply “calling a remote interface.” It is the process of breaking a local method call into five stages: request construction, byte transport, service resolution, reflective execution, and result return.

The value of this lightweight implementation is that it does not introduce a service registry, connection pooling, or Netty. Instead, it uses the fewest possible components to expose the core mechanics of RPC, making it easier to understand the common foundations behind Feign, Dubbo, and gRPC.

The RPC call chain can be abstracted as a fixed pipeline

Client -> Dynamic Proxy -> Request Object -> Serialization -> Socket Transport
      -> Server Decoding -> Service Resolution -> Reflective Execution -> Response Return

This flow shows that RPC is not magic. It is simply “turning method invocation into a protocol.”

A custom communication protocol first solves TCP’s lack of message boundaries

TCP is a byte stream. When two requests are sent continuously, the server may read a partial packet or multiple packets combined together. Without explicit boundaries, the server cannot determine where one request ends.

The most common solution is to add a fixed-length field to the message header. This implementation uses a 2-byte magic number and a 4-byte message length. The structure is simple, but sufficient to form a parsable protocol.

A minimal protocol header is enough to frame requests

+------------+-------------+----------------+
| Magic (2B) | Length (4B) | body(bytes)    |
+------------+-------------+----------------+

This definition allows the receiver to read the header first and then read the body exactly according to the declared length.

The encoder packages business data into a protocol frame

public class RpcEncoder {
    public static byte[] encode(byte[] body) {
        // Pre-allocate the total length of the protocol header and message body
        ByteBuffer buffer = ByteBuffer.allocate(2 + 4 + body.length);
        buffer.putShort((short) 0xCAFE); // Write the magic number to quickly validate the protocol
        buffer.putInt(body.length);      // Write the body length to solve packet sticking and partial packets
        buffer.put(body);                // Write the serialized business data
        return buffer.array();
    }
}

This code adds a transportable protocol header to any serialized payload.

The decoder reads the header and body in a fixed order

public static byte[] decode(InputStream in) throws IOException {
    DataInputStream dis = new DataInputStream(in);
    short magic = dis.readShort();
    if (magic != (short) 0xCAFE) {
        throw new RuntimeException("Invalid protocol packet"); // Reject unexpected traffic
    }
    int len = dis.readInt();
    byte[] body = new byte[len];
    dis.readFully(body); // Must read the exact number of bytes to avoid incomplete partial data
    return body;
}

This code reconstructs a single complete RPC message from a continuous byte stream.

Dynamic proxies preserve the local method-call experience for remote invocations

On the client side, the most important abstraction is not the Socket itself. It is the ability to let application code avoid writing network logic. JDK dynamic proxies can intercept interface method calls and convert the method name, parameter types, and argument values into a standard request object.

As a result, calculator.add(10, 20) looks like a local method call at the usage layer, but at the framework layer it is translated into a remote request.

The proxy object intercepts methods and initiates network calls

public class RpcClientProxy implements InvocationHandler {
    private final String host;
    private final int port;

    public RpcClientProxy(String host, int port) {
        this.host = host;
        this.port = port;
    }

    public 
<T> T getProxy(Class<T> clazz) {
        return (T) Proxy.newProxyInstance(
                clazz.getClassLoader(),
                new Class[]{clazz},
                this
        );
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        RpcRequest request = new RpcRequest();
        request.setInterfaceName(method.getDeclaringClass().getName()); // Identify the target interface
        request.setMethodName(method.getName());                        // Identify the target method
        request.setParameterTypes(method.getParameterTypes());          // Identify the parameter signature
        request.setParameters(args);                                    // Carry the actual arguments

        try (Socket socket = new Socket(host, port);
             ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
             ObjectInputStream ois = new ObjectInputStream(socket.getInputStream())) {
            oos.writeObject(request); // Send the request object
            RpcResponse response = (RpcResponse) ois.readObject(); // Receive the execution result
            if (response.getError() != null) {
                throw response.getError(); // Propagate remote exceptions to the caller
            }
            return response.getResult();
        }
    }
}

This code automatically translates an interface method call into a synchronous RPC request.

The server completes method dispatch through a registry and reflection

The server must solve a straightforward problem: after receiving a request, how does it find the implementation class and execute the target method? The most direct approach is to maintain a mapping of interface name -> service instance.

After a request arrives, the server retrieves the implementation object by interface name, invokes the target method by reflection using the method name and parameter types, and then wraps the execution result into a response object.

Service registration and execution dispatch form a minimal service container

public class RpcServer {
    private final Map<String, Object> serviceMap = new ConcurrentHashMap<>();

    public void registerService(Object service) {
        for (Class<?> api : service.getClass().getInterfaces()) {
            serviceMap.put(api.getName(), service); // Register the service by fully qualified interface name
        }
    }

    public Object invoke(RpcRequest request) throws Exception {
        Object service = serviceMap.get(request.getInterfaceName());
        if (service == null) {
            throw new RuntimeException("Service not found: " + request.getInterfaceName());
        }
        Method method = service.getClass().getMethod(
                request.getMethodName(),
                request.getParameterTypes()
        );
        return method.invoke(service, request.getParameters()); // Execute the target method by reflection
    }
}

This code routes a network request to the correct local service implementation.

A calculator example is enough to validate the minimum viable RPC framework

Start by defining an interface, then provide an implementation. The server registers the service and listens on a port. The client only needs to obtain a proxy object and call the interface method. This usage pattern already closely resembles the developer experience of mainstream RPC frameworks.

The minimal example demonstrates a “just like local invocation” result

public interface Calculator {
    int add(int a, int b);
}

public class CalculatorImpl implements Calculator {
    @Override
    public int add(int a, int b) {
        return a + b; // Actual business logic on the server side
    }
}

RpcClientProxy proxy = new RpcClientProxy("127.0.0.1", 8888);
Calculator calculator = proxy.getProxy(Calculator.class);
int result = calculator.add(10, 20);
System.out.println(result); // Outputs 30

This code verifies that proxying, transport, dispatch, and result return are all connected end to end.

Replacing the serialization strategy determines cross-language support and performance limits

Native Java serialization is suitable for teaching, but it is usually not the best option in production. It tends to produce larger payloads, delivers average performance, has poor cross-language compatibility, and introduces deserialization security risks.

If the goal is service governance and cross-language invocation, teams typically switch to JSON, Hessian2, or Protobuf. The protocol header does not need to change. Only the body encoding and decoding mechanism needs to be replaced.

The trade-offs among serialization strategies are clear

Strategy Size Speed Cross-Language Support Readability
Native Java Large Slow Poor Low
JSON Medium Medium Strong High
Hessian2 Small Fast Strong Low
Protobuf Very Small Very Fast Strong Low

This table shows that protocol framing and serialization can evolve independently.

This lightweight implementation still lacks key production-grade RPC capabilities

It already explains the core principles, but it does not yet provide production-level reliability. At a minimum, it still needs a service registry, load balancing, long-lived connection reuse, timeout retries, and a pluggable filter chain.

As a next step, you can introduce ZooKeeper or Nacos for service discovery, adopt Netty as the high-performance networking layer, and add a Future-based model plus distributed tracing and monitoring.

FAQ

1. Why must the protocol include a length field?

Because TCP only guarantees ordered bytes, not message boundaries. A length field tells the server exactly how many bytes belong to the current request body, which solves both partial-packet and packet-sticking issues.

2. What is the core value of dynamic proxies in RPC?

A dynamic proxy intercepts a method call and converts it into a standard request, hiding the details of Socket handling, serialization, and response processing. This keeps business code focused on interface-oriented programming and reduces cognitive overhead.

3. What is still missing compared with Dubbo or gRPC?

The main gaps are a service registry, connection management, load balancing, timeout control, asynchronous invocation, service governance, and monitoring. The current version is ideal for teaching, but not for direct production deployment.

Core Summary: This article reconstructs the minimum viable implementation of a lightweight RPC framework, covering a TCP length-field protocol, Java dynamic proxies, service registration, and reflection-based dispatch, while also comparing serialization choices and outlining a clear path toward production-grade improvements.