Kora облачно ориентированный серверный фреймворк написанный на Java для написания Java / Kotlin приложений с упором на производительность, эффективность, прозрачность сделанный выходцами из Т-Банк / Тинькофф

Kora is a cloud-oriented server-side Java framework for writing Java / Kotlin applications with a focus on performance, efficiency and transparency

Skip to content
V1 V2

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.proto contract 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, and DeleteUser
  • 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: grpcurl for 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:

  1. define the RPC contract in protobuf
  2. generate Java classes and gRPC base types
  3. implement a Kora component that handles the generated service calls
  4. map protobuf messages to your existing service layer
  5. 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-server already brings grpc-okhttp and grpc-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 /users
  • GET /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 .proto file
  • 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 CreateUser or GetUser
  • 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:

service UserService {
  rpc CreateUser(CreateUserRequest) returns (UserResponse) {}
}

From that contract, the protobuf compiler generates Java classes for:

  • CreateUserRequest
  • UserResponse
  • UserServiceGrpc

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:

  • UserRepository
  • InMemoryUserRepository
  • UserService
  • user CRUD operations

In this guide we reuse the same learning model, but replace HTTP-specific pieces with gRPC-specific ones:

  • @HttpController becomes a gRPC handler
  • JSON DTO exchange becomes protobuf message exchange
  • HTTP status codes become gRPC Status errors

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:

gradle.properties
koraVersion=2.0.0.RC1
junitVersion=6.1.3

Update build.gradle:

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:

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-server integrates a gRPC server into the Kora application graph and brings the gRPC runtime with it
  • io.grpc:grpc-protobuf gives runtime support for protobuf message serialization
  • io.grpc:grpc-services provides the standard gRPC services, including the reflection service used by the advanced guide
  • javax.annotation:javax.annotation-api is needed only at compile time, because generated stubs reference @javax.annotation.Generated
  • the protobuf Gradle plugin generates the Java classes from .proto files

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:

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:

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.

src/main/java/io/koraframework/guide/grpcserver/Application.java
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);
    }
}
src/main/kotlin/io/koraframework/guide/grpcserver/Application.kt
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
src/main/proto/user_service.proto
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:

  • UserRepository
  • InMemoryUserRepository
  • UserService
  • UserNotFoundException

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.

src/main/java/io/koraframework/guide/grpcserver/grpc/UserServiceGrpcHandler.java
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();
    }
}
src/main/kotlin/io/koraframework/guide/grpcserver/grpc/UserServiceGrpcHandler.kt
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.UserServiceImplBase and 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 CompletionStage to 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.

src/main/resources/application.conf
grpcServer {
  port = 8090 //(1)!
  telemetry.logging.enabled = true //(2)!
}

logging {
  levels {
    "ROOT": "WARN" //(3)!
    "io.koraframework": "INFO" //(4)!
    "io.koraframework.guide.grpcserver": "INFO" //(5)!
  }
}
  1. gRPC server port (default: 8090).
  2. Enables gRPC call logging for this server (default: false).
  3. Log level for ROOT.
  4. Log level for io.koraframework.
  5. Log level for io.koraframework.guide.grpcserver.
src/main/resources/application.yaml
grpcServer:
  port: 8090 #(1)!
  telemetry:
    logging:
      enabled: true #(2)!
logging:
  levels:
    ROOT: "WARN" #(3)!
    "io.koraframework": "INFO" #(4)!
    "io.koraframework.guide.grpcserver": "INFO" #(5)!
  1. gRPC server port (default: 8090).
  2. Enables gRPC call logging for this server (default: false).
  3. Log level for ROOT.
  4. Log level for io.koraframework.
  5. 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:

./gradlew clean classes

Run it:

./gradlew run

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:

./gradlew test

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, or onError with an explicit Status.
  • Reuse the same application architecture across transports whenever possible.
  • Treat generated protobuf code as transport types, not as domain models.
  • Keep every io.grpc artifact on the version that ships with io.koraframework:grpc-server.
  • Annotate handwritten DTOs with @Json only 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 BindableService from the graph
  • how unary RPC methods map to familiar CRUD operations
  • how gRPC Status errors 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: