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/" }
]
}
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 typeList<X>— then look inside the brackets to find whatXis
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
)
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" } } ]
}
Walk it key by key with the one question:
-
id→ primitive →Int -
name→ primitive →String -
sprites→{ }→ single object → its own classSprites -
types→[ ]→List<...>, and each item is{ slot, type }→ a class, andtypeinside 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)
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.
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)