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:frontend and 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:
users table:
"firstName" but the backend looked for "first_name". The latter will always be undefined.
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:api.ts in our frontend code and use the same types to call the endpoint:
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:
onUserSignup() function, we’ll get a helpful type error:
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 thebackend repo to a private NPM package such as @myorg/api, which the frontend will depend on?
Let’s move backend/api.ts into its own directory with a new package.json:
api.ts and instead we’ll do:
frontend repo, we won’t be copy-pasting api.ts anymore. Instead we’ll need to:
- update the version number in
@myorg/api; - publish the new version to our private NPM registry; and
- update
@myorg/apito the latest version in thefrontendrepo.
- We can auto-generate a date-based version number based on the latest commit that touched the
apidirectory withgit 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: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 realize 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 thename 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
backendrepo - whichever version of API types is used in the
frontendrepo
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 frontend repo.
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 backend repo!
How can we solve this conundrum?
Well, the simplest way is to have a single repository for both frontend and 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.
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 realize 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 realize 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.
- 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.