gRPC Server with Kora¶
This guide introduces unary gRPC servers with Kora. It covers how a Protocol Buffers service contract generates Java stubs and messages, how a Kora gRPC implementation connects those generated types to application services, and how status errors, metadata, and Protobuf payloads differ from JSON-over-HTTP routes. You will also see how the gRPC server module joins the compile-time dependency graph alongside repository and service components.
If you want to check your progress along the way, use the finished working example: Kora Java gRPC Server App.
If you want to check your progress along the way, use the finished working example: Kora Kotlin gRPC Server App.
What You'll Build¶
You will build a unary gRPC server application with:
- a
user_service.protocontract that defines request and response messages - generated protobuf message classes and gRPC service base types
- a Kora gRPC handler that implements
CreateUser,GetUser,GetUsers,UpdateUser, andDeleteUser - an in-memory repository and service layer reused behind the gRPC transport
- status-based error handling for missing users
- server configuration and manual checks through
grpcurl
What You'll Need¶
- JDK 25 or later
- Gradle 9+ (the reference applications use Gradle Wrapper
9.5.1) - A text editor or IDE
- Optional:
grpcurlfor manual RPC checks
Kora artifacts are compiled for Java 25, so the JDK that compiles your code must be 25 or newer.
Prerequisites¶
Required: Complete HTTP Server Guide
This guide assumes you have completed Build an HTTP Server and are comfortable with Kora modules, @Component, and separating repository, service, and transport layers.
If you haven't completed the HTTP server guide yet, do that first, because this guide keeps the same application model and replaces only the HTTP/JSON transport with gRPC and Protocol Buffers.
Overview¶
The guide keeps the repository and service responsibilities from the HTTP server guide, then replaces the HTTP controller with a generated gRPC handler.
That replacement changes the transport layer, not the business model. In HTTP/JSON APIs, a controller usually owns routing details such as paths, methods, request bodies, response codes, and JSON
serialization. In gRPC, the public contract moves into a .proto file, and the framework-generated classes become the bridge between network calls and your application service.
Kora fits into that model by wiring the generated gRPC service handler into the application graph. You still write ordinary Java or Kotlin components, but the request and response types come from protobuf generation instead of handwritten DTOs. The practical flow is:
- define the RPC contract in protobuf
- generate Java classes and gRPC base types
- implement a Kora component that handles the generated service calls
- map protobuf messages to your existing service layer
- expose the gRPC server through Kora configuration
Two runtime details are worth knowing before you start, because they shape how handler code should be written:
- Kora builds the server on the gRPC OkHttp transport. You do not add a transport artifact yourself;
io.koraframework:grpc-serveralready bringsgrpc-okhttpandgrpc-stub. - Every client connection gets a dedicated single-threaded executor backed by a virtual thread. Blocking inside a handler is safe — the carrier thread is released — but it delays the other calls arriving on the same connection. That is why handlers in this guide are plain synchronous methods with no thread pools of their own.
What Is gRPC?¶
gRPC is a remote procedure call protocol and toolchain for building typed service-to-service APIs.
The main idea is different from a typical HTTP API. With HTTP + JSON, you usually design resources and routes:
POST /usersGET /users/{userId}PUT /users/{userId}DELETE /users/{userId}
The contract is spread across HTTP methods, paths, status codes, headers, JSON request bodies, JSON response bodies, and documentation such as OpenAPI. That model is flexible and very friendly for public APIs, browsers, manual debugging, and human-readable traffic.
With gRPC, you design a service interface instead:
service UserService {
rpc CreateUser(CreateUserRequest) returns (UserResponse) {}
rpc GetUser(GetUserRequest) returns (UserResponse) {}
}
The API looks more like calling methods on a remote service. The client does not assemble a URL path and parse arbitrary JSON by hand. It calls a generated method with a generated request type and receives a generated response type.
The core difference is where the contract lives.
In HTTP + JSON, the wire format is usually simple and text-based, but the strong contract often lives outside the code unless you add code generation from OpenAPI. In gRPC, the .proto file is the
contract first, and both sides compile generated code from that same contract.
That contract-first model gives gRPC three important properties:
- you describe your API in a
.protofile - code is generated from that contract
- clients and servers exchange compact binary messages over HTTP/2
The transport is also different. gRPC uses HTTP/2 as the underlying protocol, but it does not feel like a normal JSON REST API:
- messages are serialized with Protocol Buffers instead of JSON
- calls are usually made through generated stubs instead of hand-written URL requests
- errors are represented with gRPC status codes instead of ordinary HTTP response codes in application code
- streaming is part of the RPC model, not an add-on protocol
- HTTP/2 features such as multiplexing and long-lived streams are central to how calls are carried
So gRPC is not "HTTP without JSON" and not just "REST with another serializer". It is a different API style built from these pieces:
- Protocol Buffers define the schema and binary encoding.
- Service definitions describe RPC methods and message types.
- Generated code creates request/response classes, server base types, and client stubs.
- HTTP/2 carries the calls efficiently over the network.
- gRPC status codes and metadata carry errors and call-level context.
In practice, this gives you a very different developer experience from a handwritten REST controller:
- you design operations as RPC methods such as
CreateUserorGetUser - request and response messages are strongly typed
- the same contract is shared by both the server and the client
- generated code removes a lot of transport boilerplate
This makes gRPC especially useful for service-to-service communication inside distributed systems, where performance, type safety, and contract consistency matter more than human-readable JSON payloads.
HTTP + JSON is often a better default for public APIs, browser-facing APIs, and endpoints that humans need to inspect directly. gRPC is usually strongest for internal APIs where both sides are controlled by engineering teams, the schema is shared, and generated clients are acceptable or desirable.
What Are Protocol Buffers?¶
Protocol Buffers are the schema language and binary serialization format used by gRPC.
A .proto file defines:
- services
- RPC methods
- request messages
- response messages
For example, instead of writing an HTTP controller method by hand, you define a service contract such as:
From that contract, the protobuf compiler generates Java classes for:
CreateUserRequestUserResponseUserServiceGrpc
Kora then uses those generated types as the basis for your server implementation.
Why Build gRPC over HTTP?¶
The easiest way to understand a new transport is to keep the application model stable.
In the HTTP Server guide, we already introduced:
UserRepositoryInMemoryUserRepositoryUserService- user CRUD operations
In this guide we reuse the same learning model, but replace HTTP-specific pieces with gRPC-specific ones:
@HttpControllerbecomes a gRPC handler- JSON DTO exchange becomes protobuf message exchange
- HTTP status codes become gRPC
Statuserrors
That keeps the guide beginner-friendly while still showing real gRPC architecture.
Dependencies¶
We start by adding the gRPC server module and the protobuf Gradle plugin.
Versions of Kora modules come from the Kora BOM io.koraframework:kora-bom, so individual Kora artifacts are declared without a version:
Update build.gradle:
plugins {
id "application"
id "com.google.protobuf" version "0.10.0"
}
configurations {
koraBom
annotationProcessor.extendsFrom(koraBom)
implementation.extendsFrom(koraBom)
testCompileOnly.extendsFrom(koraBom)
testAnnotationProcessor.extendsFrom(koraBom)
}
dependencies {
koraBom platform("io.koraframework:kora-bom:$koraVersion")
compileOnly "javax.annotation:javax.annotation-api:1.3.2"
annotationProcessor "io.koraframework:annotation-processors"
implementation "io.koraframework:config-hocon"
implementation "io.koraframework:grpc-server"
implementation "io.koraframework:logging-logback"
implementation "io.grpc:grpc-protobuf:1.83.1"
implementation "io.grpc:grpc-services:1.83.1"
testCompileOnly "javax.annotation:javax.annotation-api:1.3.2"
testAnnotationProcessor "io.koraframework:annotation-processors"
testImplementation platform("org.junit:junit-bom:$junitVersion")
testImplementation "io.grpc:grpc-netty:1.83.1"
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.koraframework:test-junit5"
}
Update build.gradle.kts:
import com.google.protobuf.gradle.id
plugins {
id("org.jetbrains.kotlin.jvm")
id("com.google.devtools.ksp")
id("application")
id("com.google.protobuf") version "0.10.0"
}
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
compileOnly("javax.annotation:javax.annotation-api:1.3.2")
ksp("io.koraframework:symbol-processors:${property("koraVersion")}")
implementation("io.koraframework:config-hocon")
implementation("io.koraframework:grpc-server")
implementation("io.koraframework:logging-logback")
implementation("io.grpc:grpc-protobuf:1.83.1")
implementation("io.grpc:grpc-services:1.83.1")
testCompileOnly("javax.annotation:javax.annotation-api:1.3.2")
testImplementation(platform("org.junit:junit-bom:${property("junitVersion")}"))
testImplementation("io.grpc:grpc-netty:1.83.1")
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.koraframework:test-junit5")
}
Why these dependencies matter:
io.koraframework:grpc-serverintegrates a gRPC server into the Kora application graph and brings the gRPC runtime with itio.grpc:grpc-protobufgives runtime support for protobuf message serializationio.grpc:grpc-servicesprovides the standard gRPC services, including the reflection service used by the advanced guidejavax.annotation:javax.annotation-apiis needed only at compile time, because generated stubs reference@javax.annotation.Generated- the protobuf Gradle plugin generates the Java classes from
.protofiles
Keep every io.grpc artifact on one version
The gRPC runtime shipped with io.koraframework:grpc-server is 1.83.1. Every other io.grpc artifact you declare — grpc-protobuf, grpc-services, and anything in test scope such as
grpc-netty — must use exactly that version. A pinned older version compiles fine and fails only at runtime with
AbstractMethodError: ... does not define or inherit an implementation of the resolved method 'buildClientTransportServers(List, MetricRecorder)'.
Code Generation¶
Now we teach Gradle how to turn .proto files into Java code.
Add to build.gradle:
protobuf {
protoc { artifact = "com.google.protobuf:protoc:4.35.1" }
plugins {
grpc { artifact = "io.grpc:protoc-gen-grpc-java:1.83.1" }
}
generateProtoTasks {
all()*.plugins { grpc {} }
}
}
sourceSets {
main {
java {
srcDirs "build/generated/source/proto/main/grpc"
srcDirs "build/generated/source/proto/main/java"
}
}
}
Add to build.gradle.kts:
protobuf {
protoc { artifact = "com.google.protobuf:protoc:4.35.1" }
plugins {
id("grpc") { artifact = "io.grpc:protoc-gen-grpc-java:1.83.1" }
}
generateProtoTasks {
all().forEach { task ->
task.plugins { id("grpc") }
}
}
}
sourceSets {
main {
java {
srcDirs("build/generated/source/proto/main/grpc", "build/generated/source/proto/main/java")
}
}
}
This generates two groups of code:
- protobuf message classes such as
CreateUserRequest - gRPC service classes such as
UserServiceGrpc
That generated code becomes part of your normal application sources. The plugin emits Java classes even in a Kotlin project, which is why the generated directories are registered in the java
source set in both variants.
Modules¶
Next we enable the gRPC server in the Kora app itself.
package io.koraframework.guide.grpcserver;
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.grpc.server.GrpcServerModule;
import io.koraframework.logging.logback.LogbackModule;
@KoraApp
public interface Application extends
HoconConfigModule,
LogbackModule,
GrpcServerModule { // <----- Connected module
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
package io.koraframework.guide.grpcserver
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.grpc.server.GrpcServerModule
import io.koraframework.logging.logback.LogbackModule
@KoraApp
interface Application :
HoconConfigModule,
LogbackModule,
GrpcServerModule // <----- Connected module
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
At this point Kora knows that this application should start a gRPC server. Every BindableService found in the application graph — that is, every @Component extending a generated ...ImplBase —
is registered on that server automatically. There is no @GrpcService annotation and no manual addService call.
Protobuf API¶
Now we define the transport contract itself.
Create:
Protobuf contract
syntax = "proto3";
package io.koraframework.guide.grpcserver;
option java_multiple_files = true;
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
service UserService {
rpc CreateUser(CreateUserRequest) returns (UserResponse) {}
rpc GetUser(GetUserRequest) returns (UserResponse) {}
rpc GetUsers(GetUsersRequest) returns (GetUsersResponse) {}
rpc UpdateUser(UpdateUserRequest) returns (UserResponse) {}
rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty) {}
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message GetUserRequest {
string user_id = 1;
}
message GetUsersRequest {
int32 page = 1;
int32 size = 2;
string sort = 3;
}
message GetUsersResponse {
repeated UserResponse users = 1;
}
message UpdateUserRequest {
string user_id = 1;
string name = 2;
string email = 3;
}
message DeleteUserRequest {
string user_id = 1;
}
message UserResponse {
string id = 1;
string name = 2;
string email = 3;
google.protobuf.Timestamp created_at = 4;
}
This contract intentionally mirrors the familiar CRUD API from the HTTP guide:
- create one user
- get one user
- list users
- update one user
- delete one user
That is why this is a good first gRPC example: the business meaning is already familiar, so we can focus on the transport.
Service Layer¶
We still want the same application architecture as in the HTTP guide:
- repository stores users
- service owns business logic
- transport layer only adapts requests and responses
So we keep:
UserRepositoryInMemoryUserRepositoryUserServiceUserNotFoundException
The important point is not to move business logic into the gRPC handler. The handler should stay focused on:
- reading protobuf requests
- calling the service layer
- converting service results into protobuf responses
gRPC Handler¶
This is the point where gRPC replaces the HTTP controller.
package io.koraframework.guide.grpcserver.grpc;
import com.google.protobuf.Empty;
import com.google.protobuf.Timestamp;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import java.time.ZoneOffset;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.grpcserver.CreateUserRequest;
import io.koraframework.guide.grpcserver.DeleteUserRequest;
import io.koraframework.guide.grpcserver.GetUserRequest;
import io.koraframework.guide.grpcserver.GetUsersRequest;
import io.koraframework.guide.grpcserver.GetUsersResponse;
import io.koraframework.guide.grpcserver.UpdateUserRequest;
import io.koraframework.guide.grpcserver.UserResponse;
import io.koraframework.guide.grpcserver.UserServiceGrpc;
import io.koraframework.guide.grpcserver.dto.UserRequest;
import io.koraframework.guide.grpcserver.service.UserNotFoundException;
import io.koraframework.guide.grpcserver.service.UserService;
@Component
public final class UserServiceGrpcHandler extends UserServiceGrpc.UserServiceImplBase {
private static final Logger logger = LoggerFactory.getLogger(UserServiceGrpcHandler.class);
private final UserService userService;
public UserServiceGrpcHandler(UserService userService) {
this.userService = userService;
}
@Override
public void createUser(CreateUserRequest request, StreamObserver<UserResponse> responseObserver) {
try {
logger.info("Creating user: name={}, email={}", request.getName(), request.getEmail());
var user = userService.createUser(new UserRequest(request.getName(), request.getEmail()));
responseObserver.onNext(toGrpcUser(user));
responseObserver.onCompleted();
} catch (Exception e) {
responseObserver.onError(Status.INTERNAL
.withDescription("Failed to create user")
.withCause(e)
.asRuntimeException());
}
}
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
try {
var user = userService.getUser(request.getUserId())
.orElseThrow(() -> Status.NOT_FOUND
.withDescription("User not found: " + request.getUserId())
.asRuntimeException());
responseObserver.onNext(toGrpcUser(user));
responseObserver.onCompleted();
} catch (RuntimeException e) {
responseObserver.onError(e);
}
}
@Override
public void getUsers(GetUsersRequest request, StreamObserver<GetUsersResponse> responseObserver) {
try {
int page = request.getPage();
int size = request.getSize() == 0 ? 10 : request.getSize();
String sort = request.getSort().isBlank() ? "name" : request.getSort();
var response = GetUsersResponse.newBuilder()
.addAllUsers(userService.getUsers(page, size, sort).stream().map(this::toGrpcUser).toList())
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
responseObserver.onError(Status.INTERNAL.withDescription("Failed to get users").withCause(e).asRuntimeException());
}
}
@Override
public void updateUser(UpdateUserRequest request, StreamObserver<UserResponse> responseObserver) {
try {
var updated = userService.updateUser(request.getUserId(), new UserRequest(request.getName(), request.getEmail()));
responseObserver.onNext(toGrpcUser(updated));
responseObserver.onCompleted();
} catch (UserNotFoundException e) {
responseObserver.onError(Status.NOT_FOUND.withDescription(e.getMessage()).asRuntimeException());
}
}
@Override
public void deleteUser(DeleteUserRequest request, StreamObserver<Empty> responseObserver) {
try {
userService.deleteUser(request.getUserId());
responseObserver.onNext(Empty.getDefaultInstance());
responseObserver.onCompleted();
} catch (UserNotFoundException e) {
responseObserver.onError(Status.NOT_FOUND.withDescription(e.getMessage()).asRuntimeException());
}
}
private UserResponse toGrpcUser(io.koraframework.guide.grpcserver.dto.UserResponse user) {
return UserResponse.newBuilder()
.setId(user.id())
.setName(user.name())
.setEmail(user.email())
.setCreatedAt(Timestamp.newBuilder()
.setSeconds(user.createdAt().toEpochSecond(ZoneOffset.UTC))
.setNanos(user.createdAt().getNano())
.build())
.build();
}
}
package io.koraframework.guide.grpcserver.grpc
import com.google.protobuf.Empty
import com.google.protobuf.Timestamp
import io.grpc.Status
import io.grpc.stub.StreamObserver
import org.slf4j.LoggerFactory
import io.koraframework.common.annotation.Component
import io.koraframework.guide.grpcserver.*
import io.koraframework.guide.grpcserver.dto.UserRequest
import io.koraframework.guide.grpcserver.dto.UserResponse
import io.koraframework.guide.grpcserver.service.UserNotFoundException
import io.koraframework.guide.grpcserver.service.UserService
import java.time.ZoneOffset
@Component
class UserServiceGrpcHandler(
private val userService: UserService
) : UserServiceGrpc.UserServiceImplBase() {
private val logger = LoggerFactory.getLogger(UserServiceGrpcHandler::class.java)
override fun createUser(
request: CreateUserRequest,
responseObserver: StreamObserver<io.koraframework.guide.grpcserver.UserResponse>
) {
try {
logger.info("Creating user: name={}, email={}", request.name, request.email)
val user = userService.createUser(UserRequest(request.name, request.email))
responseObserver.onNext(toGrpcUser(user))
responseObserver.onCompleted()
} catch (e: Exception) {
logger.error("Failed to create user", e)
responseObserver.onError(
Status.INTERNAL.withDescription("Failed to create user").withCause(e).asRuntimeException()
)
}
}
override fun getUser(
request: GetUserRequest,
responseObserver: StreamObserver<io.koraframework.guide.grpcserver.UserResponse>
) {
try {
logger.info("Getting user: id={}", request.userId)
val user = userService.getUser(request.userId)
?: throw Status.NOT_FOUND.withDescription("User not found: ${request.userId}").asRuntimeException()
responseObserver.onNext(toGrpcUser(user))
responseObserver.onCompleted()
} catch (e: RuntimeException) {
logger.error("Failed to get user", e)
responseObserver.onError(e)
}
}
override fun getUsers(request: GetUsersRequest, responseObserver: StreamObserver<GetUsersResponse>) {
try {
val page = request.page
val size = if (request.size == 0) 10 else request.size
val sort = request.sort.ifBlank { "name" }
val response = GetUsersResponse.newBuilder()
.addAllUsers(userService.getUsers(page, size, sort).map(::toGrpcUser))
.build()
responseObserver.onNext(response)
responseObserver.onCompleted()
} catch (e: Exception) {
logger.error("Failed to get users", e)
responseObserver.onError(
Status.INTERNAL.withDescription("Failed to get users").withCause(e).asRuntimeException()
)
}
}
override fun updateUser(
request: UpdateUserRequest,
responseObserver: StreamObserver<io.koraframework.guide.grpcserver.UserResponse>
) {
try {
val updated = userService.updateUser(request.userId, UserRequest(request.name, request.email))
responseObserver.onNext(toGrpcUser(updated))
responseObserver.onCompleted()
} catch (e: UserNotFoundException) {
logger.error("Failed to update user", e)
responseObserver.onError(Status.NOT_FOUND.withDescription(e.message).asRuntimeException())
}
}
override fun deleteUser(request: DeleteUserRequest, responseObserver: StreamObserver<Empty>) {
try {
userService.deleteUser(request.userId)
responseObserver.onNext(Empty.getDefaultInstance())
responseObserver.onCompleted()
} catch (e: UserNotFoundException) {
logger.error("Failed to delete user", e)
responseObserver.onError(Status.NOT_FOUND.withDescription(e.message).asRuntimeException())
}
}
private fun toGrpcUser(user: UserResponse): io.koraframework.guide.grpcserver.UserResponse {
return io.koraframework.guide.grpcserver.UserResponse.newBuilder()
.setId(user.id)
.setName(user.name)
.setEmail(user.email)
.setCreatedAt(
Timestamp.newBuilder()
.setSeconds(user.createdAt.toEpochSecond(ZoneOffset.UTC))
.setNanos(user.createdAt.nano)
.build()
)
.build()
}
}
There are three especially important ideas here:
- the handler extends the generated
UserServiceGrpc.UserServiceImplBaseand is registered in the graph with plain@Component - transport errors are expressed through gRPC
Status, not HTTP exceptions - every method is a synchronous, blocking method — Kora runs it on the connection's virtual thread, so there is no reactive wrapper and no
CompletionStageto return
That second point matters a lot. This is not an HTTP application anymore, so the transport language must be gRPC-native. An exception that escapes the handler without being reported through the
observer is closed by gRPC as UNKNOWN, which tells the caller nothing useful — always map failures to an explicit Status.
Configuration¶
The full model for gRPC handlers, server configuration, and reflection is covered in Handlers and Reflection.
Add a small application.conf:
For the full configuration reference, see gRPC Server and Logging SLF4J.
grpcServer {
port = 8090 //(1)!
telemetry.logging.enabled = true //(2)!
}
logging {
levels {
"ROOT": "WARN" //(3)!
"io.koraframework": "INFO" //(4)!
"io.koraframework.guide.grpcserver": "INFO" //(5)!
}
}
- gRPC server port (default:
8090). - Enables gRPC call logging for this server (default:
false). - Log level for
ROOT. - Log level for
io.koraframework. - Log level for
io.koraframework.guide.grpcserver.
grpcServer:
port: 8090 #(1)!
telemetry:
logging:
enabled: true #(2)!
logging:
levels:
ROOT: "WARN" #(3)!
"io.koraframework": "INFO" #(4)!
"io.koraframework.guide.grpcserver": "INFO" #(5)!
- gRPC server port (default:
8090). - Enables gRPC call logging for this server (default:
false). - Log level for
ROOT. - Log level for
io.koraframework. - Log level for
io.koraframework.guide.grpcserver.
This gives us:
- gRPC server on port
8090 - Kora gRPC request logging
- readable logs for the demo module
Everything else has a working default: message size is capped at 4MiB, graceful shutdown waits 30s, and gRPC reflection stays off (reflectionEnabled = false). The
advanced guide turns reflection on.
Run Application¶
Build the generated sources and compile the app:
Run it:
Then call it with grpcurl. Because reflection is disabled in this guide, point grpcurl at the contract explicitly:
grpcurl -plaintext -import-path src/main/proto -proto user_service.proto \
-d '{"name":"Alice","email":"alice@example.com"}' \
localhost:8090 io.koraframework.guide.grpcserver.UserService/CreateUser
grpcurl -plaintext -import-path src/main/proto -proto user_service.proto \
-d '{"page":0,"size":10,"sort":"name"}' \
localhost:8090 io.koraframework.guide.grpcserver.UserService/GetUsers
Testing¶
The companion app includes JUnit tests that use a real gRPC channel against the application.
@KoraAppTest starts the whole graph, so the gRPC server binds a real port and the test talks to it through an ordinary ManagedChannel. The client side of such a test needs a gRPC transport on the
test classpath, which is why io.grpc:grpc-netty:1.83.1 is declared in test scope — pinned to the same version as the gRPC runtime shipped by io.koraframework:grpc-server.
Run them with:
The tests verify the unary CRUD flow separately, not as one giant scenario. That keeps failures easier to understand.
Best Practices¶
- Keep protobuf contracts focused on transport concerns, not domain implementation details.
- Keep business logic in
UserService, not in the gRPC handler. - Map missing resources to
Status.NOT_FOUND, not to generic internal errors. - Always finish a call through the response observer:
onNext+onCompleted, oronErrorwith an explicitStatus. - Reuse the same application architecture across transports whenever possible.
- Treat generated protobuf code as transport types, not as domain models.
- Keep every
io.grpcartifact on the version that ships withio.koraframework:grpc-server. - Annotate handwritten DTOs with
@Jsononly when they cross an HTTP/JSON boundary; generated protobuf messages do not need JSON annotations.
Summary¶
In this guide you built a unary gRPC server that mirrors the CRUD application from the HTTP server guide.
The key idea was simple:
- keep repository and service layers familiar
- define the transport in
.proto - implement a generated gRPC handler on top of the same business logic
Key Concepts¶
- what gRPC is and why it is useful for service-to-service communication
- how Protocol Buffers define a shared RPC contract
- how Kora starts a gRPC server and picks up every
BindableServicefrom the graph - how unary RPC methods map to familiar CRUD operations
- how gRPC
Statuserrors replace HTTP-style transport errors - why handlers stay synchronous on Kora's virtual-thread execution model
Troubleshooting¶
Generated classes are missing:
Run ./gradlew clean classes after changing the .proto file and verify the protobuf Gradle plugin is configured.
Server does not start:
Check that the gRPC port in application.conf is free and that GrpcServerModule is included in the application graph. A busy port fails the graph with
gRPC server failed to start on port '8090': port is already in use.
RPC returns UNIMPLEMENTED:
Verify that the generated service name and method names match the .proto contract used by the client.
Tests fail with AbstractMethodError mentioning buildClientTransportServers:
A gRPC artifact in test scope is pinned to a different version than the runtime that ships with io.koraframework:grpc-server. Align every io.grpc dependency on 1.83.1.
grpcurl reports that the server does not support reflection:
Reflection is off by default. Either pass -import-path/-proto as shown above, or enable it as described in Advanced gRPC Server.
What's Next?¶
- HTTP Client if you have not completed it yet; the gRPC client guide assumes that client-side application structure.
- gRPC Client after HTTP Client, to consume this unary service through generated stubs.
- HTTP Server Advanced before Advanced gRPC Server, because the advanced gRPC guide reuses advanced server concepts.
- Observability to monitor gRPC services alongside HTTP services.
Help¶
If something does not work:
- compare with Kora Java gRPC Server App and Kora Kotlin gRPC Server App
- check the gRPC Server documentation
- check the gRPC Client documentation when client/server contracts disagree
- make sure you regenerated code after changing the
.protofile