GraphQL API with graphql-kotlin
Build a GraphQL API for a task-management system using
graphql-kotlin (Expedia’s
code-first library). Instead of hand-writing an SDL schema and wiring resolvers to
it, you write ordinary Kotlin classes and let the library generate the schema
from your code — the inverse of the schema-first workflow you might know from
Apollo Server in TypeScript or gqlgen in Go.
What you’ll build
Section titled “What you’ll build”- A
Queryclass exposingtasks(paginated, filterable) andtask(id). - A
Mutationclass forcreateTask,updateTaskStatus, anddeleteTask. - Kotlin
data classes andenums that map automatically to GraphQL object, input, and enum types. DataLoaders registered to batch nested lookups (users, comments) and avoid the N+1 query problem.
The mental model coming from TS/Go:
- Schema-first (Apollo / gqlgen): you write the
.graphqlSDL, then implement resolvers that must match it. The schema is the source of truth. - Code-first (graphql-kotlin): your Kotlin types are the source of truth. A
public function on a
Querycomponent becomes a root field; its parameters become field arguments; its return type becomes the field type. The SDL is generated for you and served at/sdl.
The worked solution
Section titled “The worked solution”A single Spring Boot module. graphql-kotlin’s Spring Boot starter auto-discovers
any @Component that implements Query or Mutation in the configured packages
and assembles the schema at startup.
Directorygraphql-api/
- build.gradle.kts deps + build config
- settings.gradle.kts project name
Directorysrc/main/
Directorykotlin/com/example/graphql/
- GraphqlApiApplication.kt Spring Boot entrypoint
Directorymodel/
- Models.kt data classes, enums, input types
Directoryquery/
- TaskQuery.kt root query operations
Directorymutation/
- TaskMutation.kt root mutation operations
Directoryservice/
- TaskService.kt in-memory task, user, comment stores
Directorydataloader/
- DataLoaderConfig.kt batched loaders for nested fields
Directoryresources/
- application.yml graphql packages, playground, sdl
Directorysrc/test/kotlin/com/example/graphql/
- GraphqlApiTest.kt MockMvc tests over the /graphql endpoint
build.gradle.kts
Section titled “build.gradle.kts”The whole GraphQL stack arrives through one starter dependency:
graphql-kotlin-spring-boot-starter. It pulls in the schema generator, the HTTP
endpoint, the playground, and the SDL route. The kotlin("plugin.spring") plugin
makes Spring’s component classes open so they can be proxied.
plugins { kotlin("jvm") version "2.1.0" kotlin("plugin.spring") version "2.1.0" id("org.springframework.boot") version "3.4.1" id("io.spring.dependency-management") version "1.1.7"}
group = "com.example"version = "1.0.0"
java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) }}
repositories { mavenCentral()}
dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// GraphQL - graphql-kotlin (Expedia) implementation("com.expediagroup:graphql-kotlin-spring-boot-starter:8.2.1")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")}
tasks.withType<Test> { useJUnitPlatform()}The starter is configured entirely from application.yml — most importantly the
graphql.packages list, which tells the generator where to scan for Query,
Mutation, and schema types:
spring: application: name: graphql-api-demo
graphql: packages: - "com.example.graphql" playground: enabled: true sdl: enabled: true introspection: enabled: true
server: port: 8080Models.kt — Kotlin types become GraphQL types
Section titled “Models.kt — Kotlin types become GraphQL types”This is where code-first really pays off. Each data class maps to a GraphQL
object type, each enum class to a GraphQL enum, and a class used only as a
function parameter (CreateTaskInput) becomes a GraphQL input type. Nullability
crosses over directly: a non-null Kotlin field is a Type! in the schema; a
nullable String? becomes a nullable String.
A few annotations steer the generator:
@GraphQLDescription("…")becomes the doc string in the generated SDL and shows up in the playground.@GraphQLIgnorehides a property from the schema entirely — hereinternalUserIdstays a server-side field and never appears in the API.
package com.example.graphql.model
import com.expediagroup.graphql.generator.annotations.GraphQLDescriptionimport com.expediagroup.graphql.generator.annotations.GraphQLIgnoreimport java.time.Instant
enum class TaskStatus { OPEN, IN_PROGRESS, DONE, CANCELLED,}
enum class Priority { LOW, MEDIUM, HIGH,}
@GraphQLDescription("A task in the system")data class TaskDto( val id: Long, val title: String, val description: String?, val status: TaskStatus, val priority: Priority, val assigneeId: Long?, val createdAt: String,
@GraphQLIgnore val internalUserId: String? = null,)
@GraphQLDescription("A user in the system")data class UserDto( val id: Long, val name: String, val email: String,)
@GraphQLDescription("A comment on a task")data class CommentDto( val id: Long, val taskId: Long, val text: String, val authorId: Long, val createdAt: String,)
data class CreateTaskInput( val title: String, val description: String? = null, val priority: Priority = Priority.MEDIUM, val assigneeId: Long? = null,)TaskQuery.kt — root query fields
Section titled “TaskQuery.kt — root query fields”A @Component implementing the marker interface Query is the entry point for
read operations. Every public function on it becomes a root field. Function
parameters become field arguments, and Kotlin defaults (limit: Int = 20) become
optional arguments with defaults. The return type drives the field type:
List<TaskDto> becomes [Task!]!, and the nullable TaskDto? on task(id)
becomes a nullable Task.
package com.example.graphql.query
import com.example.graphql.model.TaskDtoimport com.example.graphql.model.TaskStatusimport com.example.graphql.service.TaskServiceimport com.expediagroup.graphql.generator.annotations.GraphQLDescriptionimport com.expediagroup.graphql.server.operations.Queryimport org.springframework.stereotype.Component
@Componentclass TaskQuery( private val taskService: TaskService,) : Query {
@GraphQLDescription("Get a paginated list of tasks, optionally filtered by status") fun tasks( status: TaskStatus? = null, limit: Int = 20, offset: Int = 0, ): List<TaskDto> { return taskService.findAll(status, limit, offset) }
@GraphQLDescription("Get a single task by its ID") fun task(id: Long): TaskDto? { return taskService.findById(id) }}TaskMutation.kt — root mutation fields
Section titled “TaskMutation.kt — root mutation fields”Identical pattern, different marker interface: implement Mutation. createTask
takes the CreateTaskInput type (which the generator exposes as a GraphQL
input), and deleteTask returns a plain Boolean.
package com.example.graphql.mutation
import com.example.graphql.model.CreateTaskInputimport com.example.graphql.model.TaskDtoimport com.example.graphql.model.TaskStatusimport com.example.graphql.service.TaskServiceimport com.expediagroup.graphql.generator.annotations.GraphQLDescriptionimport com.expediagroup.graphql.server.operations.Mutationimport org.springframework.stereotype.Component
@Componentclass TaskMutation( private val taskService: TaskService,) : Mutation {
@GraphQLDescription("Create a new task") fun createTask(input: CreateTaskInput): TaskDto { return taskService.create(input) }
@GraphQLDescription("Update the status of an existing task") fun updateTaskStatus(id: Long, status: TaskStatus): TaskDto? { return taskService.updateStatus(id, status) }
@GraphQLDescription("Delete a task by ID. Returns true if the task was deleted.") fun deleteTask(id: Long): Boolean { return taskService.delete(id) }}TaskService.kt — the backing stores
Section titled “TaskService.kt — the backing stores”The data layer is deliberately boring: in-memory ConcurrentHashMap and listOf
stores so the focus stays on GraphQL. Note the three Spring @Service beans —
TaskService, UserService, and CommentService — that the queries, mutations,
and DataLoaders inject. UserService.findByIds and CommentService.findByTaskIds
exist specifically so a DataLoader can fetch many keys in one call.
package com.example.graphql.service
import com.example.graphql.model.*import org.springframework.stereotype.Serviceimport java.time.Instantimport java.util.concurrent.ConcurrentHashMapimport java.util.concurrent.atomic.AtomicLong
@Serviceclass TaskService { private val tasks = ConcurrentHashMap<Long, TaskDto>() private val idCounter = AtomicLong(0)
init { create(CreateTaskInput("Learn Kotlin", "Complete the basics", Priority.HIGH, 1)) create(CreateTaskInput("Build GraphQL API", "Use graphql-kotlin", Priority.MEDIUM, 2)) create(CreateTaskInput("Write tests", "Unit and integration", Priority.LOW, 1)) }
fun findAll(status: TaskStatus? = null, limit: Int = 20, offset: Int = 0): List<TaskDto> { return tasks.values .filter { status == null || it.status == status } .sortedByDescending { it.createdAt } .drop(offset) .take(limit) }
fun findById(id: Long): TaskDto? = tasks[id]
fun create(input: CreateTaskInput): TaskDto { val id = idCounter.incrementAndGet() val task = TaskDto( id = id, title = input.title, description = input.description, status = TaskStatus.OPEN, priority = input.priority, assigneeId = input.assigneeId, createdAt = Instant.now().toString(), ) tasks[id] = task return task }
fun updateStatus(id: Long, status: TaskStatus): TaskDto? { val existing = tasks[id] ?: return null val updated = existing.copy(status = status) tasks[id] = updated return updated }
fun delete(id: Long): Boolean = tasks.remove(id) != null}
@Serviceclass UserService { private val users = mapOf( 1L to UserDto(1, "Alice Johnson", "alice@example.com"), 2L to UserDto(2, "Bob Smith", "bob@example.com"), 3L to UserDto(3, "Charlie Brown", "charlie@example.com"), )
fun findById(id: Long): UserDto? = users[id]
fun findByIds(ids: List<Long>): List<UserDto> = ids.mapNotNull { users[it] }}
@Serviceclass CommentService { private val comments = listOf( CommentDto(1, 1, "Great progress on this!", 2, Instant.now().toString()), CommentDto(2, 1, "Let me know if you need help", 3, Instant.now().toString()), CommentDto(3, 2, "Started working on this", 1, Instant.now().toString()), )
fun findByTaskId(taskId: Long): List<CommentDto> = comments.filter { it.taskId == taskId }
fun findByTaskIds(taskIds: List<Long>): List<CommentDto> = comments.filter { it.taskId in taskIds }}DataLoaderConfig.kt — batching nested lookups
Section titled “DataLoaderConfig.kt — batching nested lookups”The classic GraphQL trap is the N+1 query: resolving assignee for a list of
50 tasks fires 50 separate user lookups. A DataLoader fixes this by collecting
all the keys requested during one execution and dispatching them in a single batch
call, then handing each caller back its slice.
graphql-kotlin wires this up through a KotlinDataLoaderRegistryFactory bean. Each
loader implements KotlinDataLoader<K, V> with a dataLoaderName and a batch
function (keys) -> CompletableFuture<List<V>>. The contract is strict: the
returned list must be the same size and order as the input keys.
package com.example.graphql.dataloader
import com.example.graphql.model.CommentDtoimport com.example.graphql.model.UserDtoimport com.example.graphql.service.CommentServiceimport com.example.graphql.service.UserServiceimport com.expediagroup.graphql.dataloader.KotlinDataLoaderimport com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactoryimport graphql.GraphQLContextimport org.dataloader.DataLoaderFactoryimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport java.util.concurrent.CompletableFuture
@Configurationclass DataLoaderConfig( private val userService: UserService, private val commentService: CommentService,) { @Bean fun dataLoaderRegistryFactory(): KotlinDataLoaderRegistryFactory { return KotlinDataLoaderRegistryFactory( userDataLoader(), commentsDataLoader(), ) }
private fun userDataLoader() = object : KotlinDataLoader<Long, UserDto?> { override val dataLoaderName = "userLoader"
override fun getDataLoader(graphQLContext: GraphQLContext) = DataLoaderFactory.newDataLoader<Long, UserDto?> { ids -> CompletableFuture.supplyAsync { val users = userService.findByIds(ids) ids.map { id -> users.find { it.id == id } } } } }
private fun commentsDataLoader() = object : KotlinDataLoader<Long, List<CommentDto>> { override val dataLoaderName = "commentsLoader"
override fun getDataLoader(graphQLContext: GraphQLContext) = DataLoaderFactory.newDataLoader<Long, List<CommentDto>> { taskIds -> CompletableFuture.supplyAsync { val allComments = commentService.findByTaskIds(taskIds) taskIds.map { taskId -> allComments.filter { it.taskId == taskId } } } } }}A few details worth pausing on:
- The
userLoaderis keyedLong -> UserDto?and re-orders the service’s result back to match the requestedids, returningnullfor any id with no user. - The
commentsLoaderis keyedLong -> List<CommentDto>(one task can have many comments). It fetches all comments for the batch oftaskIdsonce, then partitions them per task — one DB-style call instead of one per task. - Both wrap the work in
CompletableFuture.supplyAsync, which is how the underlyingjava-dataloaderlibrary expects a batch result.
A nested resolver field then asks for its loader by name and returns the
CompletableFuture — graphql-kotlin waits for all loaders to be dispatched before
resolving, so the batches actually coalesce.
Querying the API
Section titled “Querying the API”Once running, the schema is fully introspectable. Some example operations:
List all tasks:
{ tasks { id title status priority }}Get one task with more detail:
{ task(id: 1) { id title description status createdAt }}Filter and paginate — note the enum value OPEN is unquoted, the GraphQL way:
{ tasks(status: OPEN, limit: 10) { id title status }}Create a task with an input object (priority defaults to MEDIUM if omitted):
mutation { createTask(input: { title: "Learn GraphQL" description: "Complete the graphql-kotlin exercise" priority: HIGH }) { id title status priority }}Update status and delete:
mutation { updateTaskStatus(id: 1, status: DONE) { id title status }}mutation { deleteTask(id: 1)}Run it
Section titled “Run it”-
Start the server:
Terminal window ./gradlew bootRun -
Open the interactive playground in a browser and run the example queries above:
http://localhost:8080/playground -
Inspect the generated schema (SDL) — proof that the code is the schema:
http://localhost:8080/sdl
Test it
Section titled “Test it”The tests use Spring’s MockMvc to POST GraphQL documents to /graphql and assert
over the JSON response with jsonPath, the same way you’d integration-test a REST
endpoint.
-
Run the suite:
Terminal window ./gradlew test -
The tests cover querying all tasks, fetching one by id, filtering by status, creating a task via mutation, and deleting a non-existent task (expecting
false).
@SpringBootTest@AutoConfigureMockMvcclass GraphqlApiTest {
@Autowired lateinit var mockMvc: MockMvc
@Test fun `query all tasks`() { val query = """ { "query": "{ tasks { id title status priority } }" } """.trimIndent()
mockMvc.post("/graphql") { contentType = MediaType.APPLICATION_JSON content = query }.andExpect { status { isOk() } jsonPath("$.data.tasks") { isArray() } jsonPath("$.data.tasks[0].id") { isNotEmpty() } jsonPath("$.data.tasks[0].title") { isNotEmpty() } } }
// … create / filter / delete / sdl tests follow the same shape}