Skip to content
All posts

Swelv· Part 2 of 2

Flutter and GraphQL: a typed client, and versions that coexist

A client generated from the schema, screens that ask for exactly their fields, and a server that keeps talking to the versions already installed.

5 min readLire en français

On the web, a deploy updates everyone. On mobile, it does not: every published version stays installed on phones for weeks, sometimes months, and they all talk to the same server.

Why Flutter, why GraphQL

Swelv runs on iOS and Android, with a small team. Flutter lets you write the app once, in Dart, and compile it for both platforms, with smooth animations and the same rendering everywhere. A feature is built once, tested once, and ships the same day on both stores.

On the API side, GraphQL solves a typical mobile problem. With a REST API, each screen gets what the endpoint was built to return: too much data, or not enough, and a second call. With GraphQL, there is one entry point, and each screen describes exactly the fields it wants, in a single round trip. On a mobile network, you feel it.

REST2 calls
GET booktitleauthorcoversummarypublisherpageslanguage
GET friendsnamephotobiocity

Too much data, and two round trips.

GraphQL1 query
book {
  title author cover
  friends { name photo }
}
titleauthorcovernamephoto

Exactly what the screen shows, in one round trip.

A screen shows a book and the friends who read it. With REST, each endpoint returns what it was built to: too many fields, and a second call. With GraphQL, the screen describes exactly what it shows. In blue, what the screen actually uses.

The challenge. Keep changing the API, without ever breaking an app already installed that its owner will not update for a long time.

A typed, generated client

The GraphQL schema describes every type the server can serve. It is generated from the server's code, then the app uses it to generate its own Dart classes. No more hand-read JSON: every request is a class, every field has its type, and a typo does not compile.

01The server describes its typesTypeScript
02The schemagenerated
03The app's queries.graphql
04The typed clientDart, generated
home.graphql
query Home {  books {    title    author    cover  }}
home.dart
final res = await client    .request(GHomeReq())    .first;final book = res.data?.books.first;book?.title;  // Stringbook?.price;  // ✗ does not compile
An example. The schema is written once, on the server; everything else follows. A field renamed on the server breaks the app's build, not a reader's app.

Each screen asks for its fields

This is what makes GraphQL so valuable on mobile: a version only gets what it asks for. An added field does not bother it; it never even sees it. And on the server, only the requested columns are read from the database.

An older version
9:41

Home

★★★★☆

books {  title  cover}
The serverone schema, different queriesSQL: only the requested columns
The current version
9:41

Home

★★★★☆

books {  title  cover  rating}
Two installed versions, one server. Each sends its own query and gets exactly its fields; the server only reads the requested columns. An added field is always optional: the older version does not ask for it, so it never sees it.

The trap: enum values

Adding a field is safe. Adding a value to a closed list (an enum) is not: an old version's generated code only knows the values of its time, and an unknown value makes the whole response fail. So the app sends its version with every request, and the server adapts its answers for versions that are too old.

The same response, for two versions

the app sends its version
Recent version
{  "status": "PAUSED",  "moods": ["MOVED", "AMUSED"]}
Old version · adapted response
{  "status": "READING",  "moods": ["MOVED"]}
~ value replaced · − value dropped

The old app shows less, but it shows.

An example. A new value, “paused”, and two new moods. The recent version shows them; the old one would fail on the whole response. So the server, which knows the app's version, answers it with what it understands.

Add, deprecate, and only then remove

Removing something from the API removes it from every installed version at once. Hence a rule in three steps:

  1. 01AddAn optional field or query: safe, nobody asks for it yet.
  2. 02DeprecateThe replacement next to it, the old one marked deprecated, and kept.
  3. 03RemoveOnly once no installed version uses it: a required update, or version statistics.
The way out

One request lives outside the generated client: the one that asks whether the app should update. It must work even when everything else has changed.

The rule came from a real incident: a query removed too early broke, overnight, the versions still using it. It came back, deprecated, and waits until nobody uses it.

What I take from it

  • Generate the client from the schema. The compiler reviews every API change.
  • An installed version is a client you no longer control. Send it what it understands.
  • Adding is free; removing has to be earned.

Maxime Burri

Working on something similar? Let's talk.

Send an email