One of the more interesting projects I have been working on recently started with what sounded like a simple request:
We need to connect a new app to your app the same way Application X does.
This was not the first request of its kind. It was the sixth one we had received in the last two years. Each time, the legacy team had built a bespoke integration for an individual consumer application.
The Larger Problem
As you can imagine, this pattern was getting expensive. The cost was not limited to writing the initial integration. Although the details varied, each one required some combination of authentication rules, field mappings, validation, error handling, monitoring, documentation, testing, and production support.
Each bespoke integration also created another code path the legacy team had to understand and maintain. A change to the source application could require updates in several places. Similar business rules could behave differently depending on which integration implemented them. Troubleshooting required the team to remember how a particular consumer had been connected, and onboarding the next consumer still started almost from scratch.
I also knew of three more requests that were likely to arrive in the near future. At that point, this was no longer a series of isolated integration requests. It was a scaling problem. We were paying the initial development cost repeatedly while multiplying the long-term maintenance, testing, security, and support burden with every new consumer.
The easy answer would have been to ask for a seventh bespoke integration. As the project leader, I instead asked the development team to design and implement a shared GraphQL API using a publish-and-subscribe model. The goal is to satisfy the current request while giving future consumers a repeatable path to integration. If we get it right, the same framework should reduce development overhead, maintenance burden, and long-term cost.
The shared framework has not been implemented yet. This article describes the larger product problem I asked the team to solve and how I narrowed it into something we can design, build, and test.
Easy enough, right? Pick a framework, define a schema, and start building.
The more questions I asked, however, the less simple the request became. Before we could write useful code, we needed to understand who would use the API, who owned the data, what each consumer was allowed to change, and what the word reliable actually meant. The technical implementation was only one part of the problem.
Reframing the Request
After a few conversations, I rewrote the request into something closer to this:
As an authorized consumer system, I need one well-documented API that lets me query approved data, receive relevant change events, and submit a small number of controlled updates.
That is much more useful than “we need an API,” but it still contains three very different jobs:
- Pull the current state of approved data.
- Know when something meaningful changes.
- Submit a limited and well-controlled update.
Separating those jobs gave me a much better place to start.
Start with the People and the Data
Calling a consumer system the user is convenient, but it hides quite a few real people. Someone owns the source data. Someone builds the consuming application. Someone operates the shared platform. Someone is responsible for security and audit requirements.
Before drawing the schema, I needed answers to a few basic questions:
- Which data entities are actually in scope?
- Who owns the meaning and quality of each field?
- Which consumers can see each field?
- Who can approve an update?
- Who supports the integration when something fails?
This may sound like paperwork, but these answers set the boundaries for everything that follows. A schema cannot make an ownership decision for us, and a framework cannot decide who should be allowed to change business data.
Why GraphQL and Publish/Subscribe?
Once I separated the request into those three jobs, the choice of GraphQL became much easier to explain. The previous integrations were different ways of exposing much of the same underlying data. A shared, strongly typed schema would give every consumer one contract for understanding what data is available, what it means, and what each application is allowed to request.
GraphQL also maps cleanly to the work we need to support. Queries provide the current state of approved data. Mutations expose a small number of controlled updates. Subscriptions give consumers a common way to receive relevant changes.
Pulling Data
The first job is the most familiar one. A consumer needs to ask for the current state of data it is allowed to see. This is the query side of the API.
GraphQL allows each consumer to request only the fields it needs. The development team can add fields and capabilities to the shared schema without creating another custom endpoint for every application, provided those changes follow clear compatibility and deprecation rules.
Even this simple requirement creates useful product questions. Which fields are useful to a consumer? How fresh does the data need to be? How much data can a consumer request at one time? What should happen when part of a response is unavailable?
These questions eventually become schema choices, authorization rules, performance targets, and useful error messages. Starting with the questions keeps the schema tied to a real need.
Knowing When Something Changed
The second job is notifying consumers when something meaningful changes. It would be easy to send an event for every database update. It would also be a quick way to create a noisy and confusing system.
Instead, we need to decide which business events matter, what each event means, and what a consumer should do when it receives one. “A row changed” is usually less useful than “an order was approved” or “a shipment was cancelled.”
Publish-and-subscribe replaces the tight point-to-point relationship with a reusable event flow. A meaningful business event can be published once, and every authorized consumer interested in that event can subscribe to it. Adding another consumer should not require another custom connection or a change to the system that produced the event. It also reduces the need for every consumer to poll the API just to find out whether something changed.
In the proposed design, GraphQL would define the consumer-facing contract and expose a live subscription response. A message broker or durable event store behind that API would handle event distribution, retention, and replay. The exact broker and transport are still design decisions for the development team.
This boundary matters because GraphQL does not provide durable messaging by itself. The GraphQL specification intentionally leaves acknowledgements, buffering, replay, transport, and other delivery guarantees to the implementation.
If a disconnected consumer must be able to recover missed events, the team still needs to define the retention window, replay contract, ordering rules, and duplicate-handling behavior. GraphQL can expose the stream, but it does not make those reliability decisions for us.
Allowing Limited Updates
The third job is allowing a consumer to submit an update. This is the part I want to keep as small and specific as possible.
Giving every consumer the ability to update anything would make the API difficult to secure and even harder to support. Each allowed mutation needs a clear owner and a clear contract. Who can call it? Which fields can change? What validation applies? Is the operation safe to retry? Does it trigger another workflow? Can we tell later who made the change and when?
GraphQL gives us a mutation operation. It does not decide whether the business should allow that mutation in the first place.
Turn Reliable into Requirements
“Reliable” is one of those words that everyone agrees with until it is time to define it. Does reliable mean the API is almost always available? Does it mean an event is never silently lost? Does it mean a consumer can reconnect after an outage? Does it mean an update is either completed or rejected with a useful error?
For this framework, I would want the team to agree on a few measurable behaviors:
- Availability and response-time targets for queries and mutations.
- A unique ID for each event, plus idempotent consumer behavior so a duplicate does not create duplicate work.
- Documented ordering and delivery behavior.
- A defined recovery path and retention window for disconnected consumers.
- Authorization, validation, and an audit trail for every allowed update.
- Enough logging and monitoring to tell whether an event was created, published, delivered, replayed, or failed.
None of these items are especially glamorous. They are, however, the difference between an interesting demo and a framework people can trust.
Cut a First Release
A shared API framework could easily become a project with no finish line. There will always be another data domain, another consumer, and another special case. The first version needs to be useful, but it does not need to solve every possible integration problem.
For planning purposes, I proposed a deliberately small first release:
- One high-value data domain.
- One consumer system.
- Two useful queries.
- One meaningful business event.
- One controlled update operation.
That proposed scope is small enough to understand and large enough to prove the framework. It also gives us something we can test. The consumer can query only approved fields. A disconnected consumer can recover within a defined window. Receiving the same event twice does not create duplicate work. An invalid update is rejected with a useful message. Every accepted update identifies who made it and when.
If that first release works, the next consumer should be able to follow the same documented process without needing a custom integration.
Turn the Scope into a Delivery Plan
Scope tells the team what belongs in the first release. A shippable plan also needs to say what happens first, who must make each decision, which dependencies can block the work, and what evidence will tell us the release is ready.
I organized the work into five delivery checkpoints:
- Confirm ownership and boundaries. The data owner approves the domain and fields in scope. The platform team owns the shared API and event infrastructure. The consumer team names the people responsible for integration testing and production support.
- Agree on the contracts before building them. The teams review the GraphQL schema, event payload, authorization rules, mutation behavior, error responses, and compatibility expectations together. This is where we resolve different assumptions while they are still inexpensive to change.
- Build one thin path through the entire system. Instead of completing every query before touching events or updates, the team proves one query, one event, and one controlled mutation from the source application through the shared platform to the consumer. That gives us an early test of the architecture and the working relationship between teams.
- Prove the failure paths. The team tests expired credentials, unauthorized fields, invalid mutations, duplicate events, temporary disconnections, replay, and recovery. A successful response is useful evidence, but a shippable integration also needs predictable behavior when something goes wrong.
- Prepare to operate and release it. Before production, we need dashboards, alerts, an audit trail, a support owner, a runbook, and a rollback or disablement plan. The consumer team also needs a test environment and enough time to complete an end-to-end acceptance test.
A few dependencies need named owners before the team can estimate this work with confidence: access to the source data, approval of the authorization model, selection of the durable event infrastructure, availability of the consumer team, and agreement on the first release's reliability targets. If one of those decisions is still open, it belongs in the plan as an explicit risk rather than an assumption hidden inside an estimate.
The release is ready when the agreed queries, event, and mutation work end to end; the failure and recovery scenarios pass; monitoring can show what happened to a request or event; and the people responsible for supporting both sides have accepted the handoff. Those conditions turn a technical direction into work the team can sequence, estimate, and ship.
What Changed in the Request
We started with a request to connect one more application the same way we had connected another one. After looking at the history and the requests likely to follow, I turned that one-off request into a broader product direction for the development team.
The result so far is a better-defined problem, named users and owners, three separate API jobs, reliability behaviors that can become acceptance criteria, a proposed first release small enough to estimate, and a sequence of delivery checkpoints that takes the work through release and support.
The interesting part of this project is not choosing GraphQL. It is turning a narrow integration request into a reusable product that everyone can understand and test before the team starts building it.
The next time I receive a request like this, I will start with six questions:
- Who uses it, and who owns the data?
- What are the distinct jobs hiding inside the request?
- What does each vague quality word mean in measurable terms?
- What happens when a connection, event, or update fails?
- What is the smallest version that proves the idea?
- Which decisions, dependencies, and evidence stand between that version and release?
Once those answers are clear, the schema and implementation have a much better chance of solving a real problem instead of simply checking a technical box.
The scalable solution is not the technology alone; it is the shared contract, durable event flow, controlled updates, and focused first release that make the approach repeatable.
