1. Overview

We find a rising need for swift, efficient data retrieval and manipulation in today’s development ecosystem. GraphQL allows clients to define the structure of the data they require, and the server then returns exactly that data.

Employing GraphQL with Kotlin, we could use GraphQL Java, but we wouldn’t completely take advantage of its features. Expedia Group comes with a great solution for this, in the form of GraphQL Kotlin, which is built on its Java counterpart. Compared to the alternative, it allows us to seamlessly integrate coroutines and have our code as the point of truth for the GraphQL schema.

For this article, we’ll stick to serving content over HTTP and JSON while also delving a little into WebSockets. Since WebSockets are less known than their REST counterpart, we should make sure we’re familiar with that protocol as well.

2. Server Side

For this article, we’ll use Ktor and create a basic Gradle project using the Ktor Generator. As explained in the Ktor article, we can run the server with the following command:

./gradlew runServer

For the sake of example, we’ll base our logic on an application for managing conferences. For this, we’ll create the following data classes:

data class Conference(override var id: Int?, var name: String, var attendees: List<Int>)
data class Attendee(override var id: Int?, var name: String)

To help us illustrate some features of the library, attendees are referenced by their numeric id in the conference objects.

2.1. The Schema

The first thing to address when developing a GraphQL API is the schema. GraphQL Kotlin uses the Kotlin code as the point of truth for this, and generating it is very straightforward.

We’ll start by adding the dependency for this:

implementation("com.expediagroup", "graphql-kotlin-ktor-server", expediaGroupVersion)

Once this is done, we need to install the GraphQL module on our application:

fun main() {
    embeddedServer(Netty, port = 8080, module = Application::graphQLModule).start(wait = true)
}

fun Application.graphQLModule() {
    install(GraphQL) {
        schema {
            packages = listOf("com.baeldung.graphql.server")
        }
    }
}

We can generate the schema using a Gradle task called graphqlGenerateSDL. This simply needs the packages to scan, similar to our module installation. We can add this to the build script:

graphql {
    schema {
        packages = listOf("com.baeldung.graphql.server")
    }
}

However, the generation for this will fail as no queries are defined, but we’ll fix that in the next step. An endpoint for the schema will also be created by default.

When using graphqlGenerateSDL together with a Subscription that uses the Flow class, a custom generator hook will be required. As a workaround, we can fetch the schema via the introspection URL or generate it and print it.

The generated schema can also be Apollo Compliant, an architecture for building a unified GraphQL API from multiple services.

2.2. Queries

To define a GraphQL query, it’s sufficient to implement the Query class and define our API there. Let’s say we have an endpoint to fetch a Conference by its id:

class ConferenceQuery : Query {
    fun conferenceById(id: Int): Conference? {
        return ConferenceRepository.findById(id)
    }
}

All we have to do is provide a ConferenceQuery object to the module configuration:

install(GraphQL) {
    schema {
        packages = listOf("com.baeldung.graphql.server")
        queries = listOf(ConferenceQuery())
    }
}

It will generate the following:

type Attendee {
    id: Int
    name: String!
}

type Conference {
    attendees: [Int!]!
    id: Int
    name: String!
}

type Query {
    conferenceById(id: Int!): Conference
}

As we can see from the schema, nullable types are also carried over. The attendees are a list of id‘s, so let’s extend Conference with a method for fetching all attendees.

We’ll also allow the user to set a limit on how many attendees to return:

data class Conference(override var id: Int?, var name: String, var attendees: List<Int>) {

    fun attendeeObjects(limit: Int?): List<Attendee> {
        val attendeeObjects = attendees.mapNotNull { AttendeeRepository.findById(it) }
        if (limit == null) {
            return attendeeObjects
        }
        return attendeeObjects.take(limit)
    }
}

The resulting schema will then be:

type Conference {
    attendeeObjects(limit: Int): [Attendee!]!
    attendees: [Int!]!
    id: Int
    name: String!
}

2.3. Mutations

Mutations are just as easy to add with the Kotlin GraphQL library. We implement the Mutation interface and then simply add our functionality. Let’s add mutations for both creating and updating conferences and attendees:

class ConferenceMutation : Mutation {
    fun saveOrCreateConference(conference: Conference): Conference {
        return ConferenceRepository.save(conference)
    }
}
class AttendeeMutation : Mutation {
    fun saveOrCreateAttendee(attendee: Attendee): Attendee {
        return AttendeeRepository.save(attendee)
    }
}

We also add it to the GraphQL Ktor module:

schema {
    packages = listOf("com.baeldung.graphql.server")
    queries = listOf(ConferenceQuery())
    mutations = listOf(ConferenceMutation(), AttendeeMutation())
}

Now, if we check the schema, we’ll see something interesting:

type Mutation {
    saveOrCreateAttendee(attendee: AttendeeInput!): Attendee!
    saveOrCreateConference(conference: ConferenceInput!): Conference!
}

For the inputs of the mutations, two new types were added:

input AttendeeInput {
    id: Int
    name: String!
}
input ConferenceInput {
    attendees: [Int!]!
    id: Int
    name: String!
}

The Kotlin GraphQL library automatically generates input types. The only difference between these and the types generated for queries is that the input types don’t have any functions.

This is great because it makes it easier to change the API in the future, introducing a layer of abstraction. With this, changing the output does not necessarily impact the input and vice-versa.

2.4. Unions

Unions are a great feature in GraphQL; support for this can be achieved in two ways. Let’s add an ObjectWithId interface, and let’s have the Conference and Attendee implement it:

interface ObjectWithIdUnion
data class Attendee(var id: Int?, var name: String): ObjectWithIdUnion
data class Conference(var id: Int?, var name: String, var attendees: List<Int>): ObjectWithIdUnion

We’ll want to add an id field to the interface, but for now, let’s see how this behaves.

If we don’t use the newly created interface, this won’t be added to the schema, so let’s also add a query for it:

fun objectById(id: Int): ObjectWithIdUnion? {
    // Some object business logic
}

The schema will now contain a union type:

union ObjectWithIdUnion = Attendee | Conference 
objectById(id: Int!): ObjectWithIdUnion

If the interface has no fields or functions, it’s turned into a union type by default.

This works fine, but it won’t work if we want to implement external classes from other dependencies. A good example would be a standardized external API repository. For that, we can also use the GraphQLUnion annotation and create a new annotation:

@GraphQLUnion(
    name = "CustomUnion",
    possibleTypes = [Conference::class, Attendee::class],
    description = "Can be either conference or attendee"
)
annotation class OurCustomUnion

The downside here is that the methods annotated with it have to return Any:

@OurCustomUnion
fun objectById(id: Int): Any? { // Some object business logic }

2.5. Subscriptions

GraphQL subscriptions are where coroutines start shining. They allow for great asynchronous code while also ensuring readability. Let’s start by enabling them in Ktor. We’ll also add a ping duration of one and Jackson as a serializer:

install(WebSockets) {
    pingPeriod = Duration.ofSeconds(1)
    contentConverter = JacksonWebsocketContentConverter()
}

We also need to modify our routing to allow this:

install(Routing) {
    graphQLPostRoute()
    graphQLSubscriptionsRoute()
}

GraphQL Kotlin facilitates subscriptions through Spring WebFlux. We can also simply return a Flow instance instead of a Publisher to better integrate with Kotlin.

Let’s add a simple Conference event manager:

object ConferencePublisher {

    private val mutableConferenceFlow: MutableSharedFlow<Conference> = MutableSharedFlow(replay = 0)

    // Readonly
    val conferenceFlow = mutableConferenceFlow.asSharedFlow()

    fun publishConference(conference: Conference) {
        mutableConferenceFlow.tryEmit(conference)
    }
}

We can then call the publish method from our Conference save functionality:

fun save(obj: Conference): Conference {
    // ...
    ConferencePublisher.publishConference(conference)
    // ...
}

Let’s create our subscription by implementing the Subscription interface. We’ll add a subscription for getting the id of changed or created Conference objects:

class ConferenceSubscription : Subscription {
    @GraphQLDescription("Emits single, newly created conferences")
    fun conferenceId(): Flow<Int> = ConferencePublisher.conferenceFlow.map { it.id!! }
}

Now we just add it to our subscriptions parameter:

install(GraphQL) {
    schema {
        packages = listOf("com.baeldung.graphql.server")
        queries = listOf(ConferenceQuery())
        mutations = listOf(ConferenceMutation(), AttendeeMutation())
        subscriptions = listOf(ConferenceSubscription())
    }
}

And the resulting schema:

type Subscription {
    "Emits single, newly created conferences"
    conferenceId: Int!
}

3. Client Side

The GraphQL Kotlin client library provides a type-safe way of executing GraphQL operations. It searches for GraphQL files that contain operations such as mutations and queries and generates classes based on them.

We’ll start by adding the following plugin in the Gradle build file:

id("com.expediagroup.graphql") version "7.0.1"

This will allow us to configure the generation. We can set an SDL Endpoint, a Schema File, or an introspection endpoint for the initial schema.

With the previously created server, we could configure it to fetch the introspection query:

graphql {
    client {
        endpoint = "http://localhost:8080/graphql"
        packageName = "com.baeldung.graphql.client.generated"
    }
}

The configuration also sets the package in which our operations will be generated. For configuring it with an SDL Endpoint, we would simply replace the endpoint variable:

sdlEndpoint = "http://localhost:8080/sdl"

And for using a schema file, for example, placed in the resources directory:

schemaFile = file("src/main/resources/schema.graphql")

This configuration will not generate any classes for now, as the generation also requires operations to be defined.

We need to also choose a serializer. By default, KotlinX is used, but the polymorphism support isn’t the best, so we’ll use Jackson.

For this, we’ll add the Jackson Expedia Group dependency, and add it to the schema generation:

implementation("com.expediagroup", "graphql-kotlin-client-jackson", expediaGroupVersion)
//...
graphql {
    client {
        serializer = GraphQLSerializer.JACKSON
        //...
    }
}

As for the client implementation, GraphQL Kotlin comes with two clients out-of-the-box for Ktor and Spring. We can also customize it for other use cases. Let’s add our Ktor client dependency:

implementation("com.expediagroup", "graphql-kotlin-ktor-client", expediaGroupVersion)

Now, we can create a client with the Jackson serializer and start performing type-safe operations:

val client = GraphQLKtorClient(url = GRAPHQL_URL, serializer = GraphQLClientJacksonSerializer())

3.1. Performing Queries

As mentioned earlier, we have to define our operations. Let’s start by creating a new file for our conference query conference_by_id.graphql and place it in the resources folder:

query ConferenceByIdQuery($id: Int!, $attendeeLimit: Int!) {
    conferenceById(id: $id) {
        id
        name
        attendees
        attendeeObjects(limit: $attendeeLimit) {
            id
            name
        }
    }
}

To generate the classes, we can build the project or just run the generation command:

./gradlew graphqlGenerateClient

The generated ConferenceByIdQuery should be in the build folder, and we can use it to make our request:

suspend fun findConferenceById(id: Int, attendeeLimit: Int = 0): Conference? {
    val resp = client.execute(
        ConferenceByIdQuery(ConferenceByIdQuery.Variables(id, attendeeLimit))
    )
    return resp.data?.conferenceById
}

Besides the ConferenceByIdQuery class, a Variables and a Result class have been generated in the same folder. They simply hold the input defined and the result, which, in our case, is a Conference object.

3.2. Performing Mutations

Performing a mutation is done in a similar matter. We start by creating our operation:

mutation SaveOrCreateConferenceMutation($id: Int, $name: String!, $attendees: [Int!]!) {
    saveOrCreateConference(conference: {
        id: $id
        name: $name,
        attendees: $attendees
    }) {
        id
        name
        attendees
    }
}

This will then generate a class for this mutation, which we can call from our createOrSaveConference function:

suspend fun createOrSaveConference(id: Int?, name: String, attendees: List<Int> = listOf()): 
  com.baeldung.graphql.client.generated.saveorcreateconferencemutation.Conference? {
    val resp = client.execute(
        SaveOrCreateConferenceMutation(SaveOrCreateConferenceMutation.Variables(id, name, attendees))
    )
    return resp.data?.saveOrCreateConference
}

We’ve specified different fetched properties from the query (which also gets the attendees). That being the case, there’s no way for the library to connect this to our other generated Conference class. This will result in the generator creating different classes with small differences, both called Conference but placed in separate packages.

3.3. Polymorphism

Interfaces and unions are both supported through the client generation. We can check this with our object objectById query:

query ObjectByIdQuery($id: Int!, $attendeeLimit: Int!) {
    objectById(id: $id) {
        __typename
        id
        ... on Conference {
            name
            attendees
            attendeeObjects(limit: $attendeeLimit) {
                id
                name
            }
        }
        ... on Attendee {
            name
        }
    }
}

This will then create an ObjectWithId interface, which is implemented by both the generated Conference and the Attendee classes:

public interface ObjectWithId {
  public val id: Int?
}

All we have to do now is perform the query, and Jackson will map the result to the ObjectWithId interface:

suspend fun getObjectById(id: Int, attendeeLimit: Int = 0): ObjectWithId? {
    val resp = client.execute(
        ObjectByIdQuery(ObjectByIdQuery.Variables(id, attendeeLimit))
    )
    return resp.data?.objectById
}

3.4. Generation Bug

At the time of writing this article, a bug exists that causes the generation of multiple classes regardless of their properties. This means that, even if we have two operations that return the exact same type, multiple classes will be generated.

While this only affects the client side, it can become quite cumbersome to have to use full-package identifiers. The only viable workaround would be having a conversion layer, transforming generated classes to a single interface (defined or generated).

We can also have multiple of the same classes generated for the same operation. For example, the ObjectByIdQuery operation will create two Attendee classes named Attendee and Attendee2.

3.5. Batching

While a powerful language, GraphQL introduces a lot of overhead in operations. To remove some of that overhead, the client supports batching. It expects an array of operations and executes them successively.

This doesn’t reduce the server’s overhead, but it still improves performance as sending data can also be costly:

suspend fun getConferenceBatch(firstId: Int, secondId: Int, attendeeLimit: Int = 0): List<Conference> {
    return client.execute(
      listOf(
        ConferenceByIdQuery(ConferenceByIdQuery.Variables(firstId, attendeeLimit)),
        ConferenceByIdQuery(ConferenceByIdQuery.Variables(secondId, attendeeLimit))
      ))
      .mapNotNull { it.data }
      .map { it as ConferenceByIdQuery.Result }
      .mapNotNull { it.conferenceById }
}

While it is a nice performance improvement, we do trade some readability for it. The types returned by the client aren’t explicitly stated, so we have to cast the objects manually. This allows performing multiple types of operations in the same batch since we aren’t bound by a single return type.

3.6. Subscriptions

For now, WebSockets are not supported by the client. The feature has been pending in a GitHub Issue without a clear due date. Until this is implemented, a good workaround is using the Ktor client, which allows WebSocket subscriptions and regular API calls.

4. Conclusion

Throughout this article, we’ve covered various features, from simple queries and mutations to advanced ones like subscriptions and unions. On the client side, we’ve seen how it leverages strong typing and simplifies the development process.

We should keep in mind that there are challenges and nuances to navigate, and support can be reduced at times. This was best seen when encountering missing features and known bugs, which the small team didn’t have the time for.

The overall benefits outweigh these and enable an efficient development process fully utilizing Kotlin functionalities. 

As always, the code can be found over on GitHub.