Complete GraphQL Guide

January 01, 9999

Table of Contents


Chapter 1: What is GraphQL?

1.1 — The Big Picture

Imagine you are ordering food at a restaurant. With a traditional approach, you get a fixed meal — maybe a burger, fries, and a drink — whether you wanted the fries or not. You have no control over what comes on the plate. That is how REST APIs work: you call an endpoint, and the server decides exactly what data to send you back.

Now imagine a restaurant where you write your own order from scratch. You say: "I want the burger, hold the fries, add extra pickles, and give me a milkshake instead of water." The kitchen gives you exactly what you asked for — nothing more, nothing less. That is GraphQL.

GraphQL (Graph Query Language) is a query language for APIs invented by Facebook in 2012 and open-sourced in 2015. Instead of the server deciding what shape the data takes, the client decides. You write a query describing exactly the data you need, send it to a single endpoint, and receive a JSON response that mirrors the shape of your query.

1.2 — The Three Pillars of GraphQL

Queries — Reading Data

A query is how you ask a GraphQL server for data. Think of it as a GET request, but far more flexible. Here is a simple query that asks for a country's name and capital:

query {
  country(code: "CA") {
    name
    capital
  }
}

And the server responds with:

{
  "data": {
    "country": {
      "name": "Canada",
      "capital": "Ottawa"
    }
  }
}

Notice how the response shape mirrors the query shape exactly. This is one of GraphQL's most powerful features — you always know what you are going to get back.

Mutations — Writing Data

Mutations are how you create, update, or delete data. They are like POST, PUT, or DELETE requests in REST. The Countries API we are using is read-only, so we will not use mutations in this tutorial, but here is what one looks like conceptually:

mutation {
  createUser(name: "Alice", email: "alice@example.com") {
    id
    name
  }
}

Subscriptions — Real-Time Data

Subscriptions let you listen for real-time updates over a WebSocket connection. When the data on the server changes, your client gets notified automatically. We will not use subscriptions in this tutorial, but they are a powerful third pillar of GraphQL.

1.3 — Key Vocabulary

Term What It Means Analogy
Schema The contract defining all available types and fields A restaurant menu
Query A request for specific data Your food order
Mutation A request to change data Asking the chef to modify a dish
Resolver Server function that fetches data for a field The chef cooking your order
Type A shape of data (like a Swift struct) A category on the menu
Field A single piece of data on a type An item on the menu
Argument A parameter passed to a field "Extra pickles, no onion"
Fragment A reusable set of fields A combo meal you can add to any order

Chapter 2: GraphQL vs REST — A Detailed Comparison

If you have ever built an iOS app, you have almost certainly used REST APIs. Understanding how GraphQL differs from REST will help you appreciate why GraphQL exists and when to use it.

2.1 — The Core Differences

Endpoints

With REST, each resource typically lives at its own URL. You might have /api/countries, /api/countries/CA, /api/countries/CA/languages, and so on. Each endpoint returns a fixed structure.

With GraphQL, there is one single endpoint (for example, https://countries.trevorblades.com). Every request goes to the same URL. The query you send in the request body determines what data you get back.

Over-Fetching and Under-Fetching

Over-fetching means getting more data than you need. A REST endpoint for a country might return 30 fields when you only need the name and capital. You are downloading and parsing data you will never use, wasting bandwidth and battery.

Under-fetching means not getting enough data in one request. To show a country with its languages and continent name, you might need three separate REST calls: one for the country, one for its languages, and one for the continent. Each call adds latency.

GraphQL solves both problems. You specify exactly what fields you need, and you get everything in a single request.

2.2 — Side-by-Side Comparison

Feature REST GraphQL
Endpoints Many (one per resource) One (single endpoint)
Data shape Decided by server Decided by client
Over-fetching Common problem Eliminated by design
Under-fetching Requires multiple calls Single query gets all data
Versioning Often /v1/, /v2/ Schema evolves, no versioning needed
Tooling Postman, curl GraphQL Playground, Apollo Studio
Type safety Requires Swagger/OpenAPI Built into the schema
Learning curve Lower (familiar) Moderate (new syntax)
Caching HTTP caching is standard Requires a smart client (Apollo)

2.3 — When to Use GraphQL

GraphQL is not a universal replacement for REST. It excels in specific scenarios:

  • Mobile apps that need to minimize data transfer over cellular networks
  • Apps that display data from many related types on one screen
  • Rapidly evolving APIs where frontend and backend teams move independently
  • Situations where multiple client platforms (iOS, Android, web) need different data shapes

REST remains a great choice for simple CRUD apps, file uploads, and scenarios where HTTP caching is critical.


Chapter 3: Understanding the Countries GraphQL API

For this tutorial, we will use a free, public, no-authentication-required GraphQL API that provides information about countries, continents, and languages. This makes it perfect for learning — no API keys, no sign-up, no rate limits to worry about.

3.1 — The Endpoint

The API lives at a single URL: https://countries.trevorblades.com. Every GraphQL request you send will go to this one address.

3.2 — The Schema — What Data Is Available?

The Countries API exposes these main root query fields:

  • countries — Returns a list of all countries. Each country has fields like code, name, native name, capital, emoji flag, currency, languages, continent, states, and more.
  • country(code: ID!) — Returns a single country by its two-letter ISO code (like "CA" for Canada, "US" for the United States, "JP" for Japan).
  • continents — Returns all seven continents, each with a list of their countries.
  • continent(code: ID!) — Returns a single continent by code (like "NA" for North America, "EU" for Europe).
  • languages — Returns all languages in the dataset.
  • language(code: ID!) — Returns a single language by code (like "en" for English).

3.3 — The Country Type — A Closer Look

Field Type Description
code ID! Two-letter ISO country code (e.g., CA)
name String! English name (e.g., Canada)
native String! Native name (e.g., Canada)
phone String! Phone dialing code (e.g., 1)
capital String Capital city (nullable for some territories)
currency String Currency code (e.g., CAD)
emoji String! Country flag emoji
emojiU String! Unicode representation of the emoji
continent Continent! The continent this country belongs to
languages [Language!]! Array of spoken languages
states [State!]! Array of states or provinces

3.4 — Trying It in the Playground

Before writing any Swift code, you should explore the API in the interactive playground. Open your browser and go to https://countries.trevorblades.com. You will see a GraphQL playground interface where you can write and execute queries.

Try pasting this query into the left panel and clicking the Play button:

query {
  country(code: "CA") {
    name
    native
    capital
    emoji
    currency
    languages {
      name
    }
    continent {
      name
    }
  }
}

Try It Yourself!
Spend a few minutes in the playground. Try querying for your own country, list all continents, or filter countries by currency. The more comfortable you are writing queries here, the easier the Swift code will be.


Chapter 4: Setting Up Your Xcode Project

4.1 — Prerequisites

Before you begin, make sure you have the following:

  • A Mac running macOS 14 (Sonoma) or later
  • Xcode 15.4 or later (download free from the Mac App Store)
  • Basic familiarity with Swift (variables, functions, structs)
  • An internet connection (to download the schema and make API calls)

4.2 — Create a New Project

  1. Open Xcode and select Create New Project from the welcome screen (or go to File → New → Project).
  2. Under the iOS tab, select App and click Next.
  3. Fill in the project details:
    • Product Name: CountryExplorer
    • Team: Your personal team (or None for now)
    • Organization Identifier: com.yourname (e.g., com.johndoe)
    • Interface: SwiftUI
    • Language: Swift
  4. Click Next, choose a location to save your project, and click Create.

Why SwiftUI?
SwiftUI is Apple's modern declarative UI framework. It pairs beautifully with GraphQL because both are declarative — you describe what you want, not how to get it. SwiftUI also makes it very easy to react to data changes from network calls.

4.3 — Project Structure

Your project should now have a basic structure. We will be adding these files throughout the tutorial:

CountryExplorer/
├── CountryExplorerApp.swift      ← App entry point
├── ContentView.swift             ← Main view (we will modify this)
├── Network/
│   └── ApolloClient+Shared.swift ← Apollo client setup
├── GraphQL/
│   ├── schema.graphqls           ← Downloaded schema
│   ├── AllCountries.graphql      ← Our query files
│   ├── CountryDetail.graphql
│   └── ContinentsList.graphql
├── Views/
│   ├── CountryListView.swift
│   ├── CountryDetailView.swift
│   └── ContinentView.swift
└── CountriesAPI/                 ← Generated code (by Apollo)
    ├── Schema/
    └── Operations/

Don't create all these folders yet — we will add them step by step as we progress through each chapter.


Chapter 5: Installing Apollo iOS via Swift Package Manager

5.1 — What is Apollo iOS?

Apollo iOS is the industry-leading GraphQL client for Apple platforms. It is an open-source Swift library that handles everything you need to work with GraphQL:

  • Sending queries and mutations to a GraphQL server
  • Parsing JSON responses into type-safe Swift models (no manual Codable conformance!)
  • Caching responses locally so your app feels fast
  • Generating Swift code from your GraphQL operations

5.2 — Adding the Package to Your Xcode Project

We will use Swift Package Manager (SPM), which is Apple's built-in dependency manager and the recommended way to install Apollo iOS.

  1. In Xcode, go to File → Add Package Dependencies...
  2. In the search bar at the top right, paste this URL:
https://github.com/apollographql/apollo-ios.git
  1. Xcode will find the apollo-ios package. For Dependency Rule, select Up to Next Major Version and ensure the version starts at 2.0.0 or later.
  2. Click Add Package. Xcode will download and resolve the package.
  3. On the next screen, you will see a list of library products. Select only Apollo and make sure it is added to your CountryExplorer target.

Important Warning!
Do not select the apollo-ios-cli product. That is the command-line tool for code generation, and if you link it to your app target, it will cause build errors. Only select the Apollo library.

  1. Click Add Package to finish.

5.3 — Verify Installation

Open your ContentView.swift file and add this import at the top:

import Apollo

Build your project (Cmd + B). If it builds successfully with no errors, Apollo is installed correctly.

5.4 — Installing the Codegen CLI

Apollo iOS includes a powerful code generation command-line tool (CLI). This tool reads your GraphQL schema and query files and generates type-safe Swift code. We need to install it into our project.

  1. In Xcode's Project Navigator (left sidebar), right-click on your project name (the top-level blue icon, not a file).
  2. In the context menu, look for a plugin called Install CLI (it might appear under a submenu). Click it.
  3. A dialog will ask you to grant write access to your project directory. Click Allow.
  4. Wait for Xcode to build the CLI. This may take a minute or two the first time.

After the plugin finishes, a file called apollo-ios-cli will appear in your project's root directory. This is a symbolic link to the compiled CLI binary.

5.5 — Verify the CLI

Open Terminal, navigate to your project directory, and run:

cd /path/to/your/CountryExplorer
./apollo-ios-cli --version

You should see a version number like 2.x.x printed. If you see an error, try cleaning your Xcode Derived Data (Xcode → Settings → Locations → click the arrow next to Derived Data) and re-running the Install CLI plugin.


Chapter 6: Downloading the GraphQL Schema

6.1 — What is a Schema and Why Do We Need It?

A GraphQL schema is like a blueprint of the entire API. It defines every type, every field, every argument, and every relationship available. Think of it as a comprehensive dictionary that describes all the data the server can provide.

Apollo iOS needs a local copy of this schema to do its magic. During code generation, Apollo reads the schema to understand the types available, then looks at your .graphql query files to see what data you are requesting, and finally generates Swift structs that match exactly. Without the schema, Apollo would not know what types or fields are valid.

6.2 — Initialize the Codegen Configuration

Before we can download the schema, we need to create a configuration file that tells the CLI how to work. Open Terminal in your project root directory and run:

./apollo-ios-cli init \
  --schema-namespace CountriesAPI \
  --module-type embeddedInTarget \
  --target-name CountryExplorer

Let's break down each flag:

  • --schema-namespace CountriesAPI — This is the name of the Swift namespace (enum) that will contain all generated types. You will write things like CountriesAPI.AllCountriesQuery in your code.
  • --module-type embeddedInTarget — This tells Apollo to generate code that is embedded directly in your app target (as opposed to a separate Swift package). This is the simplest option for single-target projects.
  • --target-name CountryExplorer — The name of the Xcode target where the generated code will live.

This command creates a file called apollo-codegen-config.json in your project root. This is the central configuration file that controls all code generation behavior.

6.3 — Configure Schema Download

Open apollo-codegen-config.json in a text editor. We need to add a schemaDownload section so the CLI knows where to fetch the schema from. Add this to the top level of the JSON (as a sibling of schemaNamespace, input, and output):

"schemaDownload": {
  "downloadMethod": {
    "introspection": {
      "endpointURL": "https://countries.trevorblades.com",
      "httpMethod": {
        "POST": {}
      }
    }
  },
  "outputPath": "./graphql/schema.graphqls"
}

Also update the input section to point to our graphql folder:

"input": {
  "operationSearchPaths": ["./graphql/**/*.graphql"],
  "schemaSearchPaths": ["./graphql/**/*.graphqls"]
}

6.4 — Download the Schema

Now create the graphql directory and run the download:

mkdir -p graphql
./apollo-ios-cli fetch-schema

If successful, you should see a file at graphql/schema.graphqls. Open it and you will see the entire Countries API schema written in GraphQL Schema Definition Language (SDL). You will see type definitions like:

type Country {
  code: ID!
  name: String!
  native: String!
  phone: String!
  continent: Continent!
  capital: String
  currency: String
  languages: [Language!]!
  emoji: String!
  emojiU: String!
  states: [State!]!
}

What does the ! mean?
In GraphQL, an exclamation mark after a type means the field is non-null — the server guarantees it will always return a value. String! means a guaranteed string, while String (without !) means the value could be null. Notice that capital is nullable — some territories don't have a capital city.


Chapter 7: Writing Your First GraphQL Query

7.1 — Understanding .graphql Files

Apollo iOS looks for files with the .graphql extension. These files contain your GraphQL operation definitions — the queries, mutations, and fragments your app will use. Each operation you define in a .graphql file gets turned into a Swift class or struct during code generation.

7.2 — Create the AllCountries Query

In the graphql/ folder, create a new file called AllCountries.graphql. Paste in this query:

query AllCountries {
  countries {
    code
    name
    emoji
    capital
    currency
    continent {
      name
    }
  }
}

Let's break down every part of this query:

  • query — This keyword tells GraphQL we are reading data (not writing).
  • AllCountries — This is the operation name. Apollo will generate a Swift type called AllCountriesQuery from this name.
  • countries — This is a root field on the Query type. It returns an array of Country objects.
  • code, name, emoji, capital, currency — These are scalar fields we want from each country.
  • continent { name } — This is a nested object. We are saying: for each country, also fetch its continent, but only get the continent's name.

7.3 — Create the CountryDetail Query

Create another file in the graphql/ folder called CountryDetail.graphql:

query CountryDetail($code: ID!) {
  country(code: $code) {
    code
    name
    native
    capital
    emoji
    currency
    phone
    continent {
      name
    }
    languages {
      code
      name
      native
    }
    states {
      name
    }
  }
}

This query introduces something new — a variable:

  • $code: ID! — This declares a variable called code of type ID! (a non-nullable identifier). The dollar sign ($) marks it as a variable.
  • country(code: $code) — We pass the variable as an argument to the country field. This lets us query any country by passing different codes.

7.4 — Create the ContinentsList Query

Create one more file: ContinentsList.graphql:

query ContinentsList {
  continents {
    code
    name
    countries {
      code
      name
      emoji
    }
  }
}

This query demonstrates the power of GraphQL — we are fetching all continents and their countries in a single request. With REST, this would typically require eight separate API calls (one for continents, then one for each continent's countries).


Chapter 8: Running Code Generation

8.1 — What Code Generation Does

Code generation is the step where Apollo reads your schema (.graphqls file) and your query files (.graphql files) and produces Swift source code (.graphql.swift files). This generated code includes:

  • Swift structs for every query/mutation you defined
  • Nested data models matching the exact shape of each query's response
  • Type-safe enums for GraphQL enum types
  • Input object types for query arguments
  • Schema metadata used internally by the Apollo cache

The beauty of this approach is that you never have to manually define Codable structs for your network responses. If the schema says a field is a String!, the generated Swift property will be a String (not optional). If the schema says a field is String (nullable), it will be String? in Swift.

8.2 — Run the Generator

Open Terminal in your project root directory and run:

./apollo-ios-cli generate

If everything is configured correctly, you will see output indicating which files were generated. A new directory called CountriesAPI/ will appear in your project root with two subdirectories:

  • Sources/Schema/ — Contains generated types for your schema (enums, input types, schema metadata).
  • Sources/Operations/Queries/ — Contains generated Swift code for each of your .graphql query files.

8.3 — Add Generated Files to Xcode

If you are using an Xcode project (not a Package.swift), you need to manually add the generated files:

  1. In Xcode, right-click on your CountryExplorer folder in the Project Navigator.
  2. Select Add Files to "CountryExplorer"...
  3. Navigate to the CountriesAPI/ folder in your project root.
  4. Select the entire CountriesAPI folder and click Add. Make sure Copy items if needed is unchecked (we want references, not copies) and the CountryExplorer target is checked.

8.4 — Explore the Generated Code

Open AllCountriesQuery.graphql.swift. You will see something like this (simplified):

// @generated
// This file was automatically generated and should not be edited.
class AllCountriesQuery: GraphQLQuery {
  static let operationName: String = "AllCountries"
  static let operationDocument: DocumentType = .notPersisted(
    definition: .init("query AllCountries { ... }")
  )

  struct Data: SelectionSet {
    let __data: DataDict
    var countries: [Country] { __data["countries"] }

    struct Country: SelectionSet {
      let __data: DataDict
      var code: String { __data["code"] }
      var name: String { __data["name"] }
      var emoji: String { __data["emoji"] }
      var capital: String? { __data["capital"] }
      var currency: String? { __data["currency"] }
      var continent: Continent { __data["continent"] }

      struct Continent: SelectionSet {
        var name: String { __data["name"] }
      }
    }
  }
}

Never Edit Generated Files!
Files with the .graphql.swift extension are auto-generated. Any changes you make will be overwritten the next time you run code generation. If you want to change the shape of data, modify your .graphql query files and re-run the generator.


Chapter 9: Building the Network Layer

9.1 — What is ApolloClient?

The ApolloClient is the central object in Apollo iOS. It is responsible for sending your GraphQL operations to the server, receiving the response, parsing it into your generated models, and caching the results. You typically create one shared instance and use it throughout your app.

9.2 — Create the Shared Client

In your Xcode project, create a new Swift file called Network.swift (inside the CountryExplorer folder). Add this code:

import Foundation
import Apollo

class Network {
  static let shared = Network()

  private(set) lazy var apollo: ApolloClient = {
    let url = URL(string: "https://countries.trevorblades.com")!
    return ApolloClient(url: url)
  }()
}

Let's examine each piece:

  • static let shared = Network() — This creates a singleton. A singleton ensures there is only one instance of Network in the entire app, so every view shares the same Apollo client and cache.
  • private(set) lazy var apollo — The lazy keyword means the ApolloClient is only created when it is first accessed, not at app launch. private(set) means other code can read the apollo property but cannot replace it.
  • ApolloClient(url: url) — This creates an ApolloClient with default settings: it uses URLSession for networking, HTTP POST for requests, and an in-memory normalized cache for caching responses.

9.3 — Understanding the Defaults

When you create an ApolloClient with just a URL, Apollo sets up sensible defaults behind the scenes:

  • Transport: Uses RequestChainNetworkTransport with URLSession. Every query is sent as an HTTP POST with the GraphQL operation in the request body.
  • Cache: Uses InMemoryNormalizedCache. This stores response data in memory, organized by object identity. If two different queries fetch the same country, the cache stores it only once.
  • Request format: Sends JSON with keys "query" (the GraphQL string), "variables" (any arguments), and "operationName".

9.4 — How a Request Flows

When you call apollo.fetch(query:), here is what happens step by step:

  1. Apollo serializes your query into a JSON request body.
  2. The request is sent via HTTP POST to the endpoint URL.
  3. The server processes the query and returns a JSON response.
  4. Apollo parses the JSON into your generated Swift data model.
  5. The parsed data is stored in the normalized cache.
  6. Your completion handler (or async result) receives the type-safe data.

Chapter 10: Fetching Data & Displaying in SwiftUI

10.1 — Create a ViewModel

Create a new Swift file called CountryListViewModel.swift:

import Foundation
import Apollo

@MainActor
class CountryListViewModel: ObservableObject {
  @Published var countries: [AllCountriesQuery.Data.Country] = []
  @Published var isLoading = false
  @Published var error: String?

  func fetchCountries() {
    isLoading = true
    error = nil

    Network.shared.apollo.fetch(
      query: AllCountriesQuery(),
      cachePolicy: .fetchIgnoringCacheData
    ) { [weak self] result in
      DispatchQueue.main.async {
        self?.isLoading = false

        switch result {
        case .success(let graphQLResult):
          if let countries = graphQLResult.data?.countries {
            self?.countries = countries
          }
          if let errors = graphQLResult.errors {
            self?.error = errors
              .map { $0.localizedDescription }
              .joined(separator: "\n")
          }
        case .failure(let networkError):
          self?.error = networkError.localizedDescription
        }
      }
    }
  }
}

Key concepts in this code:

  • @MainActor — Ensures all property changes happen on the main thread (required for UI updates).
  • @Published — SwiftUI will automatically re-render views when these properties change.
  • AllCountriesQuery() — This is the generated Swift class from our AllCountries.graphql file.
  • [weak self] — Prevents memory leaks by avoiding a strong reference cycle in the closure.
  • graphQLResult.data?.countries — The response data, already parsed into type-safe Swift structs.
  • graphQLResult.errors — GraphQL responses can include both data and errors simultaneously.

10.2 — Build the Country List View

Create CountryListView.swift:

import SwiftUI

struct CountryListView: View {
  @StateObject private var viewModel = CountryListViewModel()
  @State private var searchText = ""

  var filteredCountries: [AllCountriesQuery.Data.Country] {
    if searchText.isEmpty {
      return viewModel.countries
    }

    return viewModel.countries.filter {
      $0.name.localizedCaseInsensitiveContains(searchText)
    }
  }

  var body: some View {
    NavigationStack {
      Group {
        if viewModel.isLoading {
          ProgressView("Loading countries...")
        } else if let error = viewModel.error {
          VStack(spacing: 16) {
            Image(systemName: "exclamationmark.triangle")
              .font(.largeTitle)
              .foregroundStyle(.red)

            Text(error)
              .multilineTextAlignment(.center)

            Button("Retry") {
              viewModel.fetchCountries()
            }
          }
          .padding()
        } else {
          List(filteredCountries, id: \.code) { country in
            NavigationLink {
              CountryDetailView(countryCode: country.code)
            } label: {
              HStack(spacing: 12) {
                Text(country.emoji)
                  .font(.largeTitle)

                VStack(alignment: .leading) {
                  Text(country.name)
                    .font(.headline)

                  Text(country.continent.name)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                }

                Spacer()

                if let currency = country.currency {
                  Text(currency)
                    .font(.caption)
                    .padding(.horizontal, 8)
                    .padding(.vertical, 4)
                    .background(.blue.opacity(0.1))
                    .clipShape(.capsule)
                }
              }
            }
          }
          .searchable(text: $searchText, prompt: "Search")
        }
      }
      .navigationTitle("Countries")
      .task { viewModel.fetchCountries() }
    }
  }
}

10.3 — Build the Country Detail View

Create CountryDetailView.swift:

import SwiftUI

struct CountryDetailView: View {
  let countryCode: String

  @State private var country: CountryDetailQuery.Data.Country?
  @State private var isLoading = true

  var body: some View {
    Group {
      if isLoading {
        ProgressView()
      } else if let country {
        List {
          Section {
            Text(country.emoji)
              .font(.system(size: 80))
              .frame(maxWidth: .infinity)
          }

          Section("General") {
            row("Native Name", country.native)
            row("Capital", country.capital ?? "N/A")
            row("Continent", country.continent.name)
            row("Phone Code", "+\(country.phone)")
            row("Currency", country.currency ?? "N/A")
          }

          Section("Languages") {
            ForEach(country.languages, id: \.code) { lang in
              HStack {
                Text(lang.name)
                Spacer()
                Text(lang.native)
                  .foregroundStyle(.secondary)
              }
            }
          }

          if !country.states.isEmpty {
            Section("States / Provinces (\(country.states.count))") {
              ForEach(country.states, id: \.name) {
                Text($0.name)
              }
            }
          }
        }
      }
    }
    .navigationTitle(country?.name ?? "Loading...")
    .task { await loadCountry() }
  }

  private func row(_ title: String, _ value: String) -> some View {
    HStack {
      Text(title).foregroundStyle(.secondary)
      Spacer()
      Text(value)
    }
  }

  private func loadCountry() async {
    Network.shared.apollo.fetch(
      query: CountryDetailQuery(code: countryCode)
    ) { result in
      DispatchQueue.main.async {
        isLoading = false
        if case .success(let res) = result {
          country = res.data?.country
        }
      }
    }
  }
}

10.4 — Wire Up the App

Update your ContentView.swift (or your app's main entry point) to show the CountryListView:

import SwiftUI

struct ContentView: View {
  var body: some View {
    CountryListView()
  }
}

Build and run your app (Cmd + R). You should see a list of all countries with their flags, names, continents, and currencies. Tapping a country navigates to a detail screen showing languages, states, and more — all powered by GraphQL.


Chapter 11: Using Variables and Arguments

11.1 — What Are Variables?

In GraphQL, variables let you parameterize your queries. Instead of hardcoding values into the query string, you define named placeholders that are filled in at runtime. This is critical for building dynamic apps where the user's actions determine what data to fetch.

We already used a variable in our CountryDetail query:

query CountryDetail($code: ID!) {
  country(code: $code) {
    name
    ...
  }
}

In Swift, Apollo makes this incredibly clean. The generated CountryDetailQuery class has an initializer that requires the code parameter:

// This is how you use it in Swift:
let query = CountryDetailQuery(code: "CA")

// Or with a variable:
let userSelectedCode = "JP"
let query = CountryDetailQuery(code: userSelectedCode)

11.2 — The Filter Argument

The Countries API supports an optional filter argument on its root query fields. Let's create a query that filters countries by continent. Create a new file called FilteredCountries.graphql:

query FilteredCountries($filter: CountryFilterInput) {
  countries(filter: $filter) {
    code
    name
    emoji
    capital
  }
}

After running code generation again, you can use this in Swift:

// Fetch only European countries
let filter = GraphQLNullable<CountriesAPI.CountryFilterInput>(
  .init(continent: .init(.init(eq: .some("EU"))))
)
let query = FilteredCountriesQuery(filter: filter)

11.3 — Variable Types in GraphQL

GraphQL Type Swift Equivalent Example
ID! String "CA"
String! String "Canada"
String GraphQLNullable<String> .some("Canada") or .null
Int! Int 42
Float GraphQLNullable<Double> .some(3.14)
Boolean! Bool true
[String!]! [String] ["en", "fr"]
CustomInput! CustomInput Generated struct

Chapter 12: Using Fragments for Reusable Fields

12.1 — What Is a Fragment?

A fragment is a reusable set of fields that you can include in multiple queries. Think of it like a Swift protocol or a reusable component — you define the fields once and reference them wherever needed.

This is extremely useful when multiple screens in your app need the same subset of data about a type. Instead of duplicating field selections across queries, you write a fragment once.

12.2 — Create a Fragment

Create a new file called CountryBasicInfo.graphql in your graphql/ folder:

fragment CountryBasicInfo on Country {
  code
  name
  emoji
  capital
  currency
  continent {
    name
  }
}

Now you can use this fragment in any query with the spread operator (...):

query AllCountriesWithFragment {
  countries {
    ...CountryBasicInfo
    languages {
      name
    }
  }
}

12.3 — Using Fragments in Swift

After code generation, Apollo creates a separate Swift struct for the fragment. Any query that uses the fragment will have a property called fragments that gives you access to the fragment's data:

// Access fragment data
let country = result.data?.countries.first
let basicInfo = country?.fragments.countryBasicInfo

print(basicInfo?.name)    // "Canada"
print(basicInfo?.emoji)   // "🇨🇦"
print(basicInfo?.capital) // "Ottawa"

Chapter 13: Error Handling

13.1 — Types of Errors

When working with GraphQL in iOS, you will encounter three distinct types of errors:

1. Network Errors

These happen when the HTTP request itself fails — no internet connection, server is down, timeout, DNS failure, etc. In Apollo iOS, these appear as a .failure case in the Result:

case .failure(let error):
  // The request never reached the server,
  // or the response was not valid HTTP
  print("Network error: \(error.localizedDescription)")

2. GraphQL Errors

These happen when the server successfully received your request but something went wrong with the query itself — invalid field, authorization failure, or a resolver error. These come in the .success case alongside potentially partial data:

case .success(let result):
  if let errors = result.errors {
    // The server returned errors, but may also
    // have returned partial data
    for error in errors {
      print("GraphQL error: \(error.message ?? "")")
    }
  }

  if let data = result.data {
    // Process whatever data was returned
  }

3. Null Data

Sometimes a query succeeds with no errors, but the data is nil. This can happen if you query for a specific item that does not exist (like a country code that is not in the database):

let result = try await apolloClient.fetch(
  query: CountryDetailQuery(code: "XX") // Invalid code
)

// result.data?.country will be nil

13.2 — A Robust Error Handling Pattern

func fetchData<Query: GraphQLQuery>(
  query: Query
) async throws -> Query.Data {
  return try await withCheckedThrowingContinuation { cont in
    Network.shared.apollo.fetch(query: query) { result in
      switch result {
      case .success(let graphQLResult):
        if let data = graphQLResult.data {
          cont.resume(returning: data)
        } else if let errors = graphQLResult.errors {
          let msg = errors.map { $0.localizedDescription }
            .joined(separator: ", ")
          cont.resume(throwing: AppError.graphQL(msg))
        } else {
          cont.resume(throwing: AppError.noData)
        }
      case .failure(let error):
        cont.resume(throwing: AppError.network(error))
      }
    }
  }
}

enum AppError: LocalizedError {
  case network(Error)
  case graphQL(String)
  case noData

  var errorDescription: String? {
    switch self {
    case .network(let err): return "Network: \(err.localizedDescription)"
    case .graphQL(let msg): return "Server: \(msg)"
    case .noData: return "No data returned"
    }
  }
}

Chapter 14: Understanding the Apollo Cache

14.1 — How the Normalized Cache Works

Apollo iOS includes a powerful normalized cache. When you fetch data, Apollo does not just store the raw JSON response. Instead, it breaks the response into individual objects and stores them by identity (usually the id or unique key of each object). This means:

  • If two different queries return the same country, it is stored only once in memory.
  • When data is updated, every query that references that object sees the update.
  • Subsequent requests for the same data can be served from the cache instantly.

14.2 — Cache Policies

When you call apollo.fetch(), you can specify a cache policy that controls where data comes from:

Policy Behavior Best For
.returnCacheDataElseFetch Returns cached data if available, fetches from network only if cache misses Data that rarely changes (country info)
.fetchIgnoringCacheData Always fetches from the network, then updates the cache Data that changes often (feeds, scores)
.returnCacheDataDontFetch Only returns cached data, never makes a network request Offline-first features
.returnCacheDataAndFetch Immediately returns cached data, then fetches fresh data in the background Best of both worlds — fast UI + fresh data

Here is how to use a cache policy:

Network.shared.apollo.fetch(
  query: AllCountriesQuery(),
  cachePolicy: .returnCacheDataElseFetch
) { result in
  // Handle result
}

14.3 — Watching Queries

Apollo iOS can also watch a query — your closure is called once immediately with current data (from cache or network), and then again whenever the cached data for that query changes:

let watcher = Network.shared.apollo.watch(
  query: AllCountriesQuery()
) { result in
  // Called immediately, and again whenever
  // cached country data changes
}

// Don't forget to cancel when done:
watcher.cancel()

Chapter 15: Building the Complete Country Explorer App

Let's put everything together into a polished, complete app with a tab-based navigation structure.

15.1 — Update the App Entry Point

import SwiftUI

@main
struct CountryExplorerApp: App {
  var body: some Scene {
    WindowGroup {
      TabView {
        CountryListView()
          .tabItem {
            Label("Countries", systemImage: "globe")
          }

        ContinentsView()
          .tabItem {
            Label("Continents", systemImage: "map")
          }
      }
    }
  }
}

15.2 — Create the Continents View

import SwiftUI

struct ContinentsView: View {
  @State private var continents: [ContinentsListQuery.Data.Continent] = []
  @State private var isLoading = true

  var body: some View {
    NavigationStack {
      Group {
        if isLoading {
          ProgressView()
        } else {
          List(continents, id: \.code) { continent in
            Section(continent.name) {
              ForEach(continent.countries, id: \.code) { country in
                NavigationLink {
                  CountryDetailView(countryCode: country.code)
                } label: {
                  HStack {
                    Text(country.emoji)
                    Text(country.name)
                  }
                }
              }
            }
          }
        }
      }
      .navigationTitle("Continents")
      .task { await loadContinents() }
    }
  }

  private func loadContinents() async {
    Network.shared.apollo.fetch(
      query: ContinentsListQuery()
    ) { result in
      DispatchQueue.main.async {
        isLoading = false
        if case .success(let res) = result {
          continents = res.data?.continents ?? []
        }
      }
    }
  }
}

15.3 — Complete Checklist

Before you run the app, verify you have:

  • Added Apollo iOS via Swift Package Manager (Chapter 5)
  • Downloaded the schema to graphql/schema.graphqls (Chapter 6)
  • Created all .graphql query files in the graphql/ folder (Chapter 7)
  • Run ./apollo-ios-cli generate and added the generated files to Xcode (Chapter 8)
  • Created Network.swift with the shared ApolloClient (Chapter 9)
  • Created all SwiftUI views (Chapters 10 and 15)

Press Cmd + R to build and run. You should see a beautiful tabbed app where you can browse all countries, search them, view detailed info with languages and states, and explore by continent.


Chapter 16: Where to Go From Here

16.1 — What You Learned

Congratulations! You have gone from zero GraphQL knowledge to building a working iOS app.

Here is a summary of everything you covered:

  • What GraphQL is and how it differs from REST
  • GraphQL queries, variables, arguments, and fragments
  • Setting up an Xcode project with Apollo iOS 2.x via SPM
  • Downloading a schema with the Codegen CLI
  • Writing .graphql query files and running code generation
  • Creating an ApolloClient and fetching type-safe data
  • Displaying GraphQL data in SwiftUI views with proper error handling
  • Understanding Apollo's normalized cache and cache policies
  • Building a multi-screen app with navigation and search
  1. Mutations: Try connecting to a GraphQL API that supports mutations (like a to-do list API). Practice creating, updating, and deleting data.
  2. Subscriptions: Learn how to set up WebSocket subscriptions for real-time features like live chat or notifications.
  3. Modularization: For larger projects, explore generating Apollo code into a separate Swift Package instead of embedding in your target.
  4. SQLite Cache: Replace the in-memory cache with the persistent SQLite cache (ApolloSQLite) so data survives app relaunches.
  5. Custom Interceptors: Learn to customize the request chain for adding authentication headers, logging, or retry logic.
  6. Persisted Queries: For production apps, persisted queries reduce bandwidth by sending a hash instead of the full query string.

16.3 — Resources

Here are the best places to continue learning:

  • Apollo iOS Official Docs: apollographql.com/docs/ios
  • Apollo iOS GitHub: github.com/apollographql/apollo-ios
  • Apollo iOS Tutorial (Odyssey): apollographql.com/tutorials/apollo-ios-swift-part1
  • GraphQL Specification: spec.graphql.org
  • Countries API Playground: countries.trevorblades.com

End of Tutorial

Happy coding! 🚀