The first time I wrote an RPC client in a Java project, I ended up with a hand-rolled HTTP wrapper, a pile of ObjectMapper calls, and a helper class nobody wanted to touch. The remote call worked, but the code around it read like plumbing. What I actually wanted was to call a method on an interface and have the network disappear.
That is the idea behind Nami, the declarative RPC client in Solon. You define an interface, annotate it, and call it like a local bean. The transport, serialization, and service lookup happen underneath. This post walks through the three-project layout Solon uses for RPC, how the client side works, and the knobs you reach for when a plain call is not enough.
The three-part shape of a Solon RPC service
Solon organizes an RPC service into three pieces, and keeping them separate is what makes the whole thing clean:
- The service interface, shared by both sides
- The service implementation, running as its own process
- The service consumer, calling the interface as if it were local
The interface project is the contract. It should stand on its own with full domain independence. The official guidance is explicit about this: do not fold it into a common module. Give it a real name and let both sides depend on it.
//
// Note: method names must not repeat!
//
public interface UserService {
void add(User user);
User getById(long userId);
}
Enter fullscreen mode Exit fullscreen mode
The data entity travels across the wire, so it implements Serializable to stay compatible with any serialization scheme:
@Data
public class User implements Serializable {
long userId;
String name;
int level;
}
Enter fullscreen mode Exit fullscreen mode
That is the entire shared surface. No framework dependency required in the interface project itself.
The server side: publish with @Remoting
The server implements the interface and exposes it. This example uses HTTP + JSON, so it only needs solon-web. In practice, writing the server is barely different from writing a normal web project.
server.port: 9001
solon.app:
group: "demo"
name: "userapi"
Enter fullscreen mode Exit fullscreen mode
public class ServerApp {
public static void main(String[] args) {
Solon.start(ServerApp.class, args);
}
}
@Mapping("/rpc/v1/user")
@Remoting
public class UserServiceImpl implements UserService {
@Inject
UserMapper userMapper;
@Override
public void add(User user) {
userMapper.add(user);
}
@Override
public User getById(long userId) {
return userMapper.getById(userId);
}
}
Enter fullscreen mode Exit fullscreen mode
@Remoting is the part that turns an ordinary component into a remote endpoint, and @Mapping gives it a path. Package it, run java -jar userapi.jar, and the service is live on port 9001.
The client side: inject with @NamiClient
The consumer depends on the same interface project and pulls in solon-rpc, which bundles what the client needs. The call site is where Nami earns its keep:
@Mapping("user")
@Controller
public class UserController {
// point at the address and serialization scheme directly
@NamiClient(url = "http://localhost:9001/rpc/v1/user", headers = ContentTypes.JSON)
UserService userService;
@Post
@Mapping("register")
public Result register(User user) {
// call the remote service to add a user
userService.add(user);
return Result.succeed();
}
}
Enter fullscreen mode Exit fullscreen mode
Look at register. There is no HTTP client, no JSON encoding, no response parsing. Just userService.add(user). The @NamiClient field is a proxy that Nami builds for you, and it handles the round trip. This is the payoff of the declarative approach: the remote call reads exactly like a local one.
Moving past hardcoded URLs
A hardcoded url is fine for a first run, but it does not survive contact with real deployments. Nami supports naming a service instead of pinning its address, which is where registration and discovery come in.
Declare the client on the interface itself, and injection elsewhere inherits that declaration:
@NamiClient(name = "somplex-api", path = "/ComplexModelService/")
public interface IComplexModelService {
void save(ComplexModel model);
ComplexModel read(Integer modelId);
}
Enter fullscreen mode Exit fullscreen mode
Then at the injection point you write almost nothing:
@Mapping("user")
@Controller
public class UserController {
// inherits the @NamiClient info declared on the interface
@NamiClient
IComplexModelService complexModelService;
}
Enter fullscreen mode Exit fullscreen mode
For the address resolution itself you have two routes. With a distributed registry, you bring in a discovery plugin and point the app at the registry server:
server.port: 9001
solon.app:
group: "demo"
name: "demo-app"
solon.cloud.water:
server: "waterapi:9371"
Enter fullscreen mode Exit fullscreen mode
Or, if you want the same name-based code without standing up a registry, use local discovery config with solon-cloud:
solon.cloud.local:
discovery:
service:
somplex-api:
- "http://localhost:8080"
Enter fullscreen mode Exit fullscreen mode
The code that calls the service does not change between these two setups. You move from local config to a real registry by editing YAML, not by rewriting call sites. That is a nice property to have when a project graduates from a single box to a cluster.
The knobs you actually reach for
Two settings come up often enough to know by heart.
Timeout, in seconds, applies across HTTP, socket, and WebSocket channels. Set it on the field or on the interface:
@NamiClient(name = "userapi", path = "/rpc/v1/user", timeout = 60 * 5)
UserService userService;
Enter fullscreen mode Exit fullscreen mode
Heartbeat, also in seconds, applies to socket and WebSocket channels only:
@NamiClient(name = "userapi", path = "/rpc/v1/user", heartbeat = 30)
UserService userService;
Enter fullscreen mode Exit fullscreen mode
When you outgrow annotations, Nami.builder() gives you the manual path, useful when you are constructing a client outside the container:
UserService userService = Nami.builder()
.name("userapi")
.path("/rpc/v1/user")
.timeout(60 * 5)
.decoder(SnackDecoder.instance)
.encoder(SnackTypeEncoder.instance)
.create(UserService.class);
Enter fullscreen mode Exit fullscreen mode
And for cross-cutting settings such as a shared encoder or decoder, NamiConfiguration lets you configure clients globally so individual call sites stay clean:
@Configuration
public class Config {
@Bean
public NamiConfiguration initNami() {
return new NamiConfiguration() {
@Override
public void config(NamiClient client, NamiBuilder builder) {
builder.decoder(SnackDecoder.instance);
builder.encoder(SnackTypeEncoder.instance);
}
};
}
}
Enter fullscreen mode Exit fullscreen mode
After that, the call sites drop the headers hint entirely, because the encoder is already wired in.
Why this shape holds up
The thing I keep coming back to with Nami is that it draws a clean line between the contract and the wiring. The interface is a plain Java type, shared by both sides, with no framework leaking into it. The server publishes it with @Remoting. The client consumes it with @NamiClient. Everything about transport, discovery, timeouts, and serialization lives in annotations and config, not in your business methods.
That separation is what lets a call like userService.add(user) stay honest. It looks local because, from the perspective of the code that matters, it is. The network is an implementation detail you configure, not something you hand-code into every method. When you are stitching a system together out of several services, that is exactly the boundary you want.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.