Rediscovering SOLID Principles with GraphQL

Angela Lee
Prodigy Engineering
13 min readJun 29, 2022

--

SOLID Principles are originally associated with Object-Oriented Programming. In refactoring our architecture to leverage GraphQL, we rediscovered these design principles, and in so doing, discovered what makes GraphQL invaluable.

We wanted to build a clean “backend-as-a-frontend” service using GraphQL, however, there were many challenges to tackle before we could reach that goal. Our earlier systems had both architectural and organizational issues that exacerbated each other as we grew.

  • Teams were formed temporarily to create a specific feature, then disbanded. This created services that were not owned by anyone.
  • Business logic that didn’t fit anywhere else ended up in a “catch-all” service called the Web API, and it was becoming increasingly messy.
  • Lack of ownership over the early GraphQL project meant that there was no one upholding quality standards. For example, components were duplicated in ways that violated reusability principles.
  • Lack of clear boundaries between services made developing and deploying services difficult due to unknown dependencies. It also created circular dependencies.
  • Difficulties coordinating work between teams
  • Our business logic was tied to our detail implementation (specific tech stacks), which is an anti-pattern.
  • Building new features became increasingly slower and difficult due to the above

A much larger refactoring project had to be undertaken, and in this article I will explain just how GraphQL aided and supported a better architecture at scale. In so doing, we might be surprised to rediscover some classic programming design principles that drive GraphQL.

The Great Refactoring

In the beginning, we had a monolith.

Then as we scaled, we split into services, working towards a service-oriented architecture (SOA). This is a simplified view of our architecture at the time:

messy dependencies

Everything is connected to everything else! This is indirection madness! The actual diagram is much worse — think ten times more components and ten times more dependencies. Yikes.

Following the arrows you will see that every API got access to every database model by default. Sharing a database is fine, technically, but the issue is that we were sharing models across services within those databases. API 1, 2 and 3 are all calling Model 2. This created dependencies between models and services, and consequently no one was able to update any database without checking all the other dependent services. And of course, no one could deploy changes in production at the same time.

“We have a boundaries problem” — someone very observant.

That was a big problem.

The great GraphQL refactoring project aimed to fix this. In a strategy known as the The Strangler Pattern, a monolith can gradually be converted into a services-oriented architecture:

  1. Functionality that previously existed in the monolith can be separated into its own service
  2. New functionality can be built as its own service, instead of inside the monolith.

Over time, the services will grow larger in number and scope, while the monolith shrinks in functionality. As we begin to rely more and more on the services, the monolith is “strangled” out of use. The analogy is a strangler fig that grows upon a host tree, using its height to reach a light source, and at the same time overtaking its root system, and eventually killing its host tree.

In order to execute the strangler pattern, we first decommissioned all our services and combined them back into a monolith project within GraphQL. This was also useful because the only service talking to the datasources was GraphQL, which made those connections easier to manage. Then began the hard work of examining all those services as a whole and determining which functionality changes together, and which does not. The functionality that changed at the same time were grouped together and given their own “domains” to be owned by individual teams.

Many of our entities were related to each other in ways that did not reveal any obvious patterns of use, for example with our classrooms, students, and assignments. How they should relate to each other was unclear and confusing and the work of untangling these entities was arduous. For the most part, we have been successful in separating out functionality into its own services, but some work is still in progress and a few of the earliest datasources exist in its pre-refactoring state. These databases reflect our earliest and most essential models.

We have come a long way in our refactoring journey from a monolith, to an early stage service-oriented architecture, to a better architecture served by a GraphQL gateway. There have been many rewards to this refactor, but before I can lay out those benefits, I would like to explain something else. To understand how we decoupled circular dependencies from services, and to appreciate the benefits of working with GraphQL’s schema, we must examine the design principles of GraphQL.

The Design Principles of GraphQL

Architecture is not about whether the code ‘works’. Yes, that is important, but the job of architecture is to make it easy to develop, maintain, reuse, and deploy. Robert C. Martin (also known as Uncle Bob) described five principles guide us in the best ways towards that goal, called SOLID. There are several principles that apply, including the Open-closed principle, but let’s focus on the last “D” of the SOLID principle, Dependency Inversion. Let’s first understand what is a dependency.

This principle can be considered on multiple levels: the class level, or a larger module or even service level. For simplicity I will start with class code examples but later apply the same ideas for larger components.

Consider the relationships between components. We have two classes, a Teacher and Viewer class, where Teacher extends from Viewer. Teacher will be susceptible to any changes that happen in the Viewer class. The Viewer class however, doesn’t know Teacher class even exists, so any changes to Teacher class won’t affect Viewer. We say that Teacher is dependent on Viewer. This we call the direction of dependency: Teacher→ Viewer.

If more classes depend on the Viewer class, the Viewer becomes more rigid: it gets harder to change, because for each update we have to check all its implementations elsewhere.

For the Student class though, no one is relying on it, and if it decides to update a field, only the Student class itself needs to be changed. The Student class is more flexible than the Viewer.

This is fine, because that’s what we signed up for when we created the Viewer, we wanted some shared functionality. We planned for Viewer to change less frequently, and its inherited classes to change more frequently. We call the Viewer stable, and the Teacher, Student, and Parent classes unstable.

In practice, stability is not binary, some components have more dependencies than others, and some are a mix of both. In general though, we can capture the sense of “this is more stable than that.”

Problem 1: Dependency Loops

We can have many, many components referencing many, many other components. Unstable components are easy to change, while stable components are not, but they can still be updated if required. However there will be an issue when dependencies form a loop:

Oops. Teacher extends Viewer, but Viewer also extends Teacher. If we could change both in one project and deploy it together, that’s fine. But what if this represented dependencies between two teams’ APIs? Which change should we deploy first to production?

One of the problems we had was how several services were referencing each other. Imagine how hard it is to make a change in one service, when you realize that in order to deploy your changes, you have to check with four other teams, if your changes will affect them too.

The dependency loop is at the core of the boundary problem. A service, in order to do its work, needs to know about the other, and vice versa. Now if a developer wanted to make a change to their own API, he/she has to go learn about the deployment processes and PR reviews of another team, request feature flags, and the headache goes on!

Do you remember this diagram? Each API was referencing one or more models, making it really hard to make changes to the models without knowing what service would break because of our changes:

Imagine if we cleaned up those dependencies a bit, so that we knew exactly which API used a specific model. Much better!

The above is pretty much the first stage of the GraphQL refactor: we took all our services and put them back into a GraphQL monolith. Now, at least we know that there is only one API calling any model — the GraphQL service itself.

However, inside the GraphQL monolith, the APIs are technically still strongly coupled and we haven’t solved the boundary problem. It’s really hard to detangle services because they are dependent on each other for a good reason: their business use cases compel them to. For example, if you have a classroom entity that is shared equally by a teacher API and a student API. A possible strategy around this is to categorize two types of business logic:

  1. Try to keep all business logic related to a specific API inside that API only
  2. Any business logic that is related to other components moves into the GraphQL schema

Later, we will look into GraphQL and see why it is great at organizing dependencies in a way that is really clear. This is also a good introduction to why separating higher-level from lower-level components are so valuable, and we will discuss in a later section.

Problem 2: Updating business logic in stable components

Let’s say we had many components in a system and we mapped out their dependencies. It might look something like this:

We don’t have loops in this diagram because all arrows flow in one direction that ends at Viewer. When we place our dependencies from top to bottom in the direction of its arrows, what’s on top is usually what is stable and what is on the bottom is unstable. The most stable components can often be the most essential to the business. In this diagram, Viewer is at the top because many entities depend on it. The things that are least dependent are the most flexible, and entities in the middle are somewhere in-between. Let’s say the Student entity was created way before the newer School Mascot entity, and the business is more dependent on the Student entity. The School Mascot user may be a lot more flexible and newer than a Student user, which may be harder to change and have a lot more references.

It’s great that at a glance, we can see where the most important policy of our business value lies, (in Viewer), but from a business perspective this makes it the most important feature to iterate on and build features, which implies it also needs to change frequently. How can it be stable and change frequently at the same time?

The Value of Schemas

We’ve observed two problems:

  1. Circular dependencies
  2. How to make components that are stable, yet flexible at the same time

I want to review the design principles that are advised in these situations, and then show that GraphQL’s schemas are the perfect application of this. Schemas are type definition files similar to typescript. They define type classes and their fields, with each field’s data type also defined, ie. String or Boolean.

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) states that high level modules should not depend on low level modules; both should depend on abstractions

Imagine you have a situation where a boss tells a developer directly what work items to do. “Here’s work, and I want you to do it.” This is direct control. Now imagine the boss putting that work item on a task board, and the worker later picks up that item from the board. The boss is saying “here’s work, someone please get this done, but I don’t care who or how.” The task board is an intermediary that gives both the boss and developer the freedom to work without caring about each other.

This is what an interface can do. It says ‘Make sure the Viewer type has x, y, and z, but I don’t care how you get it.’ and that can break up any dependencies that need to know about each other.

In GraphQL, the Schema Definition Language (SDL) allows developers to write type definitions representing business logic, separately from its implementation in resolvers. In this code example, the schema is not dependent on the resolvers directly, but indirectly through the Apollo Server framework. (Other GraphQL frameworks have different names for the same method)

Here, the schema is defined in typeDefs and implementation is defined in resolvers. They are combined in the ApolloServer which keeps type definitions independent from its implementation. Separating the schema and implementation allows client-side and server-side developers to work independently of each other: agreeing on use cases beforehand and mocking out return values before they are completed.

Let’s imagine that we have an Education API service that served the Web API, but also depended on the Web API to communicate with other services. This is very hand-wavy, but you can imagine HTTP requests moving in this direction:

Internally, GraphQL can break up this dependency with the schema. It’s an abstraction that other parts of the code refer to, yet the schema itself doesn’t have any implementation details.

The dependency inversion happens thanks to the schema. In my opinion, the schema is absolutely the most important part of developing with GraphQL for this and many other reasons. The use of DIP is one of the pros of a schema-first approach to GraphQL, with the alternative being a code-first approach, where schema is automatically generated from the implementation. Personally, I see the latter being used less and less, and DIP is one of the big reasons why.

Stable Abstractions Principle

The more depended upon a component is, the more stable it is. The more stable it is, the more abstract it should be.

We’ve completed the work and the API is serving the client app according to the SDL types that both teams have agreed to, and those types are resolved by the REST API built by the API team. They deploy to production, and it’s a hit. Millions of users worldwide start to use this new feature. That’s great, but now the schema will get harder to change. What if we want to extend its functionality with a new getDiscountfield? When we already have a lot of code implemented, it can be tough to make the exact change we need, and at the same time to coordinate those changes with the clients who are consuming our API too. The problem here is that many, many components are relying on this, and that makes it harder to change.

This is a tough problem either way, because dependability and flexibility tends to run in opposing directions. However a compromise to this is the use of abstractions. Because an abstract schema won’t be so entangled with the details of implementation, we can PR this change separately, and everyone referencing this can adapt independently.

Back in the day, when statically typed languages ruled the world, components had to be re-deployed whenever their imported components changed. If there was an important component, say something that was core to the product, and that others imported and used, change was actually impossible without re-deploying the entire system. The workaround to this is to import an abstract interface instead. Something is totally abstract if it’s just an interface with no implementation detail. When there’s no implementation, there’s less that could ‘break’ and we win back some flexibility.

We see examples of this in other places, where major libraries export typescript files so that projects don’t have to update packages every time someone makes a minor change, as long as the typescript interface stays the same. GraphQL uses the same principle, but what’s really cool is that, instead of being a library, it’s a design principle that we apply to our entire architecture.

Another great value of schema is the separation of policy and detail. By separating the parts that are most important to our business and are irreplaceable, from the implementation detail, we make our system more flexible. If tomorrow there was a newer, shinier datastore, we could swap that in. If Javascript no longer worked for us, we could switch to Python or Ruby, but our Schema file could still be reused. Not only that, but schemas are readable to non-technical people. The power of the SDL in capturing business logic has been game-changing in the way we develop new features. It allowed our parent mobile app team to easily learn and integrate our complex set of features.

Conclusion

We can summarize the benefits we have realized from refactoring our services under the GraphQL gateway:

  • Removal of (most) circular dependencies between services, making them easier to develop and deploy
  • A process in which teams can coordinate work, by owning their own “domains” and therefore sharing the maintenance load of the GraphQL project
  • Separation of business logic from implementation details, meaning our architecture can adapt to new technology stacks
  • Easier for disparate teams to agree on an API contract and work independently and complete work earlier
  • Having our API in one place that is reliable for many teams, yet flexible when it needs to be changed as business demands change
  • Self-documenting business logic that is readable by non-technical people, external teams, onboarding developers, and everyone else in between
  • Easier to iterate building new features, accelerating product innovation

We are now realizing the amazing power of the GraphQL gateway and how eloquently our schemas express a decade of iterations on our product. Our optimized architecture will allow us to combine features across APIs easier than ever before, and create even more interesting and valuable products that are greater than the sum of its parts.

Sources

I’d like to thank the GraphQL team for their reviews and contributions

Clean Architecture, Robert Martin

Code first vs. Schema first

On GraphQL using the Open-Closed Principle

--

--

Angela Lee
Prodigy Engineering

psychology and philosophy student turned startup developer