DEV Community

Cover image for How to derive data classes for any API
Aalaa Fahiem
Aalaa Fahiem

Posted on

How to derive data classes for any API

Let me be honest about a mistake I made this week.

I was building the data layer of a Pokedex app. I opened the JSON from the API, and I ended up writing two data classes for the list screen — PokemonListResponse and PokemonResult. And in my head, I invented a reason for why there were two.

"One of them," I told myself, "is the data coming from the server. The other one is the data I show in the UI."

It sounded smart. It was completely wrong.

Both classes describe the server's JSON. Neither one is "the UI data." I had looked at two classes and invented a structure that wasn't there — instead of reading the structure that actually was.

That mistake taught me something more useful than the fix: you don't decide how many data classes you need. The JSON decides for you. You just have to learn to read it.

Here's the method I use now for any API.


The one rule everything comes from

Your data classes mirror the shape of the JSON.

That's it. Not "one for server, one for UI." Not a magic number someone told you. The classes are a mould shaped like the data, so Gson can pour the JSON into them. Match the shape — it works. Miss the shape — it breaks.

Everything below is just how to read the shape.


Step 0 — Look at the real JSON first

Never guess. Open the actual endpoint in your browser and stare at the real response. You cannot model a shape you haven't seen.

Here's the Pokedex list endpoint (/api/v2/pokemon?limit=20):

{
  "count": 1302,
  "results": [
    { "name": "bulbasaur", "url": "https://pokeapi.co/api/v2/pokemon/1/" },
    { "name": "ivysaur",   "url": "https://pokeapi.co/api/v2/pokemon/2/" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Step 1 — Every { } object becomes one data class

A curly-brace block is one mould. Count the distinct { } shapes and you've counted your classes.

In the JSON above there are two distinct shapes:

  • the outer { count, results }
  • the little { name, url } that repeats inside the list

Two shapes → two classes.

Step 2 — For each key, ask ONE question: "what's on the right?"

This is the whole skill. For every key inside an object, look at its value:

  • A primitive"text", 45, true → a normal field: String, Int, Boolean
  • Another { } → a field whose type is another data class (a box inside a box)
  • A [ ] → a field of type List<X> — then look inside the brackets to find what X is

Say it out loud every time: [ ] means a list. { } means a single object. That one sentence saved me from three different bugs this week.

Step 3 — If the same shape repeats, reuse one class

The { name, url } shape shows up for every Pokemon in the list. You don't write a class per Pokemon — you write it once and use List<PokemonResult>.

Step 4 — Only model what you need

The real response also has next and previous keys. I didn't model them. Gson only fills the fields you declare and quietly ignores the rest — so take what your app uses and skip the rest.

Step 5 — Name it, and file it

Name each class after what it holds. Keep tiny helper classes in the same file as their parent; give standalone models their own file. This is only about you finding things later — the compiler doesn't care.


Watch the method build the classes

Applying steps 1–2 to that list JSON:

data class PokemonListResponse(
    val count: Int,                     // primitive → Int
    val results: List<PokemonResult>    // [ ] → List of the inner shape
)

data class PokemonResult(
    val name: String,                   // primitive → String
    val url: String
)
Enter fullscreen mode Exit fullscreen mode

I didn't decide on two classes. I read two { } shapes and the classes fell out.

Now the harder one — the Pokemon detail endpoint (/api/v2/pokemon/bulbasaur), trimmed:

{
  "id": 1,
  "name": "bulbasaur",
  "sprites": { "front_default": "https://.../1.png" },
  "types":  [ { "slot": 1, "type": { "name": "grass" } } ],
  "stats":  [ { "base_stat": 45, "stat": { "name": "hp" } } ]
}
Enter fullscreen mode Exit fullscreen mode

Walk it key by key with the one question:

  • id → primitive → Int
  • name → primitive → String
  • sprites{ } → single object → its own class Sprites
  • types[ ]List<...>, and each item is { slot, type } → a class, and type inside it is { }another class
  • stats[ ]List<...>, same nesting story

Which produces:

data class PokemonDetail(
    val id: Int,
    val name: String,
    val sprites: Sprites,
    val types: List<PokemonType>,
    val stats: List<PokemonStat>
)

data class Sprites(
    val front_default: String?
)

data class PokemonType(
    val slot: Int,
    val type: TypeInfo        // { } → single object
)
data class TypeInfo(val name: String)

data class PokemonStat(
    val base_stat: Int,
    val stat: StatInfo        // { } → single object
)
data class StatInfo(val name: String)
Enter fullscreen mode Exit fullscreen mode

Notice what happened. The list JSON was shallow, so it gave me flat classes. The detail JSON was nested, so it gave me nested classes. Same rule. Different input. Different-looking output. I used to think those were two different "approaches." They're not — it's one rule meeting two shapes.


The mental shift

The whole thing fits on a sticky note:

For each key, ask: what's on the right?
  primitive → String / Int / Boolean
  { }       → another data class
  [ ]       → List<that class>

Same shape twice? Reuse it.
Don't need it? Skip it.
Enter fullscreen mode Exit fullscreen mode

I stopped asking "how many data classes does this API need?" — because that question has no answer until you look. The JSON has already decided. Your job isn't to invent the structure. It's to read it.

And the next time you catch yourself inventing a reason for why your code looks the way it does — go back and check the data. It'll tell you the truth.

Top comments (0)