What actually breaks when you split a GraphQL monolith across teams
From March 2025 to April 2026 I worked as an external consultant at Realtor.com, on one of the teams migrating a large GraphQL monolith into a federated graph. The engagement was scoped and budgeted for twelve months. It ran thirteen.
The extra month had nothing to do with code.
This piece is about where that month came from, including the part of it that was mine, because it is the most useful thing the project taught me, and it is the part founders most often underestimate when they split a system across teams. I won’t name internal systems or services. What matters here is the shape of the problem, and that shape is not unique to one company.
Getting in the door
The company reached out to me because of my GraphQL background, so understanding the project was not the hard part. Once I was working with them, they asked me to get Apollo’s Graph Developer Professional certification. Federation expertise is scarce, they knew it, and they wanted it verified rather than assumed.
I treated it as part of the job and studied for it intensively. Most of the decisions that follow are about how federation behaves across team boundaries, which is precisely the ground the certification covers.
Fixing the inside first
When I joined, my own team was disorganized. Not for lack of talent, but because the unit of work was more complicated than it looked. Every resolver we moved out of the monolith had to pass through three gates:
- A pull request with the resolver itself, now in TypeScript, in our subgraph.
- A schema proposal against the development graph, tied directly to our subgraph.
- A schema proposal against the staging graph, where the change also touched the monolith’s schema.
Jira tracked the overall work, but it could not show what the team actually needed to see: who was on which gate of which resolver, today. The obvious fix was a better tool. The real constraint was that, as an external consultant, I could not bring in software the company had not already approved, and getting something approved would have taken weeks of process. Weeks we did not have.
So I built the tracker in a plain shared document: a table listing everyone involved and the status of every step, visible to the whole team.
| Resolver | Owner | Pull request | Schema proposal · dev | Schema proposal · staging |
|---|---|---|---|---|
| Resolver 01 | Developer A | Merged | Approved | Approved |
| Resolver 02 | Developer B | In review | Approved | Draft |
| Resolver 03 | Developer C | In progress | Draft | Not started |
| Resolver 04 | Developer A | Not started | Not started | Not started |
It is not a clever artifact. That is the point. The boring tool you are allowed to use today beats the right tool you will get in six weeks. What the team needed was not features, it was a single place where the answer to “who is doing what” was always current.
Learning what the scoreboard rewarded
Around the same time, the company rolled out a developer analytics platform that scored every developer, team and organization. It did not just count pull requests. It looked at quality: whether a PR was approved with minimal changes, how much review feedback we gave each other, and how quickly. Slow reviews counted against the whole team.
We started near the bottom. Not because the work was poor, but because nobody on the team understood how the platform read it. I spent a week working out what it actually rewarded, and the clearest lever was PR size: smaller, more manageable pull requests got reviewed faster, came back with fewer requested changes, and showed the work as it really was. I turned that into working norms for the team: smaller PRs, reviews answered fast, feedback given instead of withheld. Those norms happen to be exactly what makes a migration move, so the metric and the work pointed in the same direction.
This mattered beyond the dashboard. Those numbers were how the CTO saw our work, and they were how I could justify it properly. On that platform, we went from near the bottom to the best-performing team. Which made what happened next more frustrating.
The technical work: moving resolvers without changing the answers
The job itself was clear: take the monolith’s resolvers, assign each one to the subgraph that owned its domain, and rewrite it in TypeScript. The rule was stricter than it sounds. A migrated resolver had to return exactly what the monolith returned. Same data, same shape, same order.
Plain JavaScript in a single codebase, deployed through Jenkins.
The router composes the monolith and the new subgraphs. Each move is a pull request plus two schema proposals.
Resolvers in other subgraphs import the same DataLoaders instead of waiting on mine or requiring my fields.
Parity was the spec
We used GraphOS Studio to run the original queries against my subgraph and check the result matched the monolith, including when resolving a field meant crossing into another team’s subgraph. We also built a test environment to confirm that behavior and ordering matched the monolith. The move from Jenkins to CircleCI belonged to another team, and we brought them along using that same environment, so the new pipeline was checked against the old behavior rather than against our assumptions.
Every resolver was validated end to end, first against staging data and then against production data.
Fewer dependencies, not just newer ones
The monolith leaned on Ramda, a functional utility library. We removed it completely. Most of what it did is covered by modern JavaScript built-ins, which TypeScript types for free, and where Ramda’s behavior had no direct equivalent we wrote small custom functions that reproduced it. I only added a third-party library when it was truly necessary.
This is where parity testing earned its keep. Utility libraries hide semantics. Ramda’s isNil is true for both null and undefined, so swapping it for a strict === null check lets every undefined slip through. Ramda’s uniq compares values deeply, while a native Set compares object references, so a one-line “modernization” can quietly start returning duplicates. A migration is full of changes that look equivalent and aren’t, and the only defense is a test that compares the output, not the code.
Why I avoided @requires
Federation lets one subgraph declare that it needs fields owned by another, with @external and @requires. The router then fetches those fields from the owning subgraph before resolving yours. It is elegant on a diagram. In practice, on lists, it can turn one request into one downstream fetch per item, the classic N+1 problem, spread across two teams’ services where it is harder to see and harder to fix.
The principal engineer’s rule was to use it only when strictly necessary, and I agreed with it. When other subgraphs kept needing data from my domain, I took a different route, which is where the shared package comes in.
When the critical path runs through another team’s inbox
For the first months my team worked mostly in isolation, and it showed in our pace. The deeper we got, the more our resolvers needed resolvers owned by other teams. On top of the core subgraph I owned, I had to contribute to a neighboring subgraph owned by a different team, and my proposals there started to wait.
It would be easy to tell this as a story about slow people. It wasn’t. Every team had its own roadmap, its own deadline and its own priorities, and my pull request was not on any of them. Everyone was doing the right thing by their own board. The dependency simply did not exist on anyone’s board but mine.
The symptoms were predictable in hindsight. Answers took longer. Meetings stopped being optional. Each month we needed more of them than the month before. And the time my team had banked by being fast started to drain away, one waiting week at a time, against a fixed scope and a one-year budget.
I raised it with the principal engineer, and he backed us fully. We already had three recurring syncs with him where blockers came up. As the deadline got closer, cross-team blockers went from normal priority to important, and he started requesting reviews from other teams directly, through their tech leads.
That unblocked things, but it did not give the lead back. The engagement ran one month over plan, and that month was the cost of coordination, not of any technical problem.
The part I own
It would be convenient to put that month entirely on other teams. It wouldn’t be accurate, and it wouldn’t be the useful lesson.
I spent my early energy making my own team fast, and I treated other teams’ response times as something outside my plan. They were outside my control, but they were never outside my results. A team can lead on every metric it owns and still deliver late, because the dependency it doesn’t own sets the date.
You don’t always get the ideal setup: the right tools, teams that answer on time, priorities that match yours. What I took from this project is to treat cross-team dependencies as part of my own delivery from the first week, each with an owner and a date, and to raise them while there is still buffer to spend, not after it is gone.
Turning repetition into a package
While all this was happening, I noticed the same modules showing up in several subgraphs. Every team that needed data from my domain either rebuilt the access logic or waited for me to expose it. Both are expensive, and the second one made me a bottleneck.
I proposed to the principal engineer that we extract it into a shared npm package. I owned it, and built it together with the company’s senior engineer. As an external consultant there were things I was not allowed to do that an employee could, and he covered those. He was a reliable partner and we coordinated well, which is its own lesson about how much depends on one person on the other side.
The package contained the DataLoaders behind the data other teams kept asking my subgraph for. A DataLoader batches and caches fetches within a request, so importing one gives a team efficient access to the data without routing through my resolvers, and without the N+1 risk of reaching for @requires.
Then I wrote the documentation, so teams could integrate it on their own schedule.
That changed the coordination model more than any meeting did. Teams stopped coordinating with me and started coordinating with the documentation. I still answered questions, and the tracker let me keep development and meetings going at the same time, but I was no longer a step someone had to wait for. Maintenance also got more controlled: a fix happened in one place instead of in every subgraph that had copied the logic.
| Alternative considered | Decision taken | Benefit | Accepted cost |
|---|---|---|---|
| Bring in a proper project-management tool | A shared tracker table on tools already approved | Visibility in days, with no approval cycle | Manual upkeep, living outside Jira |
| Port the utility library as-is | Built-ins or small custom functions | Fewer dependencies in every subgraph | Every replacement needed a parity test |
@requires whenever a subgraph needs foreign fields | Shared DataLoaders; @requires only when strictly necessary | Avoids N+1 fan-out across subgraphs | Teams must upgrade the package to get fixes |
| Wait for other teams to answer | Escalate through the principal engineer's syncs | Blockers got cross-team priority | More meetings, and the month was still lost |
| Answer each team one conversation at a time | A documented package teams integrate on their own | Integration no longer waits on me | Docs must stay in step with the code |
What this means for a founder
You probably don’t have dozens of teams. You may have three, or one team and two agencies. The mechanics are the same, only smaller, and they arrive sooner than you expect.
- Splitting a system splits the calendar. Once work crosses team lines, the pace is set by the slowest response, not the fastest team. Budget for coordination as a real line item. On this project it was one month out of thirteen.
- Make status visible with the tool you already have. A shared table everyone trusts is worth more than a better tool nobody has adopted yet.
- Someone has to own cross-team priority. Here it was a principal engineer. In a startup it is your CTO, or you. If nobody owns it, every dependency defaults to “not my priority”, and nobody is wrong.
- Escalate with specifics, and early. Raising a blocker is not a failure. Raising it after the buffer is gone is.
- Turn repeated conversations into artifacts. A package, a contract, a page of documentation. Every question that gets answered in writing once stops being a meeting.
- In a migration, parity is the spec. “Better” can wait until “the same” is proven.
What this adds up to
Enterprise scale did not teach me much that was new about GraphQL. It taught me that the architecture diagram and the org chart are the same drawing, and the org chart is the one that sets the deadline.
That’s why, when I look at a system a founder wants to split, the first question I ask is not about the schema. It is who will be waiting on whom, and what happens the first time they don’t answer.