HTTP Server Guide¶
This guide introduces the core workflow for building HTTP APIs with Kora. It covers how @HttpController and @HttpRoute turn Java methods into HTTP endpoints, how @Json, @Path, and @Query
bind requests to typed application code, and how explicit response and exception APIs give each route clear HTTP behavior. You will also see how Kora's compile-time dependency graph connects
controllers, application services, repositories, JSON mappers, configuration, and the Undertow server into one runnable application.
If you want to check your progress along the way, use the finished working example: Kora Java HTTP Server App.
If you want to check your progress along the way, use the finished working example: Kora Kotlin HTTP Server App.
What You'll Build¶
By the end of the guide, you will have:
- a
UserControllerwith CRUD routes - request and response DTOs
- an in-memory
UserRepository - a
UserServicethat holds application logic - public API on port
8080 - system API on port
8085
What You'll Need¶
- JDK 25 or later
- Gradle 9+
- A text editor or IDE
- Completed JSON Processing with Kora
Kora 2.0 artifacts are compiled for Java 25, so the JDK that compiles the application must be 25 or newer.
Prerequisites¶
Required Foundation
This guide assumes you have completed JSON Processing with Kora and have a working Kora project with JSON DTO mapping available.
If you haven't completed the JSON guide yet, do that first, because it already builds on the getting started guide and gives this HTTP API the JSON serialization patterns it needs.
Overview¶
Kora HTTP servers are built around a simple idea: ordinary methods can become HTTP endpoints when their transport contract is declared explicitly. You write controller classes, annotate routes and parameters, and Kora generates the request handling code during compilation.
That means an HTTP API in Kora is not built from low-level request parsing. It is built from typed method signatures and annotations that describe how HTTP data maps to application code.
Controllers as Transport Adapters¶
A controller is the HTTP boundary of the application. It should understand routes, request bodies, path variables, query parameters, status codes, and headers. It should not become the place where every storage or business rule lives forever. That is why this guide gradually separates controller, service, and repository responsibilities.
Kora annotations describe how HTTP data enters and leaves controller methods:
@HttpControllermarks a class as an HTTP controller@HttpRoutedeclares an HTTP method and path@Jsonmaps JSON request and response bodies@Pathmaps route placeholders into method parameters@Querymaps query-string values into method parameters
Request handling is synchronous. Undertow dispatches every request onto a virtual thread before the generated handler calls your method, so a controller method may block freely: it returns its
result directly and never returns CompletionStage, Mono/Flux, or a suspend function.
Explicit HTTP Behavior¶
Simple methods can return DTOs directly, but real APIs often need more control. HttpResponseEntity<T> lets a route return a body with a specific status code or headers. HttpServerResponse is
useful for responses without a JSON body, such as 204 No Content. HttpServerResponseException provides a direct way to end a request with a clear HTTP error.
These types keep HTTP behavior visible in the controller instead of hiding status codes inside unrelated service code.
Application Layers¶
The guide starts with one controller method, then introduces storage and application logic as separate concerns. The repository owns data access. The service owns application behavior. The controller owns HTTP presentation. This layering is intentionally small, but it is the same shape that later guides reuse for databases, validation, caching, resilience, and observability.
The practical flow is:
- add the HTTP server and JSON modules
- create request and response DTOs
- expose the first JSON route
- add path and query parameter mapping
- introduce repository and service layers
- return explicit statuses, headers, and HTTP errors
Dependencies¶
The HTTP server lives in the http-server-undertow module, and JSON support lives in json-common. Both are Kora modules, so their versions come from the io.koraframework:kora-bom platform
instead of being written on every line.
Update build.gradle:
dependencies {
koraBom platform("io.koraframework:kora-bom:2.0.0.RC1") //(1)!
annotationProcessor "io.koraframework:annotation-processors" //(2)!
implementation "io.koraframework:config-hocon" //(3)!
implementation "io.koraframework:http-server-undertow" //(4)!
implementation "io.koraframework:json-common" //(5)!
implementation "io.koraframework:logging-logback" //(6)!
}
- Kora BOM: aligns the versions of every Kora module and of the libraries Kora depends on.
- Kora annotation processor: generates the application graph, the controller modules, and the JSON readers/writers during compilation.
- HOCON configuration reader for
application.conf. - Undertow HTTP server transport.
- Compile-time JSON infrastructure.
- Logback logging implementation wired into the Kora graph.
Update build.gradle.kts:
dependencies {
implementation(platform("io.koraframework:kora-bom:2.0.0.RC1")) //(1)!
ksp("io.koraframework:symbol-processors:2.0.0.RC1") //(2)!
implementation("io.koraframework:config-hocon") //(3)!
implementation("io.koraframework:http-server-undertow") //(4)!
implementation("io.koraframework:json-common") //(5)!
implementation("io.koraframework:logging-logback") //(6)!
}
- Kora BOM: aligns the versions of every Kora module and of the libraries Kora depends on.
- Kora KSP processor: generates the application graph, the controller modules, and the JSON readers/writers during compilation.
- HOCON configuration reader for
application.conf. - Undertow HTTP server transport.
- Compile-time JSON infrastructure.
- Logback logging implementation wired into the Kora graph.
Modules¶
UndertowPublicHttpServerModule is the module to connect for an application that serves business endpoints. It extends UndertowSystemHttpServerModule, so one extends clause gives you two
servers in the same process: the public one on httpServer.port and the system one on httpServer.system.port that answers readiness, liveness, and metrics requests.
Update src/main/java/io/koraframework/guide/httpserver/Application.java:
package io.koraframework.guide.httpserver;
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.json.common.JsonModule;
import io.koraframework.logging.logback.LogbackModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowPublicHttpServerModule { // <----- Connected module
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
Update src/main/kotlin/io/koraframework/guide/httpserver/Application.kt:
package io.koraframework.guide.httpserver
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.json.common.JsonModule
import io.koraframework.logging.logback.LogbackModule
@KoraApp
interface Application :
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowPublicHttpServerModule // <----- Connected module
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
DTO¶
Before we add any route, we need the shapes of the data we want to receive and return.
Create src/main/java/io/koraframework/guide/httpserver/dto/UserRequest.java:
package io.koraframework.guide.httpserver.dto;
import io.koraframework.json.common.annotation.Json;
@Json
public record UserRequest(String name, String email) {}
Create src/main/java/io/koraframework/guide/httpserver/dto/UserResponse.java:
Create src/main/kotlin/io/koraframework/guide/httpserver/dto/UserRequest.kt:
package io.koraframework.guide.httpserver.dto
import io.koraframework.json.common.annotation.Json
@Json
data class UserRequest(
val name: String,
val email: String
)
Create src/main/kotlin/io/koraframework/guide/httpserver/dto/UserResponse.kt:
UserRequest represents incoming JSON from the client.
UserResponse represents the JSON your API sends back.
Starting with DTOs makes the next steps easier because the controller signature already has stable, named types instead of anonymous maps or raw strings.
Create User¶
Now we create the first controller and the first route. At this point we will not save anything yet. The goal of this step is to understand how Kora maps an HTTP request to a controller method.
Create src/main/java/io/koraframework/guide/httpserver/controller/UserController.java:
package io.koraframework.guide.httpserver.controller;
import java.time.LocalDateTime;
import java.util.UUID;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.json.common.annotation.Json;
@Component
@HttpController
public final class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public UserResponse createUser(@Json UserRequest request) {
System.out.printf("Received createUser request: name=%s, email=%s%n", request.name(), request.email());
return new UserResponse(
UUID.randomUUID().toString(),
request.name(),
request.email(),
LocalDateTime.now());
}
}
Create src/main/kotlin/io/koraframework/guide/httpserver/controller/UserController.kt:
package io.koraframework.guide.httpserver.controller
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.json.common.annotation.Json
import java.time.LocalDateTime
import java.util.UUID
@Component
@HttpController
class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): UserResponse {
println("Received createUser request: name=${request.name}, email=${request.email}")
return UserResponse(
UUID.randomUUID().toString(),
request.name,
request.email,
LocalDateTime.now()
)
}
}
Let's break down what is happening here:
-
@ComponentKora should create this class and put it into the dependency graph. -
@HttpControllerThis class contains HTTP routes. Kora scans it and generates the HTTP handler wiring. -
@HttpRoute(method = HttpMethod.POST, path = "/users")This method should handlePOST /users.HttpMethodholds the standard HTTP method names as string constants. -
@Jsonon the method Kora should use the data mapper with the special@Jsontag to serialize the return value to JSON. -
@Jsonon the parameter Kora should use the data mapper with the special@Jsontag to deserialize the request body from JSON intoUserRequest.
At this point the route already feels like a real API, but it still does not remember anything. Every call creates a new response object and returns it immediately.
Try it:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john@example.com"}'
Get User¶
The next natural route is getUser. But as soon as we add it, we hit an important design question: where do users live after createUser returns?
For now, we will add the route and deliberately return 404 to show that the controller already knows how to express HTTP-level failure.
Update UserController.java:
package io.koraframework.guide.httpserver.controller;
import java.time.LocalDateTime;
import java.util.UUID;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.annotation.Path;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.json.common.annotation.Json;
@Component
@HttpController
public final class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public UserResponse createUser(@Json UserRequest request) {
System.out.printf("Received createUser request: name=%s, email=%s%n", request.name(), request.email());
return new UserResponse(
UUID.randomUUID().toString(),
request.name(),
request.email(),
LocalDateTime.now());
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
public UserResponse getUser(@Path String userId) {
throw HttpServerResponseException.of(404, "User not found: " + userId);
}
}
Update UserController.kt:
package io.koraframework.guide.httpserver.controller
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.annotation.Path
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.json.common.annotation.Json
import java.time.LocalDateTime
import java.util.UUID
@Component
@HttpController
class UserController {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): UserResponse {
println("Received createUser request: name=${request.name}, email=${request.email}")
return UserResponse(
UUID.randomUUID().toString(),
request.name,
request.email,
LocalDateTime.now()
)
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
fun getUser(@Path userId: String): UserResponse {
throw HttpServerResponseException.of(404, "User not found: $userId")
}
}
Two new ideas appear here:
-
@Path String userIdKora takes the{userId}part from the route path and passes it into the method. -
HttpServerResponseExceptionThis is a simple way to say "this request should end with this HTTP error". The exception is itself anHttpServerResponse, so the server writes its status and body without any extra mapping.
This step is intentionally incomplete. We now have enough controller behavior to see why a separate storage abstraction is needed.
User Repository¶
Now we add a repository layer. A repository is responsible for storing and retrieving data. In this guide we use an in-memory map because it keeps the example easy to run, but the abstraction itself will later let us switch to a real database.
At first we only need two operations:
- save a user
- get a user by ID
Create src/main/java/io/koraframework/guide/httpserver/repository/UserRepository.java:
package io.koraframework.guide.httpserver.repository;
import java.util.Optional;
import io.koraframework.guide.httpserver.dto.UserResponse;
public interface UserRepository {
String save(String name, String email);
Optional<UserResponse> findById(String id);
}
Create src/main/java/io/koraframework/guide/httpserver/repository/InMemoryUserRepository.java:
package io.koraframework.guide.httpserver.repository;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserResponse;
@Component
public final class InMemoryUserRepository implements UserRepository {
private final Map<String, UserResponse> users = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);
@Override
public String save(String name, String email) {
String id = String.valueOf(idGenerator.getAndIncrement());
users.put(id, new UserResponse(id, name, email, LocalDateTime.now()));
return id;
}
@Override
public Optional<UserResponse> findById(String id) {
return Optional.ofNullable(users.get(id));
}
}
Create src/main/kotlin/io/koraframework/guide/httpserver/repository/UserRepository.kt:
package io.koraframework.guide.httpserver.repository
import io.koraframework.guide.httpserver.dto.UserResponse
interface UserRepository {
fun save(name: String, email: String): String
fun findById(id: String): UserResponse?
}
Create src/main/kotlin/io/koraframework/guide/httpserver/repository/InMemoryUserRepository.kt:
package io.koraframework.guide.httpserver.repository
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserResponse
import java.time.LocalDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
@Component
class InMemoryUserRepository : UserRepository {
private val users = ConcurrentHashMap<String, UserResponse>()
private val idGenerator = AtomicLong(1)
override fun save(name: String, email: String): String {
val id = idGenerator.getAndIncrement().toString()
users[id] = UserResponse(id, name, email, LocalDateTime.now())
return id
}
override fun findById(id: String): UserResponse? = users[id]
}
The repository does not know anything about HTTP. It only knows how to store and load user data. That separation is important because storage concerns and HTTP concerns change for different reasons.
Because every request runs on its own virtual thread, the in-memory storage is shared state: ConcurrentHashMap and AtomicLong are used deliberately instead of their non-thread-safe counterparts.
Controller to Repository¶
Now that we have storage, we can go back to the controller and make createUser and getUser actually work together.
Update UserController.java:
package io.koraframework.guide.httpserver.controller;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.guide.httpserver.repository.UserRepository;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.annotation.Path;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.json.common.annotation.Json;
@Component
@HttpController
public final class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public UserResponse createUser(@Json UserRequest request) {
String id = userRepository.save(request.name(), request.email());
return userRepository.findById(id)
.orElseThrow(() -> new IllegalStateException("Saved user not found"));
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
public UserResponse getUser(@Path String userId) {
return userRepository.findById(userId)
.orElseThrow(() -> HttpServerResponseException.of(404, "User not found: " + userId));
}
}
Update UserController.kt:
package io.koraframework.guide.httpserver.controller
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.guide.httpserver.repository.UserRepository
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.annotation.Path
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.json.common.annotation.Json
@Component
@HttpController
class UserController(
private val userRepository: UserRepository
) {
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): UserResponse {
val id = userRepository.save(request.name, request.email)
return userRepository.findById(id)
?: error("Saved user not found")
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
fun getUser(@Path userId: String): UserResponse {
return userRepository.findById(userId)
?: throw HttpServerResponseException.of(404, "User not found: $userId")
}
}
This is the first moment where the API becomes stateful. You can now call createUser, get an ID back, and then use that ID in getUser.
UserRepository is an interface, and InMemoryUserRepository is the @Component that implements it. The controller asks for the interface in its constructor, and Kora resolves that edge of the
graph during compilation — if the implementation were missing, the build would fail with No component found for dependency instead of failing at runtime.
CRUD Repository¶
The API already works for create and get. Before adding more HTTP routes, we first make the storage abstraction capable of the full CRUD flow:
- list users
- update users
- delete users
This keeps the repository focused on storage operations only. The controller will start using these operations in the next section, after we introduce a service layer between HTTP routing and storage.
Expand UserRepository.java:
package io.koraframework.guide.httpserver.repository;
import java.util.List;
import java.util.Optional;
import io.koraframework.guide.httpserver.dto.UserResponse;
public interface UserRepository {
List<UserResponse> findAll();
Optional<UserResponse> findById(String id);
String save(String name, String email);
boolean update(String id, String name, String email);
boolean deleteById(String id);
}
Expand InMemoryUserRepository.java:
package io.koraframework.guide.httpserver.repository;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserResponse;
@Component
public final class InMemoryUserRepository implements UserRepository {
private final Map<String, UserResponse> users = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);
@Override
public List<UserResponse> findAll() {
return new ArrayList<>(users.values());
}
@Override
public Optional<UserResponse> findById(String id) {
return Optional.ofNullable(users.get(id));
}
@Override
public String save(String name, String email) {
String id = String.valueOf(idGenerator.getAndIncrement());
users.put(id, new UserResponse(id, name, email, LocalDateTime.now()));
return id;
}
@Override
public boolean update(String id, String name, String email) {
return users.computeIfPresent(id,
(k, v) -> new UserResponse(k, name, email, v.createdAt())) != null;
}
@Override
public boolean deleteById(String id) {
return users.remove(id) != null;
}
}
Expand UserRepository.kt:
package io.koraframework.guide.httpserver.repository
import io.koraframework.guide.httpserver.dto.UserResponse
interface UserRepository {
fun findAll(): List<UserResponse>
fun findById(id: String): UserResponse?
fun save(name: String, email: String): String
fun update(id: String, name: String, email: String): Boolean
fun deleteById(id: String): Boolean
}
Expand InMemoryUserRepository.kt:
package io.koraframework.guide.httpserver.repository
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserResponse
import java.time.LocalDateTime
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
@Component
class InMemoryUserRepository : UserRepository {
private val users = ConcurrentHashMap<String, UserResponse>()
private val idGenerator = AtomicLong(1)
override fun findAll(): List<UserResponse> = users.values.toList()
override fun findById(id: String): UserResponse? = users[id]
override fun save(name: String, email: String): String {
val id = idGenerator.getAndIncrement().toString()
users[id] = UserResponse(id, name, email, LocalDateTime.now())
return id
}
override fun update(id: String, name: String, email: String): Boolean {
return users.computeIfPresent(id) { key, current -> UserResponse(key, name, email, current.createdAt) } != null
}
override fun deleteById(id: String): Boolean = users.remove(id) != null
}
At this stage the repository can store, list, update, and delete users, but the HTTP API still exposes only the routes from the previous section. Next we add a service layer and then connect the full CRUD behavior to the controller.
Service Layer¶
In many applications the controller is treated as the presentation layer, while the service layer holds application logic. This is especially common in MVC-style applications and in services that later grow more rules, integrations, and reuse points.
The repository now has every storage operation the API needs. The service layer turns those operations into application behavior:
- it creates users from request DTOs
- it sorts and pages the in-memory list
- it maps repository update/delete results to business errors
After that, the controller can stay focused on HTTP routing, request binding, response codes, and headers.
Create src/main/java/io/koraframework/guide/httpserver/service/UserService.java:
package io.koraframework.guide.httpserver.service;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.guide.httpserver.repository.UserRepository;
import io.koraframework.http.server.common.response.HttpServerResponseException;
@Component
public final class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public UserResponse createUser(UserRequest request) {
var generatedId = userRepository.save(request.name(), request.email());
return new UserResponse(generatedId, request.name(), request.email(), LocalDateTime.now());
}
public Optional<UserResponse> getUser(String id) {
return userRepository.findById(id);
}
public List<UserResponse> getUsers(int page, int size, String sort) {
return userRepository.findAll().stream()
.sorted(getComparator(sort))
.skip((long) page * size)
.limit(size)
.toList();
}
public UserResponse updateUser(String id, UserRequest request) {
boolean updated = userRepository.update(id, request.name(), request.email());
if (!updated) {
throw HttpServerResponseException.of(404, "User not found: " + id);
}
return new UserResponse(id, request.name(), request.email(), LocalDateTime.now());
}
public void deleteUser(String id) {
boolean deleted = userRepository.deleteById(id);
if (!deleted) {
throw HttpServerResponseException.of(404, "User not found: " + id);
}
}
private Comparator<UserResponse> getComparator(String sort) {
return switch (sort.toLowerCase()) {
case "name" -> Comparator.comparing(UserResponse::name);
case "email" -> Comparator.comparing(UserResponse::email);
case "createdat" -> Comparator.comparing(UserResponse::createdAt);
default -> Comparator.comparing(UserResponse::name);
};
}
}
Create src/main/kotlin/io/koraframework/guide/httpserver/service/UserService.kt:
package io.koraframework.guide.httpserver.service
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.guide.httpserver.repository.UserRepository
import io.koraframework.http.server.common.response.HttpServerResponseException
import java.time.LocalDateTime
@Component
class UserService(
private val userRepository: UserRepository
) {
fun createUser(request: UserRequest): UserResponse {
val generatedId = userRepository.save(request.name, request.email)
return UserResponse(generatedId, request.name, request.email, LocalDateTime.now())
}
fun getUser(id: String): UserResponse? = userRepository.findById(id)
fun getUsers(page: Int, size: Int, sort: String): List<UserResponse> =
userRepository.findAll()
.sortedWith(getComparator(sort))
.drop(page * size)
.take(size)
fun updateUser(id: String, request: UserRequest): UserResponse {
if (!userRepository.update(id, request.name, request.email)) {
throw HttpServerResponseException.of(404, "User not found: $id")
}
return UserResponse(id, request.name, request.email, LocalDateTime.now())
}
fun deleteUser(id: String) {
if (!userRepository.deleteById(id)) {
throw HttpServerResponseException.of(404, "User not found: $id")
}
}
private fun getComparator(sort: String): Comparator<UserResponse> = when (sort.lowercase()) {
"name" -> compareBy { it.name }
"email" -> compareBy { it.email }
"createdat" -> compareBy { it.createdAt }
else -> compareBy { it.name }
}
}
UserService throws HttpServerResponseException for "not found" so the guide stays short. In a larger application the service would usually throw a domain exception and one global interceptor would
translate it into an HTTP status — that is exactly what the HTTP Server Advanced guide builds next.
Controller and Service¶
Now the controller can expose the full CRUD API without owning storage or application logic. It receives HTTP requests, binds route and query parameters, delegates work to UserService, and chooses
the HTTP response shape for each route.
This step also adds the remaining HTTP-specific pieces:
@Querymaps query-string values such as?page=0&size=10&sort=nameinto controller parameters- a nullable parameter type marks an optional query parameter
HttpResponseEntity<T>returns a JSON body together with an explicit status code or headersHttpServerResponsereturns responses without a JSON body, such as204 No Content
In Java, optional parameters are marked with JSpecify @Nullable from org.jspecify.annotations; in Kotlin the ? on the parameter type is enough and no annotation is needed. A @Query parameter
that is neither nullable nor optional is required, and a request without it is answered with 400 before the controller method runs.
Rewrite UserController.java to delegate to the service:
package io.koraframework.guide.httpserver.controller;
import org.jspecify.annotations.Nullable;
import java.time.Instant;
import java.util.List;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.guide.httpserver.service.UserService;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.HttpResponseEntity;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.annotation.Path;
import io.koraframework.http.common.annotation.Query;
import io.koraframework.http.common.body.HttpBody;
import io.koraframework.http.common.header.HttpHeaders;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.http.server.common.response.HttpServerResponse;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.json.common.annotation.Json;
@Component
@HttpController
public final class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
public UserResponse getUser(@Path String userId) {
return userService.getUser(userId)
.orElseThrow(() -> HttpServerResponseException.of(404, "User not found: " + userId));
}
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
public List<UserResponse> getUsers(
@Nullable @Query("page") Integer page,
@Nullable @Query("size") Integer size,
@Nullable @Query("sort") String sort) {
int pageNum = page == null ? 0 : page;
int pageSize = size == null ? 10 : size;
String sortBy = sort == null ? "name" : sort;
return userService.getUsers(pageNum, pageSize, sortBy);
}
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public HttpResponseEntity<UserResponse> createUser(@Json UserRequest request) {
UserResponse user = userService.createUser(request);
return HttpResponseEntity.of(201, HttpHeaders.of(), user);
}
@HttpRoute(method = HttpMethod.PUT, path = "/users/{userId}")
@Json
public HttpResponseEntity<UserResponse> updateUser(@Path String userId, @Json UserRequest request) {
UserResponse updated = userService.updateUser(userId, request);
return HttpResponseEntity.of(200, HttpHeaders.of("X-Updated-At", Instant.now().toString()), updated);
}
@HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
public HttpServerResponse deleteUser(@Path String userId) {
userService.deleteUser(userId);
return HttpServerResponse.of(204, HttpBody.empty());
}
}
Rewrite UserController.kt to delegate to the service:
package io.koraframework.guide.httpserver.controller
import io.koraframework.common.annotation.Component
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.guide.httpserver.service.UserService
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.HttpResponseEntity
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.annotation.Path
import io.koraframework.http.common.annotation.Query
import io.koraframework.http.common.body.HttpBody
import io.koraframework.http.common.header.HttpHeaders
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.server.common.response.HttpServerResponse
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.json.common.annotation.Json
import java.time.Instant
@Component
@HttpController
class UserController(
private val userService: UserService
) {
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
fun getUser(@Path userId: String): UserResponse =
userService.getUser(userId) ?: throw HttpServerResponseException.of(404, "User not found: $userId")
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
fun getUsers(
@Query("page") page: Int?,
@Query("size") size: Int?,
@Query("sort") sort: String?
): List<UserResponse> =
userService.getUsers(page ?: 0, size ?: 10, sort ?: "name")
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(@Json request: UserRequest): HttpResponseEntity<UserResponse> =
HttpResponseEntity.of(201, HttpHeaders.of(), userService.createUser(request))
@HttpRoute(method = HttpMethod.PUT, path = "/users/{userId}")
@Json
fun updateUser(@Path userId: String, @Json request: UserRequest): HttpResponseEntity<UserResponse> =
HttpResponseEntity.of(
200,
HttpHeaders.of("X-Updated-At", Instant.now().toString()),
userService.updateUser(userId, request)
)
@HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
fun deleteUser(@Path userId: String): HttpServerResponse {
userService.deleteUser(userId)
return HttpServerResponse.of(204, HttpBody.empty())
}
}
This is the final structure used by the runnable companion app. The behavior did not change, but the architecture became cleaner:
- controller = HTTP presentation
- repository = storage abstraction
- service = application logic
Note how the return type of each route decides which mapper Kora looks up at compile time:
UserResponseandList<UserResponse>with@Jsonneed aJsonWriterfor the type, which@Jsonon the DTO generatesHttpResponseEntity<UserResponse>reuses the same JSON writer and adds the status code and headers on topHttpServerResponseis returned as-is and needs no mapper at all
Configuration¶
Now that the application structure is in place, we can wire the HTTP server configuration itself.
Create or update src/main/resources/application.conf:
For the full configuration reference, see HTTP Server and Logging SLF4J.
httpServer {
port = 8080 //(1)!
system.port = 8085 //(2)!
telemetry.logging.enabled = true //(3)!
}
logging {
levels {
"ROOT": "WARN" //(4)!
"io.koraframework": "INFO" //(5)!
}
}
- Public HTTP port used by application endpoints (default:
8080). - System HTTP port used by probes, metrics, and management endpoints (default:
8085). - Enables request logging for the public HTTP server (default:
false). - Log level for the root logger.
- Log level for Kora framework loggers.
httpServer:
port: 8080 #(1)!
system:
port: 8085 #(2)!
telemetry:
logging:
enabled: true #(3)!
logging:
levels:
ROOT: "WARN" #(4)!
"io.koraframework": "INFO" #(5)!
- Public HTTP port used by application endpoints (default:
8080). - System HTTP port used by probes, metrics, and management endpoints (default:
8085). - Enables request logging for the public HTTP server (default:
false). - Log level for the root logger.
- Log level for Kora framework loggers.
This gives you two ports:
8080for the main application API8085for system endpoints such as readiness and liveness
That split is useful in real systems because health checks and operational endpoints are usually kept separate from public business traffic. Both keys already have those defaults, so the application
also starts without an application.conf at all; the file matters as soon as you need to move a port, raise a log level, or read a value from an environment variable.
Check Applications¶
classes is the meaningful first check in Kora: it runs the annotation processor or KSP, generates the controller module and the application graph, and fails at compile time if a route or a
dependency cannot be wired.
Public API checks:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john@example.com"}'
curl http://localhost:8080/users/1
curl "http://localhost:8080/users?page=0&size=10&sort=name"
curl -X PUT http://localhost:8080/users/1 \
-H "Content-Type: application/json" \
-d '{"name": "Updated Name", "email": "updated@example.com"}'
curl -X DELETE http://localhost:8080/users/1
System API checks:
curl http://localhost:8085/system/readiness
# Expected output: OK
curl http://localhost:8085/system/liveness
# Expected output: OK
Best Practices¶
- Keep controller methods thin once the project grows beyond trivial handlers.
- Use repositories for storage concerns and services for application logic.
- Use
HttpResponseEntitywhen you need explicit status codes or headers. - Throw
HttpServerResponseExceptionwhen the controller or service needs to expose a clean HTTP error. - Keep controller methods synchronous and let Undertow's virtual threads carry the blocking work.
- Guard any state shared between requests, because concurrent requests run on different threads.
Summary¶
You built a Kora HTTP API gradually:
- first one route without persistence
- then a second route that revealed the need for storage
- then a repository abstraction with an in-memory implementation
- then a repository contract expanded to support full CRUD
- and finally a service layer plus controller routes that expose the complete API
Key Concepts¶
- Kora HTTP routing with
@HttpRoute - JSON request and response mapping with
@Json - request mapping with
@Pathand@Query - response control with
HttpResponseEntity - HTTP error signaling with
HttpServerResponseException - the different responsibilities of controller, repository, and service
- one process, two HTTP servers: the public one and the system one
Troubleshooting¶
Server does not start:
- Check ports
8080and8085availability. - Verify
ApplicationincludesUndertowPublicHttpServerModuleandHoconConfigModule.
Compilation fails with No component found for dependency:
- The type listed in the message is not in the graph. Add
@Componentto its implementation, or provide it from a module method. - A frequent case is forgetting
@ComponentonInMemoryUserRepositoryorUserService.
Compilation fails with JsonWriter<T> was not found:
- The DTO returned by the route is not annotated with
@Json, so no writer was generated for it.
getUser always returns 404:
- Check that
createUserandgetUserare already wired to the repository layer. - Make sure you are calling
getUserwith an ID that was actually returned fromcreateUser.
Optional query parameters are not handled correctly:
- In Java use nullable wrappers with
@Nullable @Query, such asIntegerandString, and import@Nullablefromorg.jspecify.annotations. - In Kotlin declare the parameter type as nullable, for example
page: Int?. - A missing required query parameter is answered with
400before the method is called.
Kotlin build fails with Suspend methods are not supported by the HTTP server controller generator:
- Remove
suspendfrom the controller method. Kora HTTP handlers are synchronous and already run on a virtual thread.
Build hangs or fails unexpectedly:
- Run
./gradlew --stop, then retry.
What's Next?¶
- JSON Processing to make HTTP request and response DTO mapping explicit.
- Validation to add boundary checks around the same HTTP API.
- Database JDBC or Cassandra Database to replace the in-memory repository with real persistence.
- HTTP Server Advanced after the basic CRUD shape is comfortable.
- HTTP Client when you want another Kora application to call this API.
Help¶
If you encounter issues:
- compare with Kora Java HTTP Server App and Kora Kotlin HTTP Server App
- check the HTTP Server documentation
- check the JSON documentation