First steps with Kotlin RPC
Kotlin RPC (Remote Procedure Call) is a new and exciting addition to the Kotlin ecosystem, which builds on well-established foundations and runs on the kotlinx.rpc
library.
The kotlinx.rpc
library enables you to make procedure calls over network boundaries using nothing but regular Kotlin language constructs. As such, it provides an alternative to both REST and Google RPC (gPRC).
In this article, we will introduce the core concepts of Kotlin RPC and build a simple application. You can then go and evaluate the library in your own projects.
Prerequisites
This tutorial assumes you have a fundamental understanding of Kotlin programming. If you are new to Kotlin, consider reviewing some introductory materials.
For the best experience, we recommend using IntelliJ IDEA Ultimate as your integrated development environment (IDE) as it offers comprehensive support and tools that will enhance your productivity.
What is RPC?
Local vs. remote procedure calls
Anyone with programming experience will be familiar with the concept of a procedure call. This is a fundamental concept in any programming language. Technically, these are local procedure calls, since they always take place within the same program.
A remote procedure call is when the function call and parameters are in some way transferred over a network, so that the implementation can occur within a separate VM/executable. The return value travels the opposite path back to the machine where the invocation was made.
It is easiest to think of the machine where the invocation occurred as the client and the machine where the implementation resides as the server. This does not necessarily have to be the case, however. RPC calls could occur in both directions, as part of a peer architecture. But to keep things simple, let’s assume a client/server deployment.
RPC framework fundamentals
Certain fundamentals must be provided by any RPC framework. They are inevitable when implementing remote procedure calls within a conventional IT infrastructure. The terminology can vary, and responsibilities can be divided in different ways, but every RPC framework must provide:
A way to declare the procedures that will be invoked remotely. In object-oriented programming, an interface is the logical choice. This could be the interface construct provided by the current language or some kind of language-neutral standard, such as the Web IDL used by the W3C
A means to specify the types used for parameters and return values. Once again, you could use a language-neutral standard. However, it may be simpler to annotate standard data type declarations in the current language.
Helper classes, known as client stubs, that will be used to convert the procedure invocation into a format that can be sent over the network and to unpack the resulting return value. These stubs can be created either during the compiling process or dynamically at runtime.
An underlying RPC Runtime that manages the helper classes and supervises the lifecycle of a remote procedure call. On the server side, this runtime will need to be embedded in some kind of server, so that it is available to process requests on an ongoing basis.
Protocols need to be chosen (or defined) to represent the procedure being called, serialize the data being sent, and transform the information over the network. In the past, some technologies have defined new protocols from scratch (IIOP in CORBA), while others have focused on reuse (HTTP POST in SOAP).
Marshaling vs. serialization
In RPC frameworks, we speak of marshaling and unmarshaling. This is the process of packing and unpacking information to be sent over the network. It can be thought of as a superset of serialization. In marshaling, we are serializing objects, but we also need to package information about the procedure being called and the context in which that call was made.
Having introduced the core concepts of RPC, let’s see how they apply in kotlinx.rpc
by building a sample application.
Hello, kotlinx.rpc
Let’s create an application for ordering pizza over the network. In order to keep the code as simple as possible, we’ll use a console-based client.
Create the project
First, you will create a project that will include both the client and server implementations.
In more complex applications, it's best practice to use separate modules for the client and server. However, for simplicity in this tutorial, we will use a single module for both.
Launch intelliJ IDEA.
On the Welcome screen, click New Project.
Otherwise, from the main menu, select
.In the Name field, enter KotlinRpcPizzaApp as the name of your project.
Leave the rest of the default settings and click Create.
Normally, you would immediately configure the project build file. However, that’s an implementation detail that won't enhance your understanding of the technology, so you'll get back to that step at the end.
Add the shared types
The heart of any RPC project is the interface that defines the procedures to be called remotely, along with the types used in the definition of those procedures.
In a multi-module project, these types will need to be shared. However, in this example, this step won't be necessary.
Implement the client
Navigate to src/main/kotlin and create a new Client.kt file.
Open Client.kt and add the following implementation:
package com.example import com.example.model.Pizza import com.example.model.PizzaShop import io.ktor.client.* import io.ktor.http.* import kotlinx.coroutines.runBlocking import kotlinx.rpc.client.withService import kotlinx.rpc.serialization.json import kotlinx.rpc.transport.ktor.client.installRPC import kotlinx.rpc.transport.ktor.client.rpc import kotlinx.rpc.transport.ktor.client.rpcConfig fun main() = runBlocking { val ktorClient = HttpClient { installRPC { waitForServices = true } } val client: KtorRPCClient = ktorClient.rpc { url { host = "localhost" port = 8080 encodedPath = "pizza" } rpcConfig { serialization { json() } } } val pizzaShop: PizzaShop = client.withService<PizzaShop>() val receipt = pizzaShop.orderPizza(Pizza("Pepperoni")) println("Your pizza cost ${receipt.amount}") ktorClient.close() }
You only need 25 lines to prepare for and then execute an RPC call. Obviously, there is a lot going on, so let’s break the code down into sections.
The kotlinx.rpc
library uses the Ktor client to host its runtime on the client side. The runtime is not coupled to Ktor, and other choices are possible, but this promotes reuse and makes it easy to integrate kotlinx.rpc
into existing KMP applications.
Both the Ktor client and Kotlin RPC are built around coroutines, so you use runBlocking
to create the initial coroutine, and execute the rest of the client within it:
Next, you create an instance of the Ktor client in the standard way. kotlinx.rpc
uses the WebSockets plugin under the hood to transfer information. You only need to ensure that it is loaded by using the installRPC()
function:
Having created this Ktor client, you then create a KtorRPCClient
object for invoking remote procedures. You will need to configure the location of the server and the mechanism being used to transfer information:
At this point, the standard setup has been completed, and you are ready to use the functionality specific to the problem domain. You can use the client to create a client proxy object that implements the methods of the PizzaShop
interface:
You can then make the remote procedure call and use the result:
Note that a tremendous amount of work is being done for you at this point. The details of the call and all parameters must be converted into a message, sent over the network, and then the return value received and decoded. The fact that this happens transparently is the payoff for the initial setup.
Finally, we need to shut down the client as usual:
Implement the server
The implementation on the server side breaks down into two parts. Firstly, you need to create an implementation of our interface, and secondly, you need to host it within a server.
Navigate to src/main/kotlin and create a new Server.kt file.
Open Server.kt and add the following interface:
package com.example import Pizza import PizzaShop import Receipt import io.ktor.server.application.* class PizzaShopImpl( override val coroutineContext: CoroutineContext ) : PizzaShop { override suspend fun orderPizza(pizza: Pizza): Receipt { return Receipt(7.89) } }Obviously, this is not a real-world implementation, but it is enough to get our demo up and running. Note that we are required to accept a
CoroutineContext
in the constructor because our interface extendsRPC
, which extendsCoroutineScope
, which declares acoroutineContext
property.The second part of the implementation builds on Ktor.
Add the following code into the same file:
fun main() { embeddedServer(Netty, port = 8080) { module() println("Server running") }.start(wait = true) } fun Application.module() { install(RPC) routing { rpc("/pizza") { rpcConfig { serialization { json() } } registerService<PizzaShop> { ctx -> PizzaShopImpl(ctx) } } } }Here's the breakdown:
First, you create an instance of Ktor/Netty, with the specified extension function used for configuration:
embeddedServer(Netty, port = 8080) { module() println("Server running") }.start(wait = true)Then, you declare a setup function that extends the Ktor Application type. This installs the
kotlinx.rpc
plugin and declares one or more routes:fun Application.module() { install(RPC) routing { } }Inside the routing section, you use
kotlinx.rpc
extensions to the Ktor Routing DSL to declare an endpoint. As with on the client, you specify the URL and configure serialization. But in this case, our implementation will be listening at that URL for incoming requests:rpc("/pizza") { rpcConfig { serialization { json() } } registerService<PizzaShop> { ctx -> PizzaShopImpl(ctx) } }Note that you use
registerService
to provide the implementation of your interface to the RPC runtime. You might want there to be more than a single instance, but that’s a topic for a follow-up article.
Add dependencies
You now have all the code necessary to run the application, but at the moment, it would not even compile, never mind execute. At present, there is no wizard to create a kotlinx.rpc
project for us, so you need to configure the build file manually. Fortunately, this is not too complex.
In the build.gradle.kts file, add the following plugins:
plugins { kotlin("jvm") version "1.9.24" kotlin("plugin.serialization") version "1.9.24" id("io.ktor.plugin") version "2.3.12" id("com.google.devtools.ksp") version "1.9.24-1.0.20" id("org.jetbrains.kotlinx.rpc.plugin") version "0.2.1" }The reason for the Kotlin plugin is obvious. To explain the others:
The
kotlinx.serialization
plugin is required to generate the helper types for converting Kotlin objects into JSON. Remember thatkotlinx.serialization
makes no use of reflection.The Ktor plugin is used to build fat JARs that bundle the app with all its dependencies.
KSP is the Kotlin Symbol Processing API used to develop the
kotlinx.rpc
plugin.The RPC plugin is needed to create the stubs for the client-side.
Add the following dependencies:
dependencies { implementation("io.ktor:ktor-client-cio-jvm") implementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-client") implementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-ktor-client") implementation("io.ktor:ktor-server-netty-jvm:2.3.12") implementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-server") implementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-ktor-server") implementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-serialization-json") implementation("ch.qos.logback:logback-classic:1.5.6") testImplementation(kotlin("test")) }This adds the Ktor client and server, the client and server parts of the
kotlinx.rpc
runtime, and the library for integratingkotlinx.rpc
andkotlinx-serialization
.With this, you can now run the projects and start making RPC calls.
Run the application
To run the demo, follow the steps below:
Navigate to the Server.kt file.
In IntelliJ IDEA, click on the run button () next to the
main()
function to start the application.You should see the output in the Run tool panel:
Navigate to the Client.kt file and run the application. You should see the following output in the console:
Your pizza cost 7.89 Process finished with exit code 0
Extend the example
Finally, let's enhance the complexity of our example application to establish a solid foundation for future development.
In the PizzaShop.kt file, extend the
orderPizza
method by including the client’s ID, and add aviewOrders
method that returns all the pending orders for a specified client:package com.example.model import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable import kotlinx.rpc.RPC interface PizzaShop : RPC { suspend fun orderPizza(clientID: String, pizza: Pizza): Receipt suspend fun viewOrders(clientID: String): Flow<Pizza> }You can take advantage of the coroutines library by returning a
Flow
rather than aList
orSet
. This will allow you to steam the information to the client one pizza at a time.Navigate to the Server.kt file and implement this functionality by storing the current orders in a map of lists:
class PizzaShopImpl( override val coroutineContext: CoroutineContext ) : PizzaShop { private val openOrders = mutableMapOf<String, MutableList<Pizza>>() override suspend fun orderPizza(clientID: String, pizza: Pizza): Receipt { if(openOrders.containsKey(clientID)) { openOrders[clientID]?.add(pizza) } else { openOrders[clientID] = mutableListOf(pizza) } return Receipt(3.45) } override suspend fun viewOrders(clientID: String): Flow<Pizza> { val orders = openOrders[clientID] if (orders != null) { return flow { for (order in orders) { emit(order) delay(1000) } } } return flow {} } }Note that a new instance of
PizzaShopImpl
is created for each client service instance. This avoids conflicts between clients by isolating their state. However, it does not address thread safety within a single server's instance, particularly if the same instance is accessed concurrently by multiple coroutines.In the Client.kt file, submit multiple orders using two different client IDs:
val pizzaShop: PizzaShop = client.withService<PizzaShop>() pizzaShop.orderPizza("AB12", Pizza("Pepperoni")) pizzaShop.orderPizza("AB12", Pizza("Hawaiian")) pizzaShop.orderPizza("AB12", Pizza("Calzone")) pizzaShop.orderPizza("CD34", Pizza("Margherita")) pizzaShop.orderPizza("CD34", Pizza("Sicilian")) pizzaShop.orderPizza("CD34", Pizza("California"))Then you iterate over the results, using the
Coroutines
library andstreamScoped
method:streamScoped { pizzaShop.viewOrders("AB12").collect { println("AB12 ordered ${it.name}") } pizzaShop.viewOrders("CD34").collect { println("CD34 ordered ${it.name}") } }Run the server and the client. When you run the client, you will see the results being displayed incrementally:
Having created a working example, let’s now dig deeper into how everything works. In particular, let’s compare and contrast Kotlin RPC with the two main alternatives – REST and gRPC.
RPC vs. REST
The idea of RPC is considerably older than REST, dating back at least to 1981. When compared to REST, the RPC-based approach does not constrain you to a uniform interface (such as the HTTP request types), is much simpler to work with in code, and can be more performant thanks to binary messaging.
There are, however, three major advantages to REST:
It can be used directly by JavaScript clients in the browser and, hence, as part of single-page applications. Because RPC frameworks rely on generated stubs and binary messaging, they do not fit in well with the JavaScript ecosystem.
REST makes it obvious when a feature involves networking. This helps avoid the distributed objects antipattern identified by Martin Fowler. This occurs when a team splits its OO design into two or more pieces without considering the performance and reliability implications of making local procedure calls remote.
REST APIs are built on a series of conventions that make them relatively easy to create, document, monitor, debug, and test. There is a huge ecosystem of tools to support this.
These trade-offs mean that Kotlin RPC is best used in two scenarios. Firstly, in KMP clients using Compose Multiplatform, and secondly, amongst collaborating microservices in the cloud. Future developments in Kotlin/Wasm may make kotlinx.rpc
more applicable to browser-based applications.
Kotlin RPC vs. Google RPC
Google RPC is the dominant RPC technology in the software industry at present. A standard called Protocol Buffers (protobuf) is used to define data structures and message payloads using a language-neutral Interface Definition Language (IDL). These IDL definitions can be converted into a wide variety of programming languages and are serialized using a compact and efficient binary format. Microservice frameworks like Quarkus and Micronaut already have support for gRPC.
It would be difficult for Kotlin RPC to compete with gRPC, and there would be no benefit in this for the Kotlin community. Thankfully, there are no plans to do this. Instead, the intention is for kotlinx.rpc to be compatible and interoperable with gRPC. It will be possible for kotlinx.rpc services to use gRPC as their networking protocol and for kotlinx.rpc clients to call gRPC services. kotlinx.rpc
will use its own kRPC protocol as the default option (as is the case in our current example), but there will be nothing to prevent you from choosing gRPC instead.
Next steps
Kotlin RPC extends the Kotlin ecosystem in a new direction, offering an alternative to REST and GraphQL for creating and consuming services. It is built on proven libraries and frameworks, such as Ktor, Coroutines, and kotlinx-serialization
. For teams seeking to make use of Kotlin Multiplatform and Compose Multiplatform, it will provide a simple and efficient option for distributed messaging.
If this introduction has piqued your interest, make sure to check out the official kotlinx.rpc
documentation and examples.
The kotlinx.rpc
library is in its early stages, so we encourage you to explore it and share your feedback. Bugs and feature requests can be found on YouTrack while general discussions take place on Slack (request access).