14 July, 20254 minute read

Great GraphQL APIs are behavior-oriented

When comparing REST and GraphQL, it’s not just about payload size, flexibility, or tooling. The real distinction is in how you design your API. REST is centered around resources, with endpoints like createUserGroup or getUserProfile. GraphQL shifts the focus to capabilities, encouraging more semantically rich mutations like groupJoin, which are disconnected from the underlying datastore and instead represent meaningful actions within your domain.

This change in mindset is subtle but important. It’s often overlooked, which is why so many REST vs GraphQL comparisons fall short. In this post, we’ll explore what it means to design around capabilities, and how that approach can lead to simpler, more scalable APIs.

Examples

Attributes / tags

Many public-facing APIs let developers attach arbitrary key-value pairs to resources:

If you look at the RESTful APIs that implement this feature, you’ll find it’s very common to have per-resource endpoints for updating these key-value pairs. For example every single update endpoint in Stripe’s API takes an optional metadata parameter, and Intercom has three separate endpoints (#1, #2, #3) which each add a tag to a particular resource.

Shopify’s GraphQL API is different. It exposes a universal set of attributes* mutations which apply equally to all resources that can store attributes. There are two key things that enable this:

  1. Global Object Identification. Having globally unique IDs for objects is common in GraphQL, and avoids ambiguity when implementing a theoretical attributesAdd mutation which only takes an object ID.
  2. Being able to request multiple fields in a single operation. If Stripe had a separate setMetadata endpoint, then any time you wanted to update a customer's details and modify their metadata you'd need to make two round trips (^ Unless Stripe maintained two options for updating metadata or implemented their own HTTP batching protocol—both of which would add complexity.)

An abridged schema implementing this looks like the following:

Click to copy
type Task {  attribute(key: String!): Attribute  attributes: [Attribute!]!  # ...} type Project {  attribute(key: String!): Attribute  attributes: [Attribute!]!  # ...} input AttributesAddInput {  id: ID!  attributes: [Attribute!]!} type Mutation {  attributesAdd(input: AttributesAddInput!): Node  # attributesDelete  # attributesSet  # ...}

The attribute field is a convenient collection lookup, offered so that developers can look up individual attributes rather than being forced to load the entire blob.

One problem with this minimal schema is that it’s not immediately obvious which resources are compatible with our attribute mutations. Adding a documentation comment on each attribute mutation is a good first step for improving this, but you have more options if you introduce an interface:

Click to copy
interface HasAttributes {  attribute(key: String!): Attribute  attributes: [Attribute!]!} type Order implements HasAttributes {  # ...} type Product implements HasAttributes {  # ...}

The interface can be used by developers to write type-safe functions, and you can leverage this interface for static analysis or documentation purposes.

Bookmarks

Let’s scale the pattern up. Imagine you’re building a bookmarks feature for a blogging platform which lets users bookmark interesting posts or comments they come across.

In the following schema, bookmarkCreate receives the ID of a Node and creates a Bookmark object which we will later be able to query.

Click to copy
type Bookmark {  id: ID!  node: Node!} type Mutation {  bookmarkCreate(id: ID!): Bookmark  # bookmarkDelete  # ...}

We can fetch bookmarks and render rich UI based on the type of content that was bookmarked:

Click to copy
query GetMyBookmarks(first: Int!) {  me {    bookmarks(first: $first) {      node {        id        node {          ... on Comment {            ...CommentFields          }          ... on Post {            ...PostFields          }        }      }    }  }}

Like before, you could choose to have all bookmark-able content implement a common interface to make this behavior more explicit. The big difference between this example and the previous one is that we're creating a relation in our graph rather than simply writing to a field; in this case you might even prefer to use a union type to make the Bookmark.node field a bit clearer:

Click to copy
union BookmarkedEntity = Comment | Post type Bookmark {  id: ID!  entity: BookmarkedEntity!}

Long-running operations

The final example is one I've explored previously: a Job abstraction that gets reused for all long-running asynchronous operations in your GraphQL API. Rather than having API consumers poll per-resource endpoints for status updates (like Ramp does in their REST API #1 #2 #3), they can simply poll a single job query.

How this ties in with federation

This strategy of writing behavior-oriented mutations is valuable for GraphQL schemas of any size. Smaller teams will appreciate that they end up with a more manageable API surface area to maintain, which increases their engineering leverage. Similarly, consumers will appreciate the existence of powerful fine-grained mutations like attributesAdd. Most REST APIs that offer metadata functionality only support replacing the metadata wholesale, but GraphQL’s native ability to run multiple mutations in bulk makes it easy to write smaller API operations.

Once you scale up this pattern becomes even more powerful, as it plays to the strengths of GraphQL federation.

Federation allows you to partition your GraphQL schema across multiple services, each responsible for a particular domain or capability. A gateway service sits in front of all the subgraphs, and “stitches” them together so that consumers can interact with the entire system as if it were a single GraphQL server.

Crucially, it’s not possible to partition the execution of a mutation across multiple services. If you were to ship a big monolithic postUpdate mutation as part of your graph, then all of the logic for that mutation needs to run on the same server. This creates tight coupling and hampers modularity, especially if you have similar resourceUpdate mutations running within other services in your system.

You can, however, federate out individual mutations. In the case of our attribute* mutations, we can keep all of them contained to a dedicated attributes service which encapsulates all of the logic for managing resource attributes. If we ever wanted to update how we manage attributes, then we only ever need to touch the attributes service—rather than scattering logic changes across otherwise unrelated services. This keeps domain ownership clear and accelerates team autonomy.

Conclusion

GraphQL API design requires a fundamentally different mindset from building out RESTful services. At the core of this difference is embracing behavior-driven mutations: mutations which model explicit, semantically meaningful actions in your domain rather than generic resource updates. This approach to schema design leads to APIs that reduce complexity for both maintainers and consumers alike.

Following this principle guides you straight into the pit of success. It’s not unlike data loaders. A little bit of additional upfront effort yields a substantially better API on day one that also scales better over time as you start to adopt federation to keep teams working autonomously.

If you want to build GraphQL APIs that not only work well on day one but also evolve smoothly over time, rethinking your schema design around behaviors and capabilities is the essential first step.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com