I didn't choose GraphQL because I wanted to. I chose it because our MIS module had become genuinely painful to serve through the REST API we'd built, and GraphQL looked like the clean way out.
We were deep into an ERP SaaS product — the kind of system where every screen represents a different department's view of the same underlying business. Finance wants rollups. Inventory wants live counts. HR wants org charts that somehow also need to reflect approvals and budgets. The MIS dashboard sat on top of all of it, and over time our REST API had accumulated endpoints like /reports/summary, /reports/summary/v2, and /reports/summary/v2/withApprovals — each one a patch on the last, none of them a real answer.
REST could support the system. Our API design just hadn't scaled cleanly with how fast the dashboard's requirements kept changing. GraphQL looked like the escape hatch: one schema, one endpoint, clients ask for exactly what they need. On paper it solved everything I was frustrated with. In practice, it mostly relocated the complexity somewhere I hadn't learned to look yet.
The schema I regret
The first schema I shipped was basically a mirror of our database. Entity in, GraphQL type out. Employees became Employee, invoices became Invoice, approvals became Approval, and every relationship in the database became a relationship in the schema. It was fast to build, it felt structurally correct, and honestly I was a little proud of it.
That was the problem. I hadn't yet understood that a GraphQL schema isn't a database export — it's a contract with intent baked into it. By exposing our relationships almost directly, I'd also exposed traversal paths nobody had actually designed. A client could ask for an employee, their approval chain, the managers behind those approvals, and invoices reachable further down the graph, all inside one query, and nothing in the schema communicated where an expensive query should stop.
It worked beautifully in development. Development had ten records. Production had tens of thousands.
The afternoon I actually understood N+1
The ticket description was almost useless: MIS dashboard keeps spinning for one client. I opened the query and it looked reasonable — a collection of records, a few scalar fields, one nested relationship. Nothing about it looked expensive on the page.
Then I watched the database logs while the request actually ran, and I felt a bit sick. One query for the parent rows, then another for a related field, then another, then another — the same pattern firing over and over. My resolver was completely reasonable in isolation. The problem was that GraphQL was executing it once per parent record. What looked like one clean nested query from the client had quietly turned into hundreds of database calls.
That was the moment N+1 stopped being a term I'd read about and became something I'd actually caused. My resolvers were nicely separated, but each one fetched its own data independently, with no way to notice that a sibling resolver had already asked the database something almost identical. The fix was to batch and cache those lookups within the life of a single request — instead of resolving employee → manager three separate times through three separate calls, we collected the IDs first and fetched the managers together.
The concept itself wasn't hard. What actually changed for me was realizing that resolver design and database access patterns can't be treated as separate concerns. A query can look cheap at the schema level and be extremely expensive at the persistence layer, and if you're only looking at one of those, you're only seeing half the system.
Authorization was harder than I expected
REST had trained us to think about authorization at the endpoint boundary — a request hits a route, middleware checks the caller's role, the handler runs. Easy to reason about, and easy to trust.
GraphQL exposed how fragile that trust actually was. A single root query could traverse data owned by several different domains, each with its own access rules. A finance manager might be allowed to see a department's budget rollup but not individual salaries. An HR user might be allowed to inspect someone's reporting structure but not the invoice data sitting one hop further into the same query. Permission to access the starting object never implied permission to access everything reachable from it — and I only really understood that after watching the wrong data show up somewhere it shouldn't have.
We ended up pushing authorization down to the fields and domain objects themselves, instead of treating it as something that only happened before the query started. That was a bigger shift in thinking than anything I changed in the GraphQL layer itself. Authorization was never really attached to the transport. It was attached to the data. GraphQL just made that impossible to keep ignoring.
What actually changed
The biggest fix wasn't adding a batching layer or moving some permission checks around. It was becoming a lot more opinionated about what the schema should expose in the first place. Large collections got paginated. Relationships that could fan out unpredictably were constrained instead of exposed just because a foreign key happened to exist. Resolvers were designed with batching in mind from the start. Where a dashboard represented one specific business workflow, the schema started reflecting that workflow instead of pretending every client needed unrestricted access to the entire entity graph.
The schema became less "pure." It also became a lot safer, and honestly, easier to reason about — which is the part I didn't expect.
The lesson
GraphQL didn't create any of this complexity. It hadn't invented our authorization rules, our relational data, or our expensive joins — most of that already existed, and REST had just let us distribute it quietly across endpoints and handlers where nobody had to look at it all at once. GraphQL concentrated it into the schema and resolver layer instead, and that concentration was uncomfortable, because suddenly I had to answer questions I'd previously been able to postpone: which relationships should actually be exposed, how deep a query is allowed to go, which fields need their own authorization, what happens when a harmless-looking query turns into hundreds of database calls.
The real skill was never the syntax. It was learning to frame the API before writing a single resolver. A schema that mirrors your database isn't automatically a design — sometimes it's just your database wearing a network interface. And "clients can ask for exactly what they need" is only a strength if you've already decided what they're allowed to need.
I still think about that MIS dashboard every time I start a new schema. Not because GraphQL was the wrong choice, and not because REST would have magically avoided the same problems — but because GraphQL surfaced design mistakes we'd previously been able to hide. That turned out to be its most useful feature. If you're bringing GraphQL into a system with real relational depth — ERPs, MIS tools, admin platforms, anything where one entity touches five others — design the schema like you're writing a contract for the client who will eventually ask for the most expensive thing your API allows. Because eventually, someone will.