Aug 30, 2022 • 22 min read
The power of the monorepo: Keep your fullstack app in sync!
APIs can be quite complex, often leading to implementation errors. How can you make sure the frontend and backend always integrate well with each other? Let's dive into an example codebase and walk through a few iterations to see how we can solve this, what new problems arise along the way, and how we can ultimately solve them with a monorepo.
A simple server/client relationship
Consider a team working on a React frontend and a Node backend that exposes a REST API. They have two corresponding repos on GitHub:
backend. This team is already well aware of the benefits of type safety, so they use TypeScript with strict settings on both codebases. They'd make @mattpocockuk proud.
When a user signs up, the frontend makes a POST request to
/users with the data provided in the sign-up form. This includes their email address as well as their first name:
The backend extracts the corresponding fields and stores them in the database's
The website launches. A few weeks go by. Then a product manager comes along and asks: how come all users have the first name "undefined"?
Read the above code again and you might spot the bug: the frontend used the field
"firstName" but the backend looked for
"first_name". The latter will always be
In isolation, both the frontend and backend are working perfectly. Unfortunately, a simple typo breaks their integration. This is an easy fix, and we could improve the situation with careful input validation on the backend, but how could we have prevented this from happening in the first place?
Types to the rescue
We can leverage TypeScript to eliminate this type of problem. Let's start by defining API request and response types explicitly in our backend:
We can use these types in our Express endpoint definition to benefit from type safety:
Now, we can fix the backend bug and proceed to the next step.
To ensure full-stack type safety, let's copy-paste
api.ts in our frontend code and use the same types to call the endpoint:
We can start using these types in our API calling code:
You'll notice we're not calling
fetch() anymore, but our own
fetchJson() helper. It's a lot like
fetch(), but it lets us provide a request and response type. Here is a sample implementation:
Now if we try to pass the wrong field name from our
onUserSignup() function, we'll get a helpful type error:
We've successfully added type safety on both ends of the network thanks to the wonders of TypeScript and copy-pasting!
Of course, this approach has one major flaw: it relies on copied code in your frontend and backend repos, which makes it vulnerable to all kinds of human errors.
Publishing API types as a private package
To mitigate human error caused by repeated code in the frontend/backend, what if we published the type definitions from the
backend repo to a private NPM package such as
@myorg/api, which the frontend will depend on?
backend/api.ts into its own directory with a new
We can now publish it to our private NPM registry. I won't go into the specifics of how to publish private packages here, but you can read up NPM's documentation here. Note that there are other options such as GitHub Packages and CloudSmith.
With this approach, we won't need to manually copy-paste
api.ts and instead we'll do:
Whenever the API type definitions change, in order to be able to use them from the
frontend repo, we won't be copy-pasting
api.ts anymore. Instead we'll need to:
- update the version number in
- publish the new version to our private NPM registry; and
@myorg/apito the latest version in the
With a little bit of extra work, this can be mostly automated. In particular:
- We can auto-generate a date-based version number based on the latest commit that touched the
git log -1 --date=format:'%Y%m%d%H%M%S' --format=%cd
- We can publish the private package from CI whenever the version number changes.
- We can use Renovate or Dependabot to automatically open a PR to update
@myorg/apiin the frontend repo.
A breaking change
Fast-forward a few months into the future. It's tech debt week, and a backend engineer is looking for old code to delete. They notice an old deprecated field in one of the response types:
Perfect, let's delete the
name field since it was deprecated more than a year ago! They send a PR, which is promptly approved by their peers, merged and deployed.
Immediately, the frontend starts throwing 500 errors in production. Panic ensues in the frontend team. After a few minutes, they realise the backend broke something. They loop in the backend team and get the deployment rolled back in a hurry. Phew! The website was only down for twenty minutes. Meanwhile, Dependabot generates a PR to update
@myorg/api to the latest version. CI runs and fails almost immediately: TypeScript compilation error.
It turns out that usage of the deprecated
name field was never removed from the frontend repo.
Using a monorepo
You could argue that this incident wouldn't have happened if the frontend team had carefully followed up on the deprecation of the
name field in February 2021. That is true, but mistakes do happen. Maybe the backend engineer who deprecated the field forgot to mention it in the release notes of the
@myorg/api package. Maybe the frontend engineer who updated the
@myorg/api package assumed it was already removed from the codebase. Maybe they assigned a ticket to someone else, who didn't follow up on it because they were overloaded with work.
Wouldn't it be better if we made that situation technically impossible in the first place?
The problem with a private NPM library, just like copy-pasting, is that at any given time we have two versions of the API types in our codebases:
- the current "up to date" version of API types in the
- whichever version of API types is used in the
If we make sure to always update API types as soon as possible, these two versions will generally be quite close. However, they are never guaranteed to be identical. In fact, whenever a change is made to API types in the
backend repo, the
frontend version will by nature lag behind. Our incident happened precisely when the API change was merged into the
backend repo, but not yet merged into the
I mentioned earlier that the PR to merge the API change into the
frontend repo would have failed. We would have wanted to know about this upcoming failure before it got merged into the
backend repo. But that's not possible because the API types package is only generated after the change is merged into the
How can we solve this conundrum?
Well, the simplest way is to have a single repository for both
backend, i.e. a "monorepo". With a monorepo, we don't need to publish API types to a private NPM package anymore. We don't need to version API types either. Instead, the
frontend app can directly depend on
api.ts, which is shared with the
backend app. It's the same file, with the same history. It's a single source of truth.
When we make the change in the
backend/api directory, CI runs for both frontend and backend apps, and PR checks fail, blocking the merge. This is what we want! We're effectively prevented from making a backend change that would break the frontend.
Once the PR fails, the backend engineer will realise that the frontend in fact does use this field. They will need to either update the frontend code themselves, or ask a frontend engineer to contribute to the PR.
Monorepos don’t solve everything!
It's important to realise that even with a monorepo, production incidents caused by API changes can still occur.
For example, let's say we merge a PR that does two things:
- it removes usage of
namein the frontend; and
- it removes the
namefield in the backend.
If the backend is deployed first, while the "old" frontend is still in production, we'll encounter the exact same production incident. This is why, even with a monorepo, it's generally a good idea to split this type of change into several PRs: first, a PR removing usage in the frontend, then another PR implementing the breaking change in the backend.
I recommend waiting for the first PR (frontend change) to be merged, deployed and stable in production for at least a few hours, ideally a couple of days, before merging and deploying the second PR (backend change). This helps for two reasons:
- A user might still have the old version of the frontend app open in their browser, which may crash once the backend change is deployed.
- A completely unrelated bug, perhaps a small mistake by another engineer, could require the frontend to be rolled back. If the rolled back frontend was built before the first PR, it will lead to the same production incident.
Here comes the ad!
Whether or not you decide to use a monorepo, it's a good idea to make sure you have a good monitoring solution in place to be notified when an incident occurs. In the stress of the moment, you'll want to have all the required context at your disposal. You can use Highlight to easily replay sessions that led to a bug, allowing you to narrow down the cause in an instant. This way, you can roll out a fix in a matter of minutes and be confident you've solved the underlying issue.
Get the visibility you need
In this post, we've gotten a glimpse of the power of a monorepo for code sharing across projects. This is only one of the many advantages of a monorepo. You can take it to the next level with shared libraries, codegen, and so on.
When you use a true monorepo—by that I mean a single repository for all projects in any language across the entire organisation—you may also find that working across projects gets significantly simpler, since you're no longer dealing with siloed Git repos and versioned APIs/libraries. Everyone has the latest version of the same code, and any engineer making a change that breaks another team will know about it when their PR shows failing tests, leaving that team to focus on their own work uninterrupted.
I plan to write more on the topic of monorepos (in particular how to keep your monorepo lean and set up CI efficiently), but in the meantime check out monorepo.tools to learn more about monorepos!
Other articles you may like
Oct 17, 2023 • 3 min. read
Jul 11, 2023 • 4 min. read