Skip to content

API Design & Serialization

You’ve built REST APIs in Express with Zod validation, maybe used tRPC for end-to-end type safety, or Apollo Server for GraphQL. In Go, you’ve used encoding/json struct tags, maybe gqlgen or grpc-go. On the JVM, you get a richer (and more complex) ecosystem: Jackson, kotlinx.serialization, springdoc-openapi, graphql-kotlin, and grpc-kotlin.

This module covers:

  1. REST best practices — naming, versioning, pagination, filtering, HATEOAS
  2. OpenAPI/Swagger — auto-generate docs with springdoc-openapi
  3. Serialization — kotlinx.serialization vs Jackson, when to use which
  4. GraphQL — graphql-kotlin (Expedia) with Spring Boot
  5. gRPC — protobuf definitions, server/client in Kotlin

Use nouns (not verbs), plural, lowercase, kebab-case for multi-word:

Good:
GET /api/tasks → list tasks
GET /api/tasks/123 → get task 123
POST /api/tasks → create task
PUT /api/tasks/123 → replace task 123
PATCH /api/tasks/123 → partial update task 123
DELETE /api/tasks/123 → delete task 123
GET /api/tasks/123/comments → list comments on task 123
POST /api/tasks/123/comments → add comment to task 123
GET /api/task-categories → multi-word resource (kebab-case)
Bad:
GET /api/getTask/123 → verb in URL
POST /api/createTask → verb in URL
GET /api/task/123 → singular (use plural)
GET /api/Task/123 → uppercase

Three common strategies:

// 1. URL path versioning (most common, simplest)
@RestController
@RequestMapping("/api/v1/tasks")
class TaskV1Controller { /* ... */ }
@RestController
@RequestMapping("/api/v2/tasks")
class TaskV2Controller { /* ... */ }
// 2. Header versioning
@GetMapping("/api/tasks", headers = ["X-API-Version=1"])
fun getTasksV1(): List<TaskV1> { /* ... */ }
@GetMapping("/api/tasks", headers = ["X-API-Version=2"])
fun getTasksV2(): List<TaskV2> { /* ... */ }
// 3. Media type versioning (content negotiation)
@GetMapping("/api/tasks", produces = ["application/vnd.myapp.v1+json"])
fun getTasksV1(): List<TaskV1> { /* ... */ }
@GetMapping("/api/tasks", produces = ["application/vnd.myapp.v2+json"])
fun getTasksV2(): List<TaskV2> { /* ... */ }

Offset-based pagination (simple, works for most cases):

// Request: GET /api/tasks?page=0&size=20&sort=createdAt,desc
// Spring Data provides Pageable out of the box
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.web.PageableDefault
@GetMapping
fun getTasks(
@PageableDefault(size = 20, sort = ["createdAt"], direction = Sort.Direction.DESC)
pageable: Pageable,
): Page<TaskResponse> {
return taskService.findAll(pageable).map { it.toResponse() }
}

Spring’s Page<T> response includes pagination metadata:

{
"content": [
{ "id": 1, "title": "Task 1" },
{ "id": 2, "title": "Task 2" }
],
"pageable": {
"pageNumber": 0,
"pageSize": 20,
"sort": { "sorted": true, "direction": "DESC" }
},
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}

Cursor-based pagination (better for real-time data, infinite scroll):

data class CursorPage<T>(
val items: List<T>,
val nextCursor: String?, // Opaque cursor for next page
val hasMore: Boolean,
)
@GetMapping
fun getTasks(
@RequestParam cursor: String? = null,
@RequestParam(defaultValue = "20") limit: Int,
): CursorPage<TaskResponse> {
val decodedCursor = cursor?.let { decodeCursor(it) }
val tasks = taskRepository.findAfterCursor(decodedCursor, limit + 1)
val hasMore = tasks.size > limit
val items = tasks.take(limit).map { it.toResponse() }
val nextCursor = if (hasMore) encodeCursor(items.last().id) else null
return CursorPage(items = items, nextCursor = nextCursor, hasMore = hasMore)
}
private fun encodeCursor(id: Long): String =
java.util.Base64.getEncoder().encodeToString("id:$id".toByteArray())
private fun decodeCursor(cursor: String): Long {
val decoded = String(java.util.Base64.getDecoder().decode(cursor))
return decoded.removePrefix("id:").toLong()
}

The same offset-pagination handler, in each ecosystem:

// Kotlin (Spring): Pageable + Page<T> do the work for you
@GetMapping
fun getTasks(
@PageableDefault(size = 20, sort = ["createdAt"]) pageable: Pageable,
): Page<TaskResponse> =
taskService.findAll(pageable).map { it.toResponse() }

For reference, here’s the offset-pagination handler written by hand in the TypeScript and Go worlds you’re coming from:

// Express: manual pagination
app.get("/api/tasks", async (req, res) => {
const page = parseInt(req.query.page as string) || 0;
const size = parseInt(req.query.size as string) || 20;
const [tasks, total] = await db.tasks.findAndCount({
skip: page * size,
take: size,
order: { createdAt: "DESC" },
});
res.json({
content: tasks,
totalElements: total,
totalPages: Math.ceil(total / size),
page,
size,
});
});
// Request: GET /api/tasks?status=OPEN&priority=HIGH&sort=dueDate,asc
@GetMapping
fun getTasks(
@RequestParam status: TaskStatus? = null,
@RequestParam priority: Priority? = null,
@RequestParam assignee: String? = null,
@PageableDefault(size = 20) pageable: Pageable,
): Page<TaskResponse> {
return taskService.findByFilters(status, priority, assignee, pageable)
.map { it.toResponse() }
}

With Spring Data JPA Specification<Task> for dynamic queries:

import org.springframework.data.jpa.domain.Specification
object TaskSpecifications {
fun hasStatus(status: TaskStatus?): Specification<Task> {
return Specification { root, _, cb ->
status?.let { cb.equal(root.get<TaskStatus>("status"), it) }
}
}
fun hasPriority(priority: Priority?): Specification<Task> {
return Specification { root, _, cb ->
priority?.let { cb.equal(root.get<Priority>("priority"), it) }
}
}
fun assignedTo(assignee: String?): Specification<Task> {
return Specification { root, _, cb ->
assignee?.let { cb.equal(root.get<String>("assigneeId"), it) }
}
}
}
// In service
fun findByFilters(
status: TaskStatus?,
priority: Priority?,
assignee: String?,
pageable: Pageable,
): Page<Task> {
val spec = Specification.where(TaskSpecifications.hasStatus(status))
.and(TaskSpecifications.hasPriority(priority))
.and(TaskSpecifications.assignedTo(assignee))
return taskRepository.findAll(spec, pageable)
}

Use RFC 7807 Problem Details (Spring Boot 3.x supports this natively):

application.yml
spring:
mvc:
problemdetails:
enabled: true
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import java.net.URI
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(TaskNotFoundException::class)
fun handleNotFound(ex: TaskNotFoundException): ProblemDetail {
return ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
ex.message ?: "Resource not found",
).apply {
title = "Not Found"
type = URI.create("https://api.example.com/errors/not-found")
setProperty("taskId", ex.taskId)
}
}
@ExceptionHandler(ValidationException::class)
fun handleValidation(ex: ValidationException): ProblemDetail {
return ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"Validation failed",
).apply {
title = "Bad Request"
setProperty("errors", ex.errors)
}
}
}

Response:

{
"type": "https://api.example.com/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Task with id 999 not found",
"instance": "/api/tasks/999",
"taskId": 999
}

HATEOAS adds hypermedia links to responses. Spring has full support, but it adds complexity most APIs don’t need:

// With Spring HATEOAS
import org.springframework.hateoas.EntityModel
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn
@GetMapping("/{id}")
fun getTask(@PathVariable id: Long): EntityModel<TaskResponse> {
val task = taskService.findById(id).toResponse()
return EntityModel.of(task).apply {
add(linkTo(methodOn(TaskController::class.java).getTask(id)).withSelfRel())
add(linkTo(methodOn(TaskController::class.java).getTasks(Pageable.unpaged())).withRel("tasks"))
add(linkTo(methodOn(CommentController::class.java).getComments(id)).withRel("comments"))
}
}

Response:

{
"id": 1,
"title": "Task 1",
"_links": {
"self": { "href": "http://localhost:8080/api/tasks/1" },
"tasks": { "href": "http://localhost:8080/api/tasks" },
"comments": { "href": "http://localhost:8080/api/tasks/1/comments" }
}
}
build.gradle.kts
dependencies {
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0")
}

That’s it. Visit http://localhost:8080/swagger-ui.html and you get auto-generated Swagger UI.

application.yml
springdoc:
api-docs:
path: /api-docs # OpenAPI JSON at /api-docs
swagger-ui:
path: /swagger-ui.html # Swagger UI
tags-sorter: alpha
operations-sorter: method
default-produces-media-type: application/json
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.info.Contact
import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.security.SecurityScheme
import io.swagger.v3.oas.models.security.SecurityRequirement
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class OpenApiConfig {
@Bean
fun customOpenAPI(): OpenAPI {
return OpenAPI()
.info(
Info()
.title("Task API")
.version("1.0.0")
.description("Task management API with JWT authentication")
.contact(Contact().name("Dev Team").email("dev@example.com"))
)
.addSecurityItem(SecurityRequirement().addList("Bearer Auth"))
.components(
Components().addSecuritySchemes(
"Bearer Auth",
SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("Enter JWT token")
)
)
}
}

springdoc auto-generates most docs from your controller signatures. Add annotations for extra detail:

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import io.swagger.v3.oas.annotations.security.SecurityRequirement
@RestController
@RequestMapping("/api/v1/tasks")
@Tag(name = "Tasks", description = "Task management endpoints")
@SecurityRequirement(name = "Bearer Auth")
class TaskController(
private val taskService: TaskService,
) {
@Operation(
summary = "List all tasks",
description = "Returns a paginated list of tasks for the authenticated user",
)
@ApiResponses(
ApiResponse(responseCode = "200", description = "Tasks retrieved successfully"),
ApiResponse(responseCode = "401", description = "Not authenticated"),
)
@GetMapping
fun getTasks(
@Parameter(description = "Page number (0-indexed)")
@RequestParam(defaultValue = "0") page: Int,
@Parameter(description = "Page size")
@RequestParam(defaultValue = "20") size: Int,
): Page<TaskResponse> {
return taskService.findAll(PageRequest.of(page, size))
}
@Operation(summary = "Create a new task")
@ApiResponses(
ApiResponse(responseCode = "201", description = "Task created"),
ApiResponse(
responseCode = "400",
description = "Invalid request",
content = [Content(schema = Schema(implementation = ProblemDetail::class))],
),
)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createTask(@RequestBody request: CreateTaskRequest): TaskResponse {
return taskService.create(request).toResponse()
}
@Operation(summary = "Get task by ID")
@ApiResponses(
ApiResponse(responseCode = "200", description = "Task found"),
ApiResponse(responseCode = "404", description = "Task not found"),
)
@GetMapping("/{id}")
fun getTask(
@Parameter(description = "Task ID") @PathVariable id: Long,
): TaskResponse {
return taskService.findById(id).toResponse()
}
@Operation(summary = "Delete a task")
@ApiResponse(responseCode = "204", description = "Task deleted")
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteTask(@PathVariable id: Long) {
taskService.delete(id)
}
}
import io.swagger.v3.oas.annotations.media.Schema
@Schema(description = "Request to create a new task")
data class CreateTaskRequest(
@Schema(description = "Task title", example = "Buy groceries", minLength = 1, maxLength = 200)
val title: String,
@Schema(description = "Task description", example = "Milk, eggs, bread", required = false)
val description: String? = null,
@Schema(description = "Task priority", example = "HIGH")
val priority: Priority = Priority.MEDIUM,
@Schema(description = "Due date (ISO 8601)", example = "2026-12-31T23:59:59Z", required = false)
val dueDate: Instant? = null,
)
@Schema(description = "Task response")
data class TaskResponse(
@Schema(description = "Task ID", example = "1")
val id: Long,
@Schema(description = "Task title")
val title: String,
@Schema(description = "Current status")
val status: TaskStatus,
@Schema(description = "Creation timestamp")
val createdAt: Instant,
)

The OpenAPI spec at /api-docs can generate typed clients for any language:

Terminal window
# Generate TypeScript client
npx @openapitools/openapi-generator-cli generate \
-i http://localhost:8080/api-docs \
-g typescript-fetch \
-o ./generated-client
# Generate Go client
openapi-generator-cli generate \
-i http://localhost:8080/api-docs \
-g go \
-o ./generated-client-go

Compare to the TypeScript approach:

// TypeScript: tRPC gives you end-to-end type safety without codegen
// But only works with TypeScript clients
// Express + OpenAPI: use tsoa or swagger-jsdoc
import swaggerJsdoc from "swagger-jsdoc";
import swaggerUi from "swagger-ui-express";
const specs = swaggerJsdoc({
definition: {
openapi: "3.0.0",
info: { title: "Task API", version: "1.0.0" },
},
apis: ["./routes/*.ts"],
});
app.use("/swagger", swaggerUi.serve, swaggerUi.setup(specs));

Serialization: kotlinx.serialization vs Jackson

Section titled “Serialization: kotlinx.serialization vs Jackson”
kotlinx.serializationJackson
OriginJetBrains (Kotlin-native)FasterXML (Java ecosystem)
Default inKtorSpring Boot
ApproachCompiler plugin, @SerializableReflection-based + annotations
MultiplatformYes (JVM, JS, Native)JVM only
PerformanceFaster (no reflection)Slightly slower (reflection)
Java interopKotlin-only classesWorks with Java + Kotlin
Null handlingKotlin null safety@JsonInclude, @JsonProperty
CustomCustom serializers@JsonDeserialize, mixins

Spring Boot auto-configures Jackson with the Kotlin module. It works out of the box:

build.gradle.kts
// Spring Boot already includes this
dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}

Basic usage — it just works with data classes:

data class Task(
val id: Long,
val title: String,
val status: TaskStatus = TaskStatus.OPEN,
val description: String? = null,
val createdAt: Instant = Instant.now(),
)
// Spring automatically serializes/deserializes this
@PostMapping("/api/tasks")
fun createTask(@RequestBody task: CreateTaskRequest): Task { /* ... */ }

Jackson annotations:

import com.fasterxml.jackson.annotation.*
data class UserResponse(
val id: Long,
val email: String,
@JsonIgnore // Never serialize password
val passwordHash: String,
@JsonProperty("full_name") // Custom JSON field name
val name: String,
@JsonInclude(JsonInclude.Include.NON_NULL) // Omit if null
val bio: String? = null,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")
val createdAt: Instant,
)

Custom ObjectMapper:

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class JacksonConfig {
@Bean
fun objectMapper(): ObjectMapper {
return jacksonObjectMapper().apply {
registerModule(JavaTimeModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
enable(SerializationFeature.INDENT_OUTPUT)
// Don't fail on unknown properties in requests
disable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
}
}
}

Polymorphic serialization with sealed classes:

import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
JsonSubTypes.Type(value = EmailNotification::class, name = "email"),
JsonSubTypes.Type(value = SlackNotification::class, name = "slack"),
JsonSubTypes.Type(value = WebhookNotification::class, name = "webhook"),
)
sealed class Notification {
abstract val message: String
}
data class EmailNotification(
override val message: String,
val to: String,
val subject: String,
) : Notification()
data class SlackNotification(
override val message: String,
val channel: String,
) : Notification()
data class WebhookNotification(
override val message: String,
val url: String,
) : Notification()
// JSON:
// {"type":"email","message":"Hello","to":"user@test.com","subject":"Hi"}
// {"type":"slack","message":"Hello","channel":"#general"}

Setup:

build.gradle.kts
plugins {
kotlin("plugin.serialization") version "2.1.0"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

Basic usage:

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
@Serializable
data class Task(
val id: Long,
val title: String,
val status: TaskStatus = TaskStatus.OPEN,
val description: String? = null,
)
// Serialize
val json = Json.encodeToString(Task(1, "Buy groceries"))
// → {"id":1,"title":"Buy groceries","status":"OPEN"}
// Deserialize
val task = Json.decodeFromString<Task>("""{"id":1,"title":"Buy groceries"}""")
// → Task(id=1, title="Buy groceries", status=OPEN, description=null)

Custom JSON configuration:

val json = Json {
prettyPrint = true
ignoreUnknownKeys = true // Don't fail on extra fields
encodeDefaults = false // Skip default values
isLenient = true // Allow unquoted strings, etc.
coerceInputValues = true // Coerce nulls to defaults
explicitNulls = false // Don't write "field": null
}

Custom field names and ignoring fields:

import kotlinx.serialization.SerialName
import kotlinx.serialization.Transient
@Serializable
data class UserResponse(
val id: Long,
val email: String,
@Transient // Never serialize
val passwordHash: String = "",
@SerialName("full_name") // Custom JSON key
val name: String,
)

Polymorphic serialization with sealed classes — compiler-supported:

import kotlinx.serialization.SerialName
@Serializable
sealed class Notification {
abstract val message: String
}
@Serializable
@SerialName("email")
data class EmailNotification(
override val message: String,
val to: String,
val subject: String,
) : Notification()
@Serializable
@SerialName("slack")
data class SlackNotification(
override val message: String,
val channel: String,
) : Notification()
// Serialize
val notification: Notification = EmailNotification("Hello", "a@b.com", "Hi")
val json = Json.encodeToString(notification)
// → {"type":"email","message":"Hello","to":"a@b.com","subject":"Hi"}
// Deserialize (polymorphic)
val decoded: Notification = Json.decodeFromString(json)
// → EmailNotification(message=Hello, to=a@b.com, subject=Hi)

Custom serializer:

import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): Instant {
return Instant.parse(decoder.decodeString())
}
}
@Serializable
data class Task(
val id: Long,
val title: String,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
)
build.gradle.kts
dependencies {
implementation("io.ktor:ktor-server-content-negotiation:3.0.3")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3")
}
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import kotlinx.serialization.json.Json
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = false
})
}
}

Comparison: TS / Go / Kotlin Serialization

Section titled “Comparison: TS / Go / Kotlin Serialization”
import { z } from "zod";
const TaskSchema = z.object({
id: z.number(),
title: z.string().min(1).max(200),
status: z.enum(["OPEN", "IN_PROGRESS", "DONE"]),
description: z.string().optional(),
});
type Task = z.infer<typeof TaskSchema>;
// Parse + validate
const task = TaskSchema.parse(JSON.parse(body));

Key Differences:

AspectTypeScriptGoKotlin (kotlinx)Kotlin (Jackson)
Null safetyZod .optional()*string, omitemptyString? built-in@JsonInclude
ValidationZod schemaManualManual or JSR 380JSR 380 (@Valid)
Custom namesN/A (JS convention)Struct tags@SerialName@JsonProperty
PolymorphismDiscriminated unionsInterface + manualSealed classes@JsonTypeInfo
PerformanceV8 JSON.parseReflectionCompiler pluginReflection

REST requires multiple requests for related data. GraphQL lets clients request exactly what they need in one query:

# Instead of: GET /tasks/1, GET /tasks/1/comments, GET /users/5
query {
task(id: 1) {
title
status
comments {
text
author {
name
}
}
}
}
TypeScriptGoKotlin
Schema-firstApollo ServergqlgenDGS Framework
Code-firstTypeGraphQL, Nexusgraphql-kotlin
Popular choiceApollo Servergqlgengraphql-kotlin

graphql-kotlin generates the GraphQL schema from your Kotlin code. No .graphql schema files to maintain.

Dependencies:

build.gradle.kts
dependencies {
implementation("com.expediagroup:graphql-kotlin-spring-boot-starter:8.2.1")
}

Configuration:

application.yml
graphql:
packages:
- "com.example.graphql"
playground:
enabled: true # GraphQL Playground at /playground
sdl:
enabled: true # Schema at /sdl
import com.expediagroup.graphql.server.operations.Query
import org.springframework.stereotype.Component
// Every @Component that implements Query becomes a root query field
@Component
class TaskQuery(
private val taskService: TaskService,
) : Query {
// Becomes: query { tasks { id, title, ... } }
fun tasks(
status: TaskStatus? = null,
limit: Int = 20,
offset: Int = 0,
): List<TaskDto> {
return taskService.findAll(status, limit, offset).map { it.toDto() }
}
// Becomes: query { task(id: 1) { id, title, ... } }
fun task(id: Long): TaskDto? {
return taskService.findById(id)?.toDto()
}
}
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
@GraphQLDescription("A task in the system")
data class TaskDto(
val id: Long,
val title: String,
val status: TaskStatus,
val description: String?,
val createdAt: String, // ISO 8601
@GraphQLIgnore // Not exposed in schema
val internalNotes: String? = null,
)
enum class TaskStatus {
OPEN,
IN_PROGRESS,
DONE,
CANCELLED,
}

The killer feature: resolve related data lazily with data loaders. A method on the type (e.g. assignee) becomes a field resolver that returns a CompletableFuture<UserDto?> — only called if the client actually requests that field.

import com.expediagroup.graphql.generator.annotations.GraphQLDescription
import graphql.schema.DataFetchingEnvironment
import java.util.concurrent.CompletableFuture
@GraphQLDescription("A task in the system")
data class TaskDto(
val id: Long,
val title: String,
val status: TaskStatus,
val assigneeId: Long?,
) {
// This becomes a field resolver — only called if client requests 'assignee'
fun assignee(env: DataFetchingEnvironment): CompletableFuture<UserDto?> {
if (assigneeId == null) return CompletableFuture.completedFuture(null)
// Use DataLoader to batch multiple user lookups
return env.getDataLoader<Long, UserDto>("userLoader")
.load(assigneeId)
}
// Resolve comments for this task
fun comments(env: DataFetchingEnvironment): CompletableFuture<List<CommentDto>> {
return env.getDataLoader<Long, List<CommentDto>>("commentsLoader")
.load(id)
}
}

DataLoader configuration (batch loading):

import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import graphql.GraphQLContext
import org.dataloader.DataLoaderFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class DataLoaderConfig(
private val userService: UserService,
private val commentService: CommentService,
) {
@Bean
fun dataLoaderRegistryFactory(): KotlinDataLoaderRegistryFactory {
return KotlinDataLoaderRegistryFactory(
object : KotlinDataLoader<Long, UserDto> {
override val dataLoaderName = "userLoader"
override fun getDataLoader(graphQLContext: GraphQLContext) =
DataLoaderFactory.newDataLoader<Long, UserDto> { ids ->
// Batch load all users at once instead of N+1 queries
CompletableFuture.supplyAsync {
val users = userService.findByIds(ids)
ids.map { id -> users.find { it.id == id } }
}
}
},
object : KotlinDataLoader<Long, List<CommentDto>> {
override val dataLoaderName = "commentsLoader"
override fun getDataLoader(graphQLContext: GraphQLContext) =
DataLoaderFactory.newDataLoader<Long, List<CommentDto>> { taskIds ->
CompletableFuture.supplyAsync {
val comments = commentService.findByTaskIds(taskIds)
taskIds.map { taskId ->
comments.filter { it.taskId == taskId }
}
}
}
},
)
}
}
import com.expediagroup.graphql.server.operations.Mutation
import org.springframework.stereotype.Component
@Component
class TaskMutation(
private val taskService: TaskService,
) : Mutation {
// mutation { createTask(input: { title: "New task" }) { id, title } }
fun createTask(input: CreateTaskInput): TaskDto {
return taskService.create(input.title, input.description, input.priority).toDto()
}
// mutation { updateTaskStatus(id: 1, status: DONE) { id, status } }
fun updateTaskStatus(id: Long, status: TaskStatus): TaskDto {
return taskService.updateStatus(id, status).toDto()
}
// mutation { deleteTask(id: 1) }
fun deleteTask(id: Long): Boolean {
taskService.delete(id)
return true
}
}
data class CreateTaskInput(
val title: String,
val description: String? = null,
val priority: Priority = Priority.MEDIUM,
)
import com.expediagroup.graphql.server.operations.Subscription
import kotlinx.coroutines.flow.Flow
import org.springframework.stereotype.Component
@Component
class TaskSubscription(
private val taskEventService: TaskEventService,
) : Subscription {
// subscription { taskUpdated(userId: "user-1") { id, title, status } }
fun taskUpdated(userId: String): Flow<TaskDto> {
return taskEventService.getTaskUpdatesForUser(userId)
}
}
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
import com.expediagroup.graphql.server.operations.Query
import graphql.schema.DataFetchingEnvironment
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
@Component
class TaskQuery(
private val taskService: TaskService,
) : Query {
@GraphQLDescription("Get tasks for the authenticated user")
fun myTasks(env: DataFetchingEnvironment): List<TaskDto> {
val authentication = SecurityContextHolder.getContext().authentication
?: throw GraphQLException("Not authenticated")
val userId = authentication.name
return taskService.getTasksForUser(userId).map { it.toDto() }
}
}

Schema-First Alternative: DGS Framework (Netflix)

Section titled “Schema-First Alternative: DGS Framework (Netflix)”

If you prefer schema-first (write .graphql files, then implement resolvers):

src/main/resources/schema/schema.graphqls
type Query {
tasks(status: TaskStatus): [Task!]!
task(id: ID!): Task
}
type Mutation {
createTask(input: CreateTaskInput!): Task!
}
type Task {
id: ID!
title: String!
status: TaskStatus!
description: String
assignee: User
comments: [Comment!]!
}
enum TaskStatus {
OPEN
IN_PROGRESS
DONE
}
input CreateTaskInput {
title: String!
description: String
}
// DGS data fetcher
import com.netflix.graphql.dgs.DgsComponent
import com.netflix.graphql.dgs.DgsQuery
import com.netflix.graphql.dgs.DgsMutation
import com.netflix.graphql.dgs.InputArgument
@DgsComponent
class TaskDataFetcher(
private val taskService: TaskService,
) {
@DgsQuery
fun tasks(@InputArgument status: TaskStatus?): List<Task> {
return taskService.findAll(status)
}
@DgsQuery
fun task(@InputArgument id: Long): Task? {
return taskService.findById(id)
}
@DgsMutation
fun createTask(@InputArgument input: CreateTaskInput): Task {
return taskService.create(input)
}
}

graphql-kotlin vs DGS vs Apollo Server vs gqlgen

Section titled “graphql-kotlin vs DGS vs Apollo Server vs gqlgen”
Featuregraphql-kotlinDGSApollo Server (TS)gqlgen (Go)
ApproachCode-firstSchema-firstBothSchema-first
Schema generationFrom Kotlin codeFrom .graphql filesFrom TS or .graphqlFrom .graphql
Spring integrationYesYes (Netflix OSS)N/A (Express)N/A (net/http)
DataLoaderBuilt-inBuilt-indataloader pkgdataloaden codegen
SubscriptionsKotlin FlowReactorPubSubChannels
FederationSupportedSupportedNativeSupported

gRPC uses Protocol Buffers (protobuf) for schema definition and binary serialization. It’s faster than JSON REST for service-to-service communication:

REST vs gRPC transport
Rendering diagram…
build.gradle.kts
import com.google.protobuf.gradle.*
plugins {
kotlin("jvm") version "2.1.0"
id("com.google.protobuf") version "0.9.4"
}
dependencies {
implementation("io.grpc:grpc-kotlin-stub:1.4.1")
implementation("io.grpc:grpc-protobuf:1.68.2")
implementation("io.grpc:grpc-netty-shaded:1.68.2")
implementation("com.google.protobuf:protobuf-kotlin:4.29.2")
// For coroutine support
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.29.2"
}
plugins {
id("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:1.68.2"
}
id("grpckt") {
artifact = "io.grpc:protoc-gen-grpc-kotlin:1.4.1:jdk8@jar"
}
}
generateProtoTasks {
all().forEach { task ->
task.plugins {
id("grpc")
id("grpckt")
}
task.builtins {
id("kotlin")
}
}
}
}
src/main/proto/task.proto
syntax = "proto3";
package taskapi;
option java_package = "com.example.grpc";
option java_multiple_files = true;
import "google/protobuf/timestamp.proto";
service TaskService {
rpc ListTasks (ListTasksRequest) returns (ListTasksResponse);
rpc GetTask (GetTaskRequest) returns (Task);
rpc CreateTask (CreateTaskRequest) returns (Task);
rpc UpdateTaskStatus (UpdateStatusRequest) returns (Task);
rpc DeleteTask (DeleteTaskRequest) returns (DeleteTaskResponse);
// Server streaming: get real-time task updates
rpc WatchTasks (WatchTasksRequest) returns (stream TaskEvent);
}
message Task {
int64 id = 1;
string title = 2;
string description = 3;
TaskStatus status = 4;
string assignee_id = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
enum TaskStatus {
TASK_STATUS_UNSPECIFIED = 0;
TASK_STATUS_OPEN = 1;
TASK_STATUS_IN_PROGRESS = 2;
TASK_STATUS_DONE = 3;
}
message ListTasksRequest {
TaskStatus status_filter = 1;
int32 page_size = 2;
string page_token = 3;
}
message ListTasksResponse {
repeated Task tasks = 1;
string next_page_token = 2;
int32 total_count = 3;
}
message GetTaskRequest {
int64 id = 1;
}
message CreateTaskRequest {
string title = 1;
string description = 2;
string assignee_id = 3;
}
message UpdateStatusRequest {
int64 id = 1;
TaskStatus status = 2;
}
message DeleteTaskRequest {
int64 id = 1;
}
message DeleteTaskResponse {
bool success = 1;
}
message WatchTasksRequest {
string user_id = 1;
}
message TaskEvent {
enum EventType {
EVENT_TYPE_UNSPECIFIED = 0;
CREATED = 1;
UPDATED = 2;
DELETED = 3;
}
EventType type = 1;
Task task = 2;
}
import com.example.grpc.*
import com.google.protobuf.Timestamp
import io.grpc.Status
import io.grpc.StatusException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.time.Instant
class TaskGrpcService(
private val taskRepository: TaskRepository,
) : TaskServiceGrpcKt.TaskServiceCoroutineImplBase() {
override suspend fun listTasks(request: ListTasksRequest): ListTasksResponse {
val tasks = taskRepository.findAll(
statusFilter = request.statusFilter.takeIf { it != TaskStatus.TASK_STATUS_UNSPECIFIED },
pageSize = request.pageSize.takeIf { it > 0 } ?: 20,
pageToken = request.pageToken.takeIf { it.isNotEmpty() },
)
return listTasksResponse {
this.tasks.addAll(tasks.items.map { it.toProto() })
nextPageToken = tasks.nextToken ?: ""
totalCount = tasks.total
}
}
override suspend fun getTask(request: GetTaskRequest): Task {
val task = taskRepository.findById(request.id)
?: throw StatusException(Status.NOT_FOUND.withDescription("Task ${request.id} not found"))
return task.toProto()
}
override suspend fun createTask(request: CreateTaskRequest): Task {
if (request.title.isBlank()) {
throw StatusException(Status.INVALID_ARGUMENT.withDescription("Title is required"))
}
val task = taskRepository.save(
TaskEntity(
title = request.title,
description = request.description,
assigneeId = request.assigneeId.takeIf { it.isNotEmpty() },
)
)
return task.toProto()
}
override suspend fun updateTaskStatus(request: UpdateStatusRequest): Task {
val task = taskRepository.findById(request.id)
?: throw StatusException(Status.NOT_FOUND.withDescription("Task ${request.id} not found"))
val updated = taskRepository.save(task.copy(status = request.status.toDomain()))
return updated.toProto()
}
override suspend fun deleteTask(request: DeleteTaskRequest): DeleteTaskResponse {
taskRepository.deleteById(request.id)
return deleteTaskResponse { success = true }
}
override fun watchTasks(request: WatchTasksRequest): Flow<TaskEvent> = flow {
// Emit task events as they happen (simplified — real impl would use a message queue)
taskRepository.watchChanges(request.userId).collect { event ->
emit(taskEvent {
type = event.type.toProto()
task = event.task.toProto()
})
}
}
private fun TaskEntity.toProto(): Task = task {
id = this@toProto.id
title = this@toProto.title
description = this@toProto.description ?: ""
status = this@toProto.status.toProto()
assigneeId = this@toProto.assigneeId ?: ""
createdAt = this@toProto.createdAt.toTimestamp()
updatedAt = this@toProto.updatedAt.toTimestamp()
}
private fun Instant.toTimestamp(): Timestamp = Timestamp.newBuilder()
.setSeconds(epochSecond)
.setNanos(nano)
.build()
}
import io.grpc.ServerBuilder
fun main() {
val taskRepository = InMemoryTaskRepository() // Or real DB
val server = ServerBuilder
.forPort(50051)
.addService(TaskGrpcService(taskRepository))
.build()
server.start()
println("gRPC server started on port 50051")
server.awaitTermination()
}
import com.example.grpc.*
import io.grpc.ManagedChannelBuilder
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val channel = ManagedChannelBuilder
.forAddress("localhost", 50051)
.usePlaintext() // No TLS for local dev
.build()
val client = TaskServiceGrpcKt.TaskServiceCoroutineStub(channel)
// Create a task
val created = client.createTask(createTaskRequest {
title = "Learn gRPC"
description = "Complete the gRPC module"
})
println("Created task: ${created.id} - ${created.title}")
// List tasks
val response = client.listTasks(listTasksRequest {
pageSize = 10
})
println("Tasks (${response.totalCount} total):")
response.tasksList.forEach { task ->
println(" ${task.id}: ${task.title} [${task.status}]")
}
// Watch for updates (server streaming)
client.watchTasks(watchTasksRequest {
userId = "user-1"
}).collect { event ->
println("Event: ${event.type} - ${event.task.title}")
}
channel.shutdown()
}

Use grpc-spring-boot-starter for Spring integration:

build.gradle.kts
dependencies {
implementation("net.devh:grpc-spring-boot-starter:3.1.0.RELEASE")
}
import net.devh.boot.grpc.server.service.GrpcService
@GrpcService
class TaskGrpcService(
private val taskService: TaskService, // Inject Spring beans
) : TaskServiceGrpcKt.TaskServiceCoroutineImplBase() {
// Same implementation as above, but with Spring DI
}
application.yml
grpc:
server:
port: 50051
// grpc-js: callback-based, less ergonomic
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
const packageDef = protoLoader.loadSync("task.proto");
const proto = grpc.loadPackageDefinition(packageDef);
const server = new grpc.Server();
server.addService(proto.taskapi.TaskService.service, {
listTasks: (call, callback) => {
// Implementation
callback(null, { tasks: [], totalCount: 0 });
},
});

Key Differences:

AspectTypeScriptGoKotlin
API styleCallbacksSync/goroutinesCoroutines + Flow
StreamingEventEmitterChannelsKotlin Flow
Code gengrpc-toolsprotoc-gen-go-grpcprotoc-gen-grpc-kotlin
Spring integrationN/AN/Agrpc-spring-boot-starter
Type safetyGenerated TS typesGenerated Go structsGenerated Kotlin classes

Content negotiation lets your API serve different formats based on the Accept header.

// Spring supports JSON, XML, etc. based on Accept header
// JSON is the default with Jackson on classpath
// Add XML support — build.gradle.kts
// implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
// Controller — no changes needed
@GetMapping(
"/api/tasks/{id}",
produces = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE],
)
fun getTask(@PathVariable id: Long): TaskResponse {
return taskService.findById(id).toResponse()
}
// Client sends Accept: application/json → JSON response
// Client sends Accept: application/xml → XML response

In Ktor, register each format on the ContentNegotiation plugin:

import io.ktor.serialization.kotlinx.json.*
import io.ktor.serialization.kotlinx.xml.*
import io.ktor.server.plugins.contentnegotiation.*
fun Application.configureSerialization() {
install(ContentNegotiation) {
json() // application/json
xml() // application/xml (needs ktor-serialization-kotlinx-xml)
}
}

REST vs GraphQL vs gRPC — When to Use What

Section titled “REST vs GraphQL vs gRPC — When to Use What”
FactorRESTGraphQLgRPC
Use casePublic APIs, CRUDFlexible queries, BFFService-to-service
FormatJSON over HTTP/1.1JSON over HTTP/1.1Protobuf over HTTP/2
SchemaOpenAPI (optional)GraphQL schema (required)Protobuf (required)
Over-fetchingCommonSolvedN/A (typed messages)
N+1 problemNot applicableDataLoader neededNot applicable
CachingHTTP caching easyHard (POST for queries)Manual
File uploadMultipartComplexStreaming
Browser supportNative fetchClient librarygrpc-web proxy
Learning curveLowMediumMedium-high
Best forExternal APIsMobile apps, SPAsMicroservices

Most production systems use a combination: REST for the public API, GraphQL for the BFF layer, and gRPC for internal service-to-service calls.

Combined REST / GraphQL / gRPC architecture
Rendering diagram…
  • REST for the public API (easy to consume, well-understood)
  • GraphQL for the BFF layer (frontend-optimized queries)
  • gRPC for internal service-to-service communication (fast, typed)
TopicTypeScriptGoKotlin/JVM
REST docsswagger-jsdoc, tsoaswaggo/swagspringdoc-openapi
JSONBuilt-in JSONencoding/jsonJackson / kotlinx.serialization
ValidationZod, class-validatorManualJSR 380 (@Valid)
GraphQLApollo Servergqlgengraphql-kotlin / DGS
gRPCgrpc-jsgrpc-gogrpc-kotlin
Type safetyTypeScript typesGo structsKotlin data classes
Code-first schemaTypeGraphQLN/Agraphql-kotlin
Schema-firstApollogqlgenDGS Framework

Key takeaways:

  • Use Jackson for Spring Boot, kotlinx.serialization for Ktor and multiplatform.
  • springdoc-openapi gives you Swagger UI with minimal annotation effort.
  • graphql-kotlin’s code-first approach maps naturally to Kotlin data classes.
  • gRPC with Kotlin coroutines and Flow is the most ergonomic gRPC experience.
  • Match the API style to the use case: REST for external, GraphQL for flexible queries, gRPC for internal.

Build real APIs in two different styles on top of the same task domain.