TypeScript monorepo with no extra modules
Don’t Repeat Yourself, project edition
Only a few years ago, having a repository for every project made a lot of sense. That’s because, unless you had a really big company, you were likely to have only a few, completely independent projects.
Your GitHub org structure probably looked like this:
My Company
- website.git: The repo that houses your web server. This application servers up HTML and responds to GET/POST. It calls out to a database somewhere most likely.
- backend.git: Ok here's your serious API. The code in website.git spends a lot of time calling out to this guy.
- mobile-app.git: Your mobile app. Also talks to backend.git a fair amount.
So far, so reasonable.
What’s the problem then?
There are a few factors that have started making this approach cumbersome. For one, servers have become much less monolithic and more about groups of functionality with small and manageable interfaces. These functional groups can be deployed and iterated independently of one another, which vastly improves maintainability, test-ability, and a lot of other abilities. But it also necessarily means that you’ve got more projects to think about. We’re no longer talking about “the server,” but rather, “the services.” Do you really want 10 repos kicking about in your org? That’s a lot of version control to to think about, separate CI pipelines, etc.
But the biggest issue is this: although each of these functional groups can be thought of as “independent” in an environmental and business logic sense, they are actually very likely operating with the same schema logic.
Wait what?
In which I become an eye doctor
If you think about your product for a moment, you might be surprised to find that it is really just a database with a couple of different colored sunglasses surrounding it.
What I mean is this: imagine that you are writing a pizza delivery app. You’ve got your database, which is your source of truth for everything. It holds the information needed to coordinate and store orders, delivery details, timing details, users, etc.
You might also have four different repos:
- mobile app
- consumer-facing website
- pizza company facing website
- the service that handles new orders from customers.
But here’s the thing: all four of these repos are simply different “views” of your database.
You’ve got the green-tint glasses, which presents your database in a consumer-y way to your consumer: food selection options, deliver address entry, etc. The red-tint glasses present your database in a pizza-company sort of way to your pizza company: unfulfilled orders, delivery addresses, etc. Your blue-tint glasses are similar to the green, but they present your database in a mobile app-y consumer-y kind of way. In each case, your users are looking at the same database, they’re just looking at it through different colored lenses.
The big idea
Get ready for this.
*breathes in*
The essential logic in each of those projects is not the sunglasses, it’s the color.
The essence of your order service is actually the color of the glasses, not the glasses themselves. That cumbersome frame, fragile folding mechanism, and precision lens are not only expensive to duplicate in each project, trying to duplicate them comes with all the downsides of violating the DRY principle: lack of maintainability and four times more code (which is subject to decay).
Your product should actually have:
- 1 database
- 1 pair of glasses
- 4 plastic lens covers of different colors.
Think about that: how much easier is it to maintain a couple of thin sheets of colored plastic than four different pairs of glasses, whose only essential difference is their tint? When you need a new way of looking at the database, is it easier to whip together a whole new pair of glasses or just a thin sheet of colored plastic?
Please explain yourself
Let me bring this analogy home. The glasses are the interface to your schema. This might be connection configuration files, specific queries stored in .sql
or .graphql
files, testing or schema mocking infrastructure, rules or types for interacting with the data, commonly used functions to reformat the storage-optimized shape of the data into something more code-usable, and any number of other things. The point is, they’re not the business logic that your project is supposed to be performing, they’re all the prerequisite stuff that has to happen before that logic can operate.
So what are the colored lenses in this analogy? The colored lenses are the essential logic itself. That <1000 lines of code that do what readme file talks about.
The fact that each of these are doing the same thing in a different way guarantees that code will be duplicated in those repositories.
Ok so what’s the solution?
The solution is a monorepo, with as many projects written in the same language as possible. Right now, there’s no more ubiquitous language than JavaScript, and no better way to write JavaScript than with TypeScript.
So the solution is a TypeScript monorepo, with your “schema interface” (remember our glasses) contained in a single subfolder, and your many business logic projects (websites, services, etc) in their own subfolders, stripped down to their essential logic, and using the interface code.
monorepo
- glasses
- red lens
- blue lens
- green lens
- yellow lens
For our pizza app, that means you have a React Native mobile app, a couple of React websites, and a lambda or other cloud function, all written in TypeScript.
Most importantly, you also have your “glasses,” which you might call common
or schema-interface
, which is also written in TypeScript. Your other projects can import its types and functionality, and simply focus on the logic. Note: there’s no reason you can’t also pull out some of your utils
files to this common repo as well, if you find that utility functions for random stuff end up being useful across projects.
A quick plug for schema types
If you don’t have types in your code that govern how you interact with your schema, you should. Schema types don’t just add safety (preventing you from accessing the non-existent database field “ipzza” rather than “pizza”), they are also embedded documentation. The ability to use intellisense to find out the shape of the schema rather than having to go and find the actual schema (or worse, look at live tables) is incredibly powerful.
But most powerful is what happens when you change your schema. You only need to change your types to reflect your schema, and suddenly you have your IDE telling you exactly where your schema changes broke code across your project. It’s an incredibly powerful tool…
And it only gets better in a TypeScript monorepo
At HoodHub, our schema types are auto-generated at the same time the schema is generated, and stored in a top level project which all the other projects import. It’s really fun to make schema changes at HoodHub. Edit a few lines and watch your IDE highlight for you, across the entire project, which parts of your product need to be updated accordingly.
In the same project, we have common TypeScript utility functions, configurations, and test helpers. If you remember writing a binary search in one microservice and need it in another, don’t copy-paste it to your new microservice, and definitely don’t make a whole new private npm package for it, move it to common
and have both projects import it!
“But wait”
, you breathlessly say! TypeScript doesn’t support relative code imports above the root project level, and it doesn’t make sense to make the entire monorepo one big TypeScript project!
You’re right. Here’s the skinny. TypeScript doesn’t directly allow breaking the project barrier with relative imports, but it does allow you to define custom module resolution policies that have relative filepaths. This is not as scary as it sounds. It’s actually quite PG. Here’s an almost direct copy-paste from our internal readme on the topic:
This may seem like a lot, but it’s just allowing you to hack the configuration in a few different types of projects so that you can do two things:
- Import your code with
import { myType } from '@common/type';
- Use modules within your common code, even though it’s not itself a module. That’s the
'*': [...]
part.
The big thing to note here is that the common directory is not a module, it’s just some source files, so by all normal rules it should not be allowed to import things from node_modules
. In fact, the common directory has neither package.json
, tsconfig.json
, or any other config file, just plain old .ts
files.
This has been a lot to talk about, so lets recap.
Your product is almost certainly repeating itself a lot across a variety of projects. You should use a monorepo and a common language to distill these repeated chunks and focus on only maintaining the business logic. In TypeScript, you can accomplish this by having a folder that contains TS source files and editing the configuration for each project to reference that folder. If you want to import external modules in you common files, you need to provide each subproject with the "*": [...]
alias to help with resolution.
There are a lot of benefits to having a monorepo, but this particular issue was the one that had the most potential, and in its absence was my biggest hesitation. I hope that this solution has inspired you to give a shot!
— Isaiah Taylor @ HoodHub