← Back to Blog

What Building a 200-Type GraphQL API Taught Me About REST

engineeringgraphqlrestapi-designbuilds

When I started building Builds in May of 2025, everything was new. I'd never written a mobile app. React Native, Expo, Apollo — all brand new to me. I'd been a backend developer for years, so even the front end was uncharted territory. In that context, adding GraphQL to the mix didn't feel like an extra risk. It was just one more new thing in a pile of new things.

I had some prior experience with GraphQL from years earlier — enough to have a hypothesis that it would work well in a TypeScript/Node environment. Builds seemed like the right project to test that theory on a real product.

Fast forward to today: Builds runs on GraphQL and Apollo, and every project I've started since — Grimly, Harken, and Corvus — uses REST. This is the story of how I got there.

The Schema

Before I get into the experience, here's a sense of scale. The Builds API has grown quite a bit:

Category Count
Object types 130
Enums 48
Input types 60
Union types 1
Queries 100
Mutations 117
Subscriptions 2
Total definitions ~241

The heaviest areas are activity logging (autocross, drag strip, road trips), group drives, discussions, and vehicle maintenance tracking. It's a big API.

What I Liked

GraphQL's schema language is genuinely great for modeling. Builds has a core domain concept: your profile is your Garage, your cars are Rides, and each Ride tracks its Builds (versions) over time. Expressing those nested relationships in a GraphQL schema felt natural and readable.

I also liked the support for union types. Early on, I needed to distinguish between users who had completed onboarding and those who hadn't. In GraphQL, I could model that as a union — essentially a sum type. If you come from a functional programming background, that's a familiar and satisfying pattern. Try doing that cleanly in OpenAPI 2.x or Java 8.

And the schema itself is concise. 241 type definitions, 100 queries, and 117 mutations expressed in about 3,000 lines. An equivalent OpenAPI spec would be massive. That compactness is a real advantage when you're working contract-first, which I strongly prefer.

Where Things Got Difficult

Apollo's Learning Curve

GraphQL itself wasn't that hard to learn. Writing resolvers on the backend was manageable. But Apollo — the client library — added a whole layer of complexity that I wasn't prepared for.

The local cache, merging responses into the cache, deciding between cached and network-only requests, reconciling Apollo's state management with React's own state model — all of this was challenging. And I was learning it simultaneously with React Native and Expo, so untangling "what's a React problem vs. what's an Apollo problem" was its own adventure.

Debugging Nightmares

This was the biggest pain point, and it's still a pain point today. When something fails in the Apollo/GraphQL stack, it's genuinely hard to figure out why.

Here's a concrete example: if your schema declares a field as non-nullable, but the backend returns a response missing that field, Apollo doesn't throw a clear error. The query just... doesn't work. You're left hunting through layers of abstraction trying to figure out where things went wrong. Every debugging session is an adventure, and not the fun kind.

I've never found a good solution for this. It's something I've just learned to live with on Builds.

The Nesting Trap

This is the big one — and I want to be upfront that this is probably more about how I was using GraphQL than a problem with GraphQL itself. Someone with more experience might have avoided this entirely.

My initial API design leaned heavily into nested types. It's GraphQL, right? The whole point is the graph. So a Garage contained an array of Ride objects, and each Ride contained an array of Build objects, and everything was interconnected.

The problem was that when I needed to evolve the model — add a field here, change a relationship there — the effects cascaded through both the backend and the front end. I ended up with database queries joining five tables deep, not necessarily because the client needed all that data, but because the type definitions said those fields had to be there. The API became brittle. Every small change broke things in dozens of places.

The Flattening

After a couple of months, I made a fundamental change. I redesigned the API to favor scalar identity fields over nested structures. Instead of a Garage containing an array of Ride objects, it contained an array of rideIds. Clients would then look up each entity by ID using dedicated queries.

Some nesting remained where it made sense — small, self-contained structures like an array of tags. But the deep nesting was gone.

This meant more round trips to the backend, yes. But the tradeoffs were worth it:

  • Changes stopped cascading. Modifying one type didn't break twenty queries.
  • Object retrieval became explicit. Instead of implicitly resolving nested data based on which fields the client requested, every entity lookup was a deliberate, visible operation.
  • Iteration got dramatically faster. I could evolve the API without fear of collateral damage.

The "standard" GraphQL answer to this problem is data loaders — resolve nested data on demand rather than eagerly. And I get it. But to me, data loaders felt like a workaround for a deeper issue with API usability and developer experience. I preferred the clarity of explicit lookups, even if it meant the client was responsible for deciding when to fetch related entities.

The Realization

At some point, I looked at what I'd built and had an honest moment: I was doing REST semantics in GraphQL syntax. Flat resources, identified by ID, fetched individually. The "graph" in GraphQL was barely there.

That wasn't a failure — the API worked, and the flattened design was far more maintainable than what I'd started with. But it did raise an obvious question: if I'm going to design APIs this way, why not just use REST and skip the extra abstraction layer?

So when I started Grimly, I went with REST and Fastify. Then Harken. Then Corvus. Each time, the experience confirmed the decision. REST with a well-structured API was simpler to build, simpler to debug, and simpler to evolve.

Why Not Migrate Builds?

I get asked this sometimes. The answer is straightforward: Builds is a big project. 241 type definitions, 217 queries and mutations, a mature mobile client, and a working production system. The cost of migrating to REST would be enormous, and the return on that investment would be... what, exactly? Slightly easier debugging? It's not worth it. I'd rather spend that time building features.

So Builds stays on GraphQL. It works. I've learned how to work within its constraints. It's fine.

What I'd Tell Someone Choosing Today

I want to be careful here, because I fully realize that many of the problems I ran into were probably a result of how I was doing things, not inherent flaws in GraphQL. A more experienced GraphQL developer would likely have made different decisions — used data loaders from the start, structured their schema differently, maybe chosen a different client library. My experience is one data point, not a verdict.

That said, here's what I took away:

GraphQL's strengths are real. The schema language is expressive and concise. Union types and sum-type patterns are powerful. Contract-first development feels natural. If you have a complex, deeply interconnected domain and a team with GraphQL experience, it can be a great fit.

But the ecosystem adds weight. Apollo (or whatever client you choose) brings its own complexity. Debugging is harder than it needs to be. The learning curve isn't just GraphQL — it's GraphQL plus the tooling on top of it.

REST is boring in the best way. It's well understood, easy to debug, and the tooling is mature. For a solo dev shipping multiple products, the simplicity is a genuine competitive advantage. I can spend my limited time on features instead of fighting my API layer.

The best API is the one you can maintain. For me, today, that's REST. For you, it might be different — and that's fine.


This is the second post on the Cone Crows engineering blog. If you're interested in the solo dev journey or the technical decisions behind Builds, Grimly, Harken, and Corvus, subscribe to the RSS feed to follow along.