Part 3: Let's Build the App! πŸ“±πŸ’» GraphQL with Apollo Kotlin in Compose Multiplatform

Part 3: Let's Build the App! πŸ“±πŸ’»  GraphQL with Apollo Kotlin in Compose Multiplatform

Hey Again, API Adventurers! πŸ‘‹

Guess what? We're back for another exciting chapter in our YouTube Downloader saga!

Remember our awesome GraphQL server? Time to build the app that uses it! We'll set up Apollo Kotlin in our Compose Multiplatform project to fetch data like a pro.

In Part 1, we were like architects, drawing blueprints and building the foundations of our GraphQL server using Kotlin, Ktor, and graphql-kotlin. πŸ—οΈ
Then, in Part 2, we got our hands dirty, wiring up the server's brain 🧠 to actually understand YouTube links and prepare for downloads using yt-dlp.
Part 3 : The core client setup for Kotlin Multiplatform App Android and JVM using Apollo Kotlin.
Part 4 : Consuming Our Api using Apollo Kotlin.

Now for the really cool part: building the actual app that people will see and use! This is where our Compose Multiplatform skills shine, letting us create a beautiful UI for Android, Desktop, and even iOS. And to make our app talk to our GraphQL server? We've got a trusty sidekick: Apollo Kotlin! πŸ¦Έβ€β™‚οΈ

Think of Apollo Kotlin as a super-smart translator. Our server speaks "GraphQL," and our Kotlin app needs to understand it. Apollo helps make this conversation smooth, error-free, and even gives us pre-built Kotlin words (models) based on what our server can say. No more guessing games with messy JSON!

in Part 3, our mission is simple but super important:

  1. Roll out the red carpet for Apollo Kotlin: We'll add it to our Compose Multiplatform project.
  2. Give Apollo the "map" to our server: This is our server's schema, so Apollo knows what's possible.
  3. Write our app's first "GraphQL postcard": A simple "hello world" style query.
  4. Set up the "phone line": Configure the ApolloClient so we can actually send that postcard.
  5. Make the call! We'll send our query and see if the server replies. πŸŽ‰

No fancy UI yet – that's for Part 4! Today is all about making that first successful connection. It's like sending the first text message on a new phone!

What you'll need (just the basics!):

  • You've followed Parts 1 & 2, or your GraphQL server is up and running.
  • A little Kotlin know-how (if you know val and fun, you're set!).
  • You've seen a Compose Multiplatform project before.
  • Android Studio is open and ready.Let's get our app to say its first "Hello!" to the server!

Step 1: Getting Apollo Kotlin on Board (Adding Dependencies)

First things first, let's setup Apollo Kotlin into our composeApp project

We'll be working in our composeApp/build.gradle.kts file and the gradle/libs.versions.toml file. we going to setup libraries and pluglin for : Apollo-Kotlin, Ktor, Kotlinx-serialization,kotlinx-coroutines

a. The Version Catalog (gradle/libs.versions.toml)


[versions]
# ... your other versions
apollo-kotlin = "4.3.2"
ktor = "3.2.3"
kotlinx-coroutines = "1.10.2"
kotlinx-serialization-json = "1.9.0"

[libraries]
# ... your other libraries
apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo-kotlin" }
apollo-normalized-cache = { group = "com.apollographql.apollo", name = "apollo-normalized-cache", version.ref = "apollo-kotlin" }
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }

kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }




[plugins]
# ... your other plugins
apollo-kotlin = { id = "com.apollographql.apollo", version.ref = "apollo-kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

b. Now, let's tell our composeApp module to actually use these libraries (composeApp/build.gradle.kts)

// In composeApp/build.gradle.kts

plugins {
    // ... other plugins
    alias(libs.plugins.apollo.kotlin) // << Make sure this line is here!
    alias(libs.plugins.kotlin.serialization)
}

kotlin {
    androidTarget {
        // ... your android specific config
    }

    jvm() // For our desktop app

    sourceSets {
        commonMain.dependencies {
            // ... other common dependencies
            implementation(libs.apollo.runtime)        // Core Apollo
            implementation(libs.apollo.normalized.cache) // For caching
            implementation(libs.kotlinx.serialization.json)
            implementation(libs.ktor.client.core)
            implementation(libs.kotlinx.coroutines.core)
        }
        androidMain.dependencies {
            // ... other android dependencies
            implementation(libs.kotlinx.coroutines.android)
            implementation(libs.ktor.client.okhttp) // Ktor's OkHttp client for Android networking
        }
        jvmMain.dependencies {
            // ... other jvm dependencies
            implementation(libs.kotlinx.coroutinesSwing)
            implementation(libs.ktor.client.okhttp) // Ktor's OkHttp client for JVM networking
        }
        // ... commonTest, androidUnitTest etc.
    }
}

// ... your android {} block

// Right below your android {} block (or anywhere at the top level of the file), add this:
apollo {
    service("service") { // You can name this if you have multiple GraphQL backends
        packageName.set("com.droidslife.dcnytloader.graphql")
        introspection {
            endpointUrl.set("http://127.0.0.1:8082/graphql") // Your server's GraphQL endpoint
        }
        schemaFile.set(file("src/commonMain/graphql/schema.graphqls")) // Base package for generated files
    }

Action Time! 🎬

  1. Start your Ktor GraphQL Server (the one we built in Part 1 and 2). It needs to be running at http://127.0.0.1:8080/graphql.
  2. Create the directory: In your composeApp module, create the path src/commonMain/graphql/ if it doesn't already exist. This is where the schema.graphqls file will live.
  3. Sync Gradle (again!) or Build Project: This time, when Gradle syncs (or you build the project: Build > Make Project), the Apollo plugin will try to connect to your running server at the endpointUrl, download its schema, and save it as schema.graphqls in the src/commonMain/graphql/ directory.

If everything goes well, you should see a new schema.graphqls file appear! Open it up – it should look like a blueprint of all the type Query, type Mutation, type VideoInfo, etc., that you defined on your server. This is Apollo's map!

If it fails, double-check:
  • Is your server running?
  • Is the endpointUrl correct? (In Part 1, we set up graphQLSDLRoute() which often exposes the schema at /graphql/sdl, but your Apollo config points to /graphql. Make sure your server is exposing its Schema Definition Language (SDL) at the URL Apollo is trying to hit). Your introspection URL http://127.0.0.1:8082/graphql from build.gradle.kts in the context suggests your server might be at port 8082. Please verify the correct URL for your running server's GraphQL SDL endpoint. Let's assume for this guide it's http://127.0.0.1:8080/graphql as per your initial apollo block, but adjust if your server is elsewhere.
  • Are there any errors in the Gradle Build output in Android Studio?
Wait, my server has a different URL or port for the schema! No problem! Just update the endpointUrl.set("...") in your apollo block to the correct address.

Step 3: Writing Our App's First "GraphQL Postcard" (A Query)

Now that Apollo has the map (schema.graphqls), we can start writing "postcards" – our GraphQL queries! These are simple text files (ending in .gql or .graphql) where we ask the server for specific information.

Let's write a query to get the HelloWorldQuery, just like we tested in Part 1 with GraphiQL.

  1. Inside src/commonMain/graphql/, create a new directory structure that matches the packageName we set in the Apollo config. So, create com/droidslife/dcnytloader/graphql/.
  2. Inside this new directory, create a file named HelloWorld.graphql.

Now, put the following GraphQL query into HelloWorld.graphql

query HelloWorldQuery {
  helloWorld
}

Sync Gradle one more time!
Now for the magic ✨! When you sync Gradle (or build), the Apollo plugin will:

  1. Find our HelloWorldQuery.graphql file.
  2. Read the query.
  3. Look at the schema.graphqls (the map).
  4. Automatically generate Kotlin classes for us (like HelloWorldQuery.kt) in the com.droidslife.dcnytloader.graphql package (in the generated sources folder, which Android Studio understands).

These generated classes are type-safe, meaning if you mistype a field name in your .graphql file that doesn't exist in the schema, you'll get a build error! How cool is that? No more runtime surprises from typos in API calls!

Step 4: Setting Up the "Phone Line" (Configuring ApolloClient)

We have our postcard (HelloWorldQuery.graphql) and Apollo has generated the necessary Kotlin code. Now we need to set up the actual "phone line" – the ApolloClient – that will send this postcard to our server.

We'll use the YtdlApolloClient.kt file you already have, as it's a good starting point for managing the ApolloClient instance, especially with Koin for dependency injection.

Let's ensure it's configured correctly for Apollo Kotlin v4 and our project. Open composeApp/src/commonMain/kotlin/com/droidslife/dcnytloader/network/YtdlApolloClient.kt.

class YtdlApolloClient : KoinComponent {
    private val _clients = mutableMapOf<String, ApolloClient>()
    private val mutex = Mutex()

    suspend fun getClient(clientName: String = "default"): ApolloClient {
        return mutex.withLock {
            _clients.getOrPut(clientName) {
                apolloClient()
            }
        }
    }

    @OptIn(ApolloExperimental::class)
    private fun apolloClient(): ApolloClient {
        val networkMonitor = object : NetworkMonitor {
            override val isOnline: StateFlow<Boolean?>
                get() = MutableStateFlow(true)

            override fun close() {}
        }
        return get<ApolloClient.Builder>()
            .serverUrl("http://127.0.0.1:8082/graphql)
            .webSocketServerUrl("ws://127.0.0.1:8082/graphql)
            .retryOnErrorInterceptor(RetryOnErrorInterceptor(networkMonitor))
            .fetchPolicy(FetchPolicy.NetworkOnly)
            .doNotStore(true)
            .autoPersistedQueries(enableByDefault = false)
            .wsProtocol(
                GraphQLWsProtocol.Factory()
            )
            .webSocketReopenWhen { throwable, attempt ->

                if (throwable is WebSocketReconnectException) {
                    true
                } else {
                    delay(attempt * 5000)
                    attempt < 10
                }
            }
            .build()
    }

    fun close() {
        _clients.values.forEach {
            it.close()
        }
        _clients.clear()
    }

    fun clear() {
        _clients.values.forEach {
            it.apolloStore.clearAll()
            it.close()
        }
        _clients.clear()
    }
}

class WebSocketReconnectException : Exception("The WebSocket needs to be reopened")

YtdlApolloClient.kt

What's happening here (the important bits for now)? 🧐
  • createApolloClient(): This is the heart of it.
  • HttpNetworkTransport: This is set up for our regular queries and mutations. It points to your server's BASE_URL.
  • WebSocketNetworkTransport: This is for real-time updates using Subscriptions (which we'll use for download progress in Part 4!). It points to BASE_URL_WS.
  • MemoryCacheFactory: We're setting up a simple in-memory cache. This means if we ask for the same data again quickly, Apollo might give it to us from memory instead of asking the server again. Super fast!
Note: Subscription will not work without SSL so host your server and use a domain with SSL instead of localhost.

Setup platform spacific ApolloClient.Builder

// commonMain
expect fun platformModule(): Module

//androidMain
actual fun platformModule() =
    module {
        factory {
            ApolloClient.Builder()
        }
    }
//jvmMain
actual fun platformModule() =
    module {
        factory {
            ApolloClient.Builder()
        }
    }
Note : Here we use KoinComponent and Koin Module for DI which is part of Koin libraries we will not cover Koin setup in this article if you want to know How to setup Koin in you Kotlin Multiplatform App visit this Linkedin post.

Step 5: Make the Call! (Sending our First Query) πŸŽ‰

Let's try to fetch the Hello World! We'll do this within the HelloWorldServiceImpl since that's its job – to interact with the network layer.

interface HelloWorldService {
    suspend fun helloWorld(): Result<String>
}


class HelloWorldServiceImpl(
    val client: YtdlApolloClient,
) : HelloWorldService {
    override suspend fun helloWorld(): Result<String> {
        val result = client.getClient().query(HelloWorldQuery()).execute()

        if (result.data == null && !result.errors.isNullOrEmpty()) {
            return Result.failure(
                Throwable(result.errors?.firstOrNull()?.message),
            )
        }

        return if (result.data != null) {
            Result.success(result.data!!.helloWorld)
        } else {
            Result.failure(
                Throwable(result.errors?.firstOrNull()?.message ?: "Unknown error"),
            )
        }
    }
}

A Quick Test (No UI Yet!)
To see if this works, you can temporarily add a call in your App.kt or a MainViewModel (if you have one that Koin provides and is initialized early).
For example, in your MainViewModel (which is already set up with Koin):

class MainViewModel(
    private val helloWorldService: HelloWorldService
) : ViewModel() {

    init {
        helloWorld()
    }

      private fun helloWorld() {
        viewModelScope.launch {
            helloWorldService
                .helloWorld()
                .onSuccess {
                    println("SUCCESS: $it.")
                }.onFailure {
                    println("Error: ${it.message}")
                }
        }
    }
}

Before you run:

  1. Ensure your Ktor GraphQL server is running!
  2. Your App.kt should be setting up Koin, so that MainViewModel and its dependencies are correctly initialized and injected.

Now, run your Android or Desktop app!

Check the Logcat (for Android) or the Run console output (for JVM/Desktop). You should see:

  • "SUCCESS: Hello World".
  • "Error: ..." if something went wrong.

If you see "SUCCESS!", give yourself a massive pat on the back! πŸŽ‰

You've just made your Compose Multiplatform app talk to your Ktor GraphQL server using Apollo Kotlin, fetching data in a type-safe way!

If it failed, don't worry! Common culprits:

  • Server not running or URL incorrect.
  • Mistake in the .graphql query file (field name doesn't match schema).
  • Network issues between your client and server.

Phew! What a Step! πŸš€


That was a big one, but incredibly important! We've laid all the crucial groundwork for our app's communication with the backend.

Here's a recap of our achievements in Part 3:
  • βœ… Welcomed Apollo Kotlin: Added all necessary dependencies and the Apollo Gradle plugin.
  • βœ… Shared the Server's Map: Configured Apollo to get our server's schema.graphqls, either by asking the server (introspection) or using a local file.
  • βœ… Wrote our First GraphQL Postcard: Created HelloWorld.graphql to ask for data. Apollo magically turned this into type-safe Kotlin code!
  • βœ… Set Up the Phone Line: Configured our YtdlApolloClient with Koin to handle HTTP requests and WebSocket connections, including caching.
  • βœ… Made the First Successful Call: HelloWorldServiceImpl used our Apollo-generated query and tested fetching data from the server!

You now have a robust, type-safe, and Kotlin-idiomatic way to interact with your GraphQL API from your shared Kotlin Multiplatform code. This setup will make adding more queries and mutations a breeze.

Get Ready for Part 4: Bringing It All to Life!

This was the "under the hood" plumbing. In Part 4, we'll take all this amazing setup and:

  • Build the Compose Multiplatform UI to display the video information and download history.
  • Implement fetching video details when a user pastes a YouTube URL.
  • Wire up the "Download" button to send a mutation using Apollo Kotlin.
  • Use GraphQL subscriptions to show real-time download progress.
  • Handle loading states, errors, and display the data beautifully.

It's going to be where all our hard work from Parts 1, 2, and 3 comes together in a visible, interactive application!


πŸ“‚ Full Project Code on GitHub!

Want to dive deeper into the code or run this project yourself? I've made the complete source code for this available on GitHub!

You'll find all the setup, service logic, GraphQL schema definitions, and configurations we've discussed in these articles. It's a great way to see how all the pieces fit together and to use it as a starting point for your own amazing GraphQL projects.

➑️ Check out the repository here: DCNYtLoader
Feel free to fork it, star it if you find it helpful, and explore!


πŸ’¬ Did you hit any snags? Or did it work like a charm? Share your experiences in the comments below! Your questions and successes help everyone learn. Subscribe & Share if this guided you well would be awesome!