Small Teams, Big Wins: Why GraphQL Isn’t Just for the Enterprise
Many developers hesitate to adopt GraphQL for their fullstack projects, believing the setup overhead outweighs the benefits, especially for smaller teams or solo projects. Recent discussions around GraphQL often highlight its enterprise-level advantages, particularly in federation and tooling—as seen with WunderGraph Cosmo or Hive Router. But what if GraphQL’s power isn’t reserved just for large-scale systems? Let’s explore how GraphQL can offer tangible benefits, even for small teams or individual developers.
What is GraphQL?
Despite what the name might suggest, GraphQL is more than just a query language. It’s a versatile framework that empowers backend developers to define entities and their relationships, while enabling frontend developers to query the server using an intuitive, flexible syntax.
Instead of creating multiple views or endpoints on the backend for the frontend to consume, you define a single schema. This schema acts as the blueprint for what data is available and how it relates, giving frontend developers the freedom to fetch precisely the data they need to build their user interfaces.
A Simple Example
Before diving into why GraphQL is valuable even for smaller projects, let’s consider a quick example. A schema and query can showcase how backend developers define entities and relationships, while frontend developers optimize their queries to retrieve all necessary data in a single request.
1directive loggedIn on FIELD_DEFINITION
2
3scalar JSON
4scalar DateTime
5
6type User {
7 id: ID!
8 username: String!
9 email: String!
10 firstName: String
11 lastName: String
12 posts: [Post!]!
13}
14
15type Post {
16 id: ID!
17 content: JSON!
18 createdAt: DateTime!
19 updatedAt: DateTime!
20 author: User!
21}
22
23type Query {
24 me: User! @loggedIn
25 user(id: ID!): User
26 users: [User!]!
27 post(id: ID!): Post
28 posts: [Post!]!
29}
30
31type Mutation {
32 signUp(input: SignUpInput!): User!
33 logIn(input: LogInInput!): User!
34 createPost(input: CreatePostInput!): Post!
35 updatePost(input: UpdatePostInput!): Post!
36}
This example highlights how a GraphQL schema allows developers to define their API contract in an RPC-like manner, representing data through the Query
type and actions via the Mutation
type.
Beyond providing a type-safe schema (more on that later), GraphQL supports directives—middleware-like tools that perform checks, transform data, or log actions.
On the frontend, developers can leverage this schema to communicate exactly what they need from the server. They can even send multiple queries or mutations in a single request, often executed in parallel by backend frameworks.
For instance, to fetch a logged-in user’s posts:
1{
2 me {
3 id
4 posts {
5 id
6 content
7 createdAt
8 updatedAt
9 }
10 }
11}
Or to create a new post:
1mutation CreatePost($input: CreatePostInput!) {
2 createPost(input: $input) {
3 id
4 content
5 createdAt
6 updatedAt
7 }
8}
These query documents can be sent using a simple fetch
call to the /graphql
endpoint provided by the backend or, even better, with advanced clients like Apollo, Relay, or URQL to unlock GraphQL’s full potential—features I’ll delve into later.
Benefits for Backend Developers
Now that we’ve seen what a GraphQL schema looks like, why should we invest the effort to define one and find the right tooling to implement it on our server?
No More BFF
With GraphQL, you write your schema once and use it everywhere. This reduces the need for constant synchronization with UI designers and frontend developers about which API endpoints should include what data. Instead, you can focus on business logic and building a robust API capable of supporting mobile apps, desktop clients, and more in the future.
Type-Safety
Thanks to its schema-first approach, GraphQL server libraries automatically validate incoming and outgoing data types. For custom types, each library offers its own mechanisms for parsing and serialization, ensuring a consistent, type-safe experience.
Code Generation
Building on type-safety, GraphQL’s schema provides detailed information about your models (or DTOs) and query/mutation functions, enabling ecosystems to generate boilerplate code automatically.
Take GraphQL Codegen, for example. Using its typescript-resolvers
plugin, you can generate resolver types that ensure TypeScript validates their signatures, even as your schema evolves. This also means typed inputs for resolvers, so you no longer need to parse route params, search params, or JSON bodies manually—just accept arguments and pass them to your services.
Code Organization
Effective code organization is crucial for scalability. As a Go developer, I value Go’s standard library but recognize the importance of structuring packages to avoid bloated controllers or service files as the backend grows.
At InnoPeak, we use GQLGen for our GraphQL services. It offers excellent support for modularizing schemas. By splitting your schema into multiple .graphqls
files and using the extends
keyword, you can maintain clear boundaries between domains. GQLGen then generates corresponding .resolvers.go
files with ready-to-fill function templates:
1// CreatePost is the resolver for the createPost field.
2func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*model.Post, error) {
3 panic(fmt.Errorf("not implemented: CreatePost - createPost"))
4}
5
6// Posts is the resolver for the posts field.
7func (r *queryResolver) Posts(ctx context.Context) ([]*model.Post, error) {
8 panic(fmt.Errorf("not implemented: Posts - posts"))
9}
This modularity ensures cleaner, more maintainable codebases that can scale alongside your project’s requirements.
Taking the example schema, we can now split it into three files:
1directive loggedIn on FIELD_DEFINITION
2
3scalar JSON
4scalar DateTime
5
6type Query
7type Mutation
Benefits for Frontend Developers
If you're a frontend developer, GraphQL has a solid offering of client libraries that do much more than your usual fetch
or useSWR
implementation. The benefit of GraphQL, is that the server communicates much more than just types, but also behavior via the special Query
and Mutation
types.
The popular GraphQL clients for React include Apollo, Relay and URQL. I'll be focusing on Apollo client in this post but most of the features we'll be discussing are shared between these clients.
Caching
Apollo client supports normalized caching which is a pretty powerful feature, but it's a lot of manual work to set up with a simple REST API. Tanstack Query and RTK are similar in that way, but you have to use cache keys for Tanstack or set up your own stores for RTK, and you can't merge and share data across the application as easily.
Normalized caching means that the client will track individual entities, like a User
or Post
and merge any updates triggered by a new query or mutation into the main cache. Entities are tracked by their id
attribute, which can be customized if you're using a different primary key. When you run queries you can also configure a fetchPolicy
depending on how recent the data should be.
Deduplication
Apollo client deduplicates requests when multiple components fire off the same query on a page. This helps you write your components in a way that they aren't dependent on prop-drilling/context, without needing to worry about making unnecessary requests.
Type-Safety & Code Generation
We mentioned it on the backend, but the same advantages you get from generating types on the backend apply to the frontend too.If you've got a GraphQL schema, you can use the GraphQL Codegen CLI to generate types for your queries and mutations. Then, you can use those with the client of your choice to get type-safe queries and inputs.
Pagination
Pagination can be a complex task, especially when talking about infinite scroll UX. On top of paginating the data, you also have to merge the results and keep track of the page or cursor.
But don't worry, Apollo client handles this all for you. If you're using Relay-style connections with cursor-based pagination, they offer a plugin that will automatically merge pages for you, making this feature a breeze.
Subscriptions
Subscriptions send real-time updates straight to your query client, letting it know right away when data has been updated or added. For simple subscriptions that notify you of updates, the client can merge the new data into the normalized cache for you. In more complex cases, such as when a notification is sent or an entity is added, you can handle the event yourself.
Fragments
Fragments are reusable selections that you can apply to any query without having to repeat yourself. Here's an example of a fragment to select certain fields from a Post
:
1fragment PostFields on Post {
2 id
3 content
4 createdAt
5 updatedAt
6}
Looking at our previous query and mutation examples, we repeated these post fields and can make use of the fragment to simplify them:
1{
2 me {
3 id
4 posts {
5 ...PostFields
6 }
7 }
8}
Another perk of using fragments is that we can use fragment-masking to write more reusable components that require fragments instead of concrete types or generated query types. This means they can be used by any component making a query/mutation that fits the selection.
Wrap-Up
GraphQL isn’t just a tool for large enterprises or massive federated systems—it’s a game-changer for projects of any size. From eliminating the need for countless endpoints to streamlining development with type-safety, caching, and modularity, GraphQL empowers developers to build robust and scalable solutions.
That said, it’s important to acknowledge the transformative role federation plays for large teams. By enabling teams to decouple their APIs and work with their preferred tech stacks, federation brings everything together into a unified schema. This allows organizations to scale their systems efficiently while fostering collaboration across diverse teams.
Whether you’re a solo developer working on a side project or part of a large team managing complex architectures, GraphQL has something valuable to offer. So why not take the leap and see how it can simplify your development workflow?