Blog
/
Education

10 Principles for Designing Good GraphQL Schemas

cover
Jens Neuse

Jens Neuse

min read

One question that keeps coming up in discussions about GraphQL is how to create a good schema. You might be tasked with creating a new GraphQL API on top of an existing database or REST APIs, or maybe you're just starting from scratch.

As a major vendor in the GraphQL space, we're in touch with companies of all sizes and industries, including companies like eBay, SoundCloud, Paramount, Shutterstock, and many others. Schema design and governance is a major concern for many of these companies, and we're regularly discussing this topic with our customers.

In this post, we'll discuss different approaches to schema design and the trade-offs between them.

Introducing WunderGraph Hub: Rethinking How Teams Build APIs

WunderGraph Hub is our new collaborative platform for designing, evolving, and shipping APIs together. It’s a design-first workspace that brings schema design, mocks, and workflows into one place.

What is a (good) GraphQL Schema?

First, let's define what a (good) GraphQL schema is. Compared to REST APIs, a schema is never optional in a GraphQL API. A REST API might be documented in an OpenAPI specification, but that is not a hard requirement.

A GraphQL server, if it follows the GraphQL specification , must always implement certain root fields that allow clients to introspect the schema. This is powerful because it allows tools like GraphiQL and GraphQL Playground to introspect the schema and leverage it for various purposes.

While GraphiQL shows an interactive interface to explore the schema, CLI tools like GraphQL Codegen can generate typesafe client code simply by pointing at the schema.

In a nutshell, the GraphQL schema is the definition of the API contract. It's all the types and fields that are available for the clients to "query".

This brings us to the most important question of the post:

What makes a good GraphQL schema?

For me, a good GraphQL schema allows clients to fetch exactly the data they need in the least amount of requests possible. Ideally, a client can fetch all the data it needs in a single request.

Further, a good GraphQL schema is easy to explore and understand, while allowing the creator to safely evolve it over time without breaking clients.

To formalize this, here's a list of principles that, based on our experience, leads to good GraphQL schemas.

10 Principles for Designing Good GraphQL Schemas

  1. Capability-based design: Before designing a schema, ask yourself what capabilities you need to support. What are the jobs to be done that you need to support? What workflows are clients trying to achieve? What user experiences are clients trying to achieve?
  2. Client-centric design: Similar to capability-based design, it's important to center the design around the clients and their usage patterns and requirements. This is in contrast to a datasource-centric design that is driven by the underlying data models or REST APIs.
  3. It's ok to repeat yourself: Focusing on capabilities and client-centric design leads to another advantage: We're avoiding the trap of trying too hard to not repeat ourselves. Capabilities-centric thinking allows us to find clear boundaries between different subdomains, making it easier for us to understand the difference between a Viewer, a UserProfile, and a TeamMember. We might want to push all these concepts into a single User type, but that just blurs the lines between use cases and makes the schema less explicit, e.g. when some of the fields are only available in a specific context.
  4. Make expected errors explicit: GraphQL allows for a lot of expressiveness in handling outcomes of operations. Being very explicit about possible outcomes and errors makes it easier for clients to handle all possible cases. We'll discuss this in more detail in the next section.
  5. Nullability and absence of data: GraphQL is often used as an API orchestration layer on top of other systems. As such, a good schema should be designed to distinguish between absence of data, the value null, and null as a consequence of an error.
  6. Null blast radius: As an extension to the previous point, a good schema should be designed to minimize the blast radius in case of errors. You should really only mark fields as non-nullable if you're sure that the data will always be present.
  7. Assume that you can never deprecate a field: Although GraphQL has a deprecation mechanism using the @deprecated directive, which we strongly recommend using, you should always assume that you cannot deprecate or change fields once they are published. Mobile apps are notorious for not immediately updating to the latest version of an app, and almost every successful GraphQL API has mobile apps as API consumers. When designing a schema and thinking about schema evolution, we should always keep in mind the upgrade behaviour of our consumers.
  8. Pagination is a must: It's ok not to opt into more complicated pagination styles like Relay's Connection type , but you should always have a way to paginate results. Pagination is not just important for clients to efficiently fetch a subset of a larger result set, but it's also an important building block to ensure the security and stability of your API. Without pagination, e.g. by limiting the result set to a fixed number of items, a cost-based rate limiter is unable to effectively protect your API against denial of service attacks.
  9. Abstract away implementation details: The other end of the spectrum to capability-based API design is to generate your GraphQL Schema from your underlying data models, REST APIs, OpenAPI specifications, or database schemas. While this approach gives you a schema without much effort, it's very unlikely to satisfy your API consumers. In addition, it's not possible to keep the client-side contract alive when the underlying services change.
  10. The Schema should match your organizational structure: Instead of blindly choosing a stack and patterns, think about how your organization is structured and design your schema to best support your employees and team(s). While a single team might benefit best from a single monolithic schema, larger orgs with many teams contributing to the schema should consider a federated approach.

Next, we'll dive a bit deeper into each topic and explain how to put the 10 principles into practice.

GraphQL Schema Design Principles: #1 Capability-based design

I see two major issues with Schema Design. Either the Schema is driven by the underlying data models, e.g. REST APIs or an existing database, or people are jumping the gun and design the Schema before having a clear understanding of all use cases and requirements. Without a clear understanding of the jobs to be done and the workflows of the clients, the schema will still work, but not necessarily in the best possible way for your API consumers.

So here's a simple exercise to get you started:

  1. Write down all the use cases and workflows that you need to support
  2. Derive the capabilities your API needs to support
  3. Cluster the capabilities into "subdomains"

All of this can be done with a Miro board, a Google Document, or a Whiteboard and Post-It notes. Once you have a clear understanding of the capabilities and clusters of capabilities, you should start designing the Schema.

Another method to improve the understanding of the capabilities and help create a better Schema is to define events and interactions of the system you're building. If we build a shop system, we could define events like AddProductToCart, RemoveProductFromCart, Checkout, PaymentSuccess, etc... The events will guide the Schema design by defining possible interactions between the different parts of the system. We can derive required capabilities from events, and then use the two to create entities and relationships between them.

You can use events and capabilities as a checklist of requirements. Once you've covered all the requirements, you can design the Schema and always test it against the requirements to verify that every use case is covered.

If you're looking for tooling to support capability mapping and Schema design, take a look at WunderGraph Hub . It gives your teams a multiplayer Miro-like canvas to design & govern a good Schema in a collaborative way.

GraphQL Schema Design Principles: #2 Client-centric design

As an extension to the previous point, it's important to design the Schema around the clients and their desired workflows. It's possible to design a Schema in a too fine-grained way where a client has to make many different requests for a single use case.

If you're defining events and jobs to be done for your clients, it'll be much easier to design the Schema to support these workflows with as few requests as possible.

In general, I recommend to design the Schema from the perspective of the API consumer, not from the perspective of the underlying data models or services. Ideally, we're able to abstract away the implementation details and keep the contract as a boundary between the API consumer and the API provider.

Furthermore, I'd like to emphasize the importance of designing a graph that is easy to traverse from a client perspective. It's much easier for a client to select a user, their top 10 posts, and the top 3 comments for each of them, instead of returning the user and IDs to their posts. While it's a good practice to return IDs or links to related entities when we're designing a REST API, idiomatic GraphQL favors a graph-like structure where a client can fetch related entities in a single request, just by requesting more nested fields with a selection set.

Example:

1
2
3
4
5
6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

GraphQL Schema Design Principles: #3 It's ok to repeat yourself

Another important principle is to accept that it's ok to sometimes repeat yourself. As described in an article by Marc-Andre Giroux on the MOIST Principle , it can be a mistake if you're trying to combine all possible fields related to users into a single User entity, even though your system has multiple concepts that sound like a User, but they are completely different use cases, and the specific types are used in completely different contexts.

An example Marc gives is a system that has a "Viewer", a "UserProfile" and a "TeamMember" type. Even though all of them might share some common fields like name, email, and so on, they serve different purposes.

While a Viewer might always have a profile, it's possible that we're not allowed to fetch profile information for TeamMembers. If we're using the same type for all of them, our GraphQL server would have to return null for the profile fields for TeamMembers. The problem with this is that we're not able to differentiate between "TeamMember will never have a profile" and "The current Viewer has no profile". The API consumer has to infer this from the response, which is not very transparent.

A better approach is to use separate types for each concept, even though it looks like we're duplicating some fields, but by doing so, we achieve more clarity at the expense of some duplication.

To summarize, combine fields that are related to the same concept into a single type, while keeping fields that form a similar shape but serve a different purpose in separate types.

Example:

1
2
3
4
5
6
7
8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

GraphQL Schema Design Principles: #4 Make expected errors explicit

When designing a GraphQL Schema, leverage the type system for every known outcome of an operation instead of just using generic errors for expectable "errors".

Using generic errors for expected "errors" can lead to a lack of clarity and expressiveness in the API contract. You should use the full spectrum of the GraphQL type system, including Union and Interface types, to cover such use cases.

With generic errors, clients have to somehow infer from the error message what the outcome of the operation was. The problem with this is that error messages are not typed and there's no guarantee that the error message is consistent and won't change over time.

An example use case is a field that returns an order status. The order might not exist, we might not have permission to fetch the order, or the order cannot be fetched currently because there's an error in the underlying system, e.g. a database error.

1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

In case of the second approach, the client code can easily handle all possible cases by switching on the union type.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

With the first approach, all we can do is check the message of the error object, but our user experience on the client side will never be as good as with the second approach where we can show the user very helpful information, e.g. if the order is not found or if they are not allowed to access it.

Like I mentioned in the section about designing for the client, expressiveness in known unhappy paths can make a big difference in the end user experience.

GraphQL Schema Design Principles: #5 Nullability and absence of data

You need to be very careful with nullability and making fields non-nullable. When building distributed systems, things can go wrong, so sometimes we simply cannot return a value.

If you believe that a user always under all circumstances has an email, you might be thinking that you should make this field non-nullable. However, like I said in the beginning of this section, in a distributed system things can go wrong, so sometimes we simply don't have an email for a user.

Alright, so we just make it nullable, problem solved, right? Not quite.

Another problem we have to consider is that we need to differentiate between "there is no value", "the value is null", and "the value is null because of an error". This is where semantic nullability comes into play.

With the @semanticNonNull directive, clients can assume that an otherwise nullable field is never null, unless the server returns a path-matching error.

This is a very powerful feature as it allows clients to assume that a whole branch of the response is never null, except when there's an error in the response that matches the path to the field. If you've got experience with building client applications, it can be very annoying to null-check a whole branch of the response because we have to assume that a field is nullable. With semantic nullability, we can respect failures gracefully and still have good ergonomics on the client side. Side note, Cosmo Router has support for semantic nullability , so you can use it today with your federated or non-federated GraphQL APIs.

Example:

1
2
3
4
5
6
7
8

In this example, the client can assume that email is never null in case of no errors in the response.

To summarize, consider that distributed systems can fail, distinguish between values that are absent, values that are null, and values that are null because of an error. In addition, use the @semanticNonNull directive to keep client code simpler with less null-checks.

GraphQL Schema Design Principles: #6 Null blast radius

When it comes to nullability, another important aspect we have to consider is the blast radius of a non-nullable field. As per the GraphQL specification, if a non-nullable field would return null, the server must return an error in the errors array of the response with a path to the field that returned null, and bubble up the null field to the nearest nullable parent field.

What this means is that if the nearest nullable parent field is the root field of the query, the server must return null for the entire response, even if other fields could have been resolved successfully. The more fields are affected by this, the bigger the "blast radius" of the non-nullable field.

Example for illustration:

1
2
3
4
5
6
7
8
9

If our server always fetches the user id and email from the database, we could assume that setting the email field to non-nullable is safe. We will always have an email or we return null for the user. This can be further enhanced by using database constraints to ensure that we never have a user with a null email field.

However, if our server is fetching the email field from another service over the network, we're no longer able to make this assumption. If the network call fails, we have to bubble up the error and return null for the entire user object, even if we're able to resolve other fields like the id or name.

Keep in mind that changing a non-nullable field to nullable is a breaking change. Clients could assume that a field is never null, so they might not handle the null value correctly.

1
2
3
4
5

GraphQL Schema Design Principles: #7 Assume that you can never deprecate a field

Depending on the audience of your GraphQL API, the next principle might affect you in different ways, but it's important to consider regardless as it's helping you to create a good mental model for API design. I'm talking about the assumption that you will never be able to deprecate a single field.

The sweet spot of GraphQL is when more than one client uses the API for different use cases, the more the merrier. The query language enables an organization to have many people across different teams and apps use the same API. This sounds great, but there's a catch.

The more successful your API, the harder it becomes to correct a mistake, sunset parts of the API, or simply walk back a path you're now unhappy about. More people using the API means that the investment is worth it, but at the same time it also means that more and more teams and apps depend on the current shape of the API.

Consequently, the more people depend on our API, the less likely it is that we can make them stop using a part of the API we're intending to change.

What's some practical advice on this one? First, think carefully before releasing something. Be sure that you're ready to support this API for a very long time.

One specific example on how this might affect you is when we think about wrapping 3rd party APIs. If we're forwarding (leaking) a 3rd party contract into our Schema, it means that throughout the lifecycle of our API, we should be able to stick to the 3rd party API. A better approach could be to abstract away the 3rd party API Schema and come up with our own generic SDL. This keeps the door open for us to change the implementation later.

1
2
3
4
5
6
7
8
9
10
1
2
3
4
5
6
7
8
9
10

With the abstracted approach, we can switch from Stripe to another payment provider without breaking our clients.

Similarly, we might be leaking internals through our API, e.g. by deriving the SDL from an OpenAPI specification or database tables. In both cases we should be aware that we're creating tight coupling between the internal systems and our API consumers. These anti-patterns will later make it harder for us to make changes.

The ideal API design allows us to continuously make internal changes without affecting our API consumers.

Another suggestion is to always use the @deprecated directive to help manage breaking changes of your API. Tools like Cosmo Schema registry automatically keep track of field usage, allowing developers to get notified when a field is safe to remove.

With the Cosmo tooling, you first mark a field as @deprecated to indicate that you intend to make changes. Next, you'll look into analytics to see which clients and client versions are using the field. (Ideally you're tracking both as this is very useful for this workflow) Once you know the clients, you can inform their maintainers to change their behaviour. Next, you can track in Cosmo Studio if all clients have changed their usage patterns. Once you can confirm in Cosmo Studio that usage for the deprecated field is at zero, you can safely remove the deprecated field.

GraphQL Schema Design Principles: #8 Pagination is a must

Pagination is a topic that some might think is optional. In fact, pagination is not just relevant for a good client experience, it's also crucial for security.

As a general rule, we should almost never return plain lists. I'm not advocating that everyone must adopt Relay cursor style pagination, but creating unbounded lists is almost always a bad idea for many reasons.

First, our goal should be to create a great experience for our clients. If you're thinking about the client experience, in many cases where a client shows a list to the user, it's very unlikely that we want to show all values at once. It's not just inconvenient, it's also a performance problem. We don't want to load thousands of posts from the database when the user is interested in the newest 20. In addition, the user wants to see their posts as fast as possible, so it's best if we can just load a few at a time. Once the first page is loaded quickly, the user might want to see some older posts, which means that we should have an efficient mechanism for pagination on the backend, while exposing it in a user friendly way in the Schema.

Aside from usability and performance, I mentioned that pagination is also relevant for security. Many websites, like for example eCommerce, are exposing their APIs to public web clients. This automatically attracts crawlers which try to gather pricing information. The problem with these crawlers is that they need to be rate limited, otherwise they might be overloading your system. Of course, there's not just crawlers but also bad actors who might attack your website, or just regular users who access your API from your website.

In any way, many platform engineering teams want to enforce rate limiting through static analysis. This means that they want to calculate the cost of a request and then rate limit users or IP addresses based on the cost. The challenge? How do you rate limit a query when you don't know if a list returns 20 or 1000 items? That's where pagination comes into play. For such APIs, you can simply enforce that each page must have at max 20 items, which allows you to set upper boundaries for the query cost, which the query cost algorithm can use.

One popular algorithm to calculate the cost of GraphQL Queries is the cost spec from IBM . It allows you to configure arguments influencing the size of lists.

If you're looking for a ready-to-use implementation of the algorithm, Cosmo Router supports it out of the box, handling query cost based rate limiting at the API gateway/router level, no changes to your infrastructure required. There's only one catch, your GraphQL Schema should have proper pagination implemented. So, please make sure to implement pagination properly in your Schema.

1
2
3
4
5
6
7
8
9
10
1
2
3
4
5
6
7
8
9
10

Again, we could implement a much more sophisticated pagination pattern like for example Relay connections, but providing first and after args with a default and a server-side validation rule to limit first to 20 is already very good.

GraphQL Schema Design Principles: #9 Abstract away implementation details

In the first two sections we were discussing capability & client centric design, and how it gives you a mental model to create a GraphQL Schema that really helps your clients to build great user experiences.

If you remember, the key to creating a GraphQL Schema that serves your clients well is to design the Schema around client use cases, workflows and jobs to be done. If instead, we're generating our Schema from an OpenAPI specification or database, we're essentially starting from a different mental model. Instead of focusing on the API consumer and their use cases, we're starting from the underlying data models or services. But not only that, we're also leaking implementation details into the Schema, and this is going to hurt us even more in the long run.

While it seems tempting to use this approach, you'll end up with a Schema that doesn't serve your clients well while maintaining it becomes harder over time. The main issue is that by generating our Schema from the underlying data models or services, we're creating a leaky abstraction, meaning that changes to the underlying architecture will break our contract and ultimately our clients.

So, what's a better approach?

We need to get back to the basics and leverage APIs to create a contract that can be maintained as a strong abstraction between our clients and the underlying systems. If we focus on use cases and workflows instead of data models and tables, we're going to build a contract where implementation details can change over time without breaking our clients.

We should be able to migrate from one database to another or replace a REST API with a different provider without our clients noticing. This is the key to a good API design.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

The first example leaks database naming conventions (table prefixes, foreign key columns, timestamp types) into the API. The second example presents a clean, domain-focused API that clients can easily understand and use.

GraphQL Schema Design Principles: #10 The Schema should match your organizational structure

Last but not least, let's discuss another very important aspect of GraphQL Schema Design: Don't fight Conway's Law. Build a Schema that matches your organizational structure. Conway's Law states that the structure of your software will mirror the structure of the organization that builds it.

This means that we can finally end the discussion about Monolithic vs Federated Schemas by following a simple rule: If a single team is building and consuming a single Schema, it's very likely that a Monolithic Schema is going to work best for them. Contrary to that, if multiple teams are building and consuming a unified GraphQL API, it's very likely that a Federated GraphQL API is going to be the best fit.

Of course, there will be exceptions to this rule, but in general, it's a good starting point. Also keep in mind that a monolithic GraphQL API is always also a federated GraphQL API with a single subgraph. At any point in time, you can introduce entities and add the second subgraph.

Example of mapping teams to subgraphs:

1
2
3
4
5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

Each team owns their subgraph and can deploy independently, while the Router composes them into a unified API.

Other good reasons to build a federated GraphQL API, even if you're smaller:

  1. Scaling: Some parts of your API might be much more heavily used than others. By moving some responsibility to a dedicated subgraph, you have more control over the performance and scalability of these parts of the API.
  2. Isolation: Keeping everything in a single service means that everything is failing together. Different parts of the system might operate at different scale, have different quality, different load patterns, etc. By moving some responsibility to a dedicated subgraph, you can isolate these parts of the system and make them more resilient.
  3. Experimentation: If you're building a new feature, you can experiment with a new implementation in a dedicated subgraph without affecting the rest of the API. Beyond that, tools like Cosmo Router allow you to create isolated subgraph compositions with feature flags, so you can gradually roll out new features to your clients.
  4. Multi-language-support: You might want to write the most critical parts of your API with Rust or Go to achieve the best performance and scalability, but then you're also interested in using TypeScript or Python because there's a very powerful library to work with LLMs you're keen on using. With a federated approach, you can use different languages for different parts of the API and you automatically have a means to combine the different implementations into a single API.

Conclusion

Designing a good GraphQL schema is both an art and a discipline. The 10 principles we've covered provide a framework for making thoughtful decisions that will serve your clients well over time.

Let's recap the key takeaways:

  1. Start with capabilities, not data models - Understand what your clients need to accomplish before writing any SDL
  2. Design for your clients - The schema should make their workflows easy, not mirror your backend architecture
  3. Embrace purposeful duplication - Separate types for separate concerns, even when they share fields
  4. Be explicit about outcomes - Use the type system to communicate all possible results, including errors
  5. Think carefully about nullability - Consider distributed system failures and the blast radius of non-null fields
  6. Plan for permanence - Assume every field you publish will live forever
  7. Paginate everything - It's essential for both user experience and API security
  8. Abstract your implementation - Keep the contract stable while internals evolve
  9. Match your organization - Let Conway's Law guide your monolith vs federation decision

These principles are not just theoretical - they come from real-world experience working with companies building GraphQL APIs at scale. Following them won't guarantee a perfect schema, but it will help you avoid the most common pitfalls and build APIs that are a joy to use and maintain.

Remember that schema design is iterative. Start with a solid foundation based on these principles, gather feedback from your clients, and evolve your schema thoughtfully over time. The best schemas aren't designed in isolation - they emerge from a deep understanding of client needs and a commitment to creating excellent developer experiences.

If you're like us and believe that good Schemas require collaboration and governance, take a look at WunderGraph Hub , our most recent addition to the WunderGraph universe. It's a Miro-like canvas for teams to build and evolve their Schema in a collaborative way.


Frequently Asked Questions (FAQ)

Capability-based and client-centric design are the most important principles. Before writing any schema, understand what your clients need to accomplish and design the schema around their workflows rather than your backend data models.

No, generating schemas from databases or REST APIs creates leaky abstractions that expose implementation details to clients. This makes it difficult to evolve your backend without breaking clients. Instead, design your schema around client use cases and abstract away the underlying systems.

Use nullable fields when data comes from distributed systems that might fail, and consider the 'blast radius' - if a non-nullable field returns null, the error bubbles up to the nearest nullable parent. Use the @semanticNonNull directive to indicate fields that are only null during errors while keeping good client ergonomics.

Pagination is critical for both user experience and security. Unbounded lists can cause performance issues and make rate limiting impossible. Always provide pagination arguments like 'first' and 'after' with sensible defaults, even for simple list fields.

Follow Conway's Law - if a single team builds and consumes the schema, a monolithic approach works best. If multiple teams contribute to the API, federation allows each team to own their subgraph and deploy independently while the Router composes them into a unified API.

Use union types to make expected errors explicit rather than relying on generic error messages. This allows clients to handle all possible outcomes with type-safe code and provide better user experiences for known unhappy paths.

Yes, it's often better to have separate types for separate concerns even if they share fields. For example, a Viewer, UserProfile, and TeamMember might all have 'name' and 'email' fields but serve different purposes and have different access patterns.

Assume you can never deprecate a field once published, especially if mobile apps use your API. Use the @deprecated directive to signal intent, track field usage with analytics, and only remove fields when usage drops to zero across all clients.

Jens Neuse

Jens Neuse

CEO & Co-Founder at WunderGraph

Jens Neuse is the CEO and one of the co-founders of WunderGraph, where he builds scalable API infrastructure with a focus on federation and AI-native workflows. Formerly an engineer at Tyk Technologies, he created graphql-go-tools, now widely used in the open source community. Jens designed the original WunderGraph SDK and led its evolution into Cosmo, an open-source federation platform adopted by global enterprises. He writes about systems design, organizational structure, and how Conway's Law shapes API architecture.