Full Stack TypeScript Monorepo

This is the full extent of the process to setup your environment as a new developer at Mable:

  1. Install Node.js, Docker, and Yarn.
  2. Clone a single git repo.
  3. Run yarn run everything.

...and that's it. You now have an entire company's worth of technology, running locally in a few minutes with a single command. yarn run everything will install all the dependencies you need, compile everything, start file watchers to re-compile anything whenever you change it, and start up everything that can start up. This includes our backend services, our mobile apps, and dev servers for our websites. It takes some time to spin up the first time you do it, but once it's running, any change you make to any part of the stack is reflected in what's running within seconds. Here's an example of editing the mobile app and website side-by-side, in the same editor, with quick reloading:

Editing and hot-reloading the app and website side-by-side

This is, by far, the most developer-friendly full-stack setup process I have ever seen.

We also have full support for IDE features like autocomplete, go to definition, automatic refactoring, etc. across the entire stack. For example, this is what it looks like to call one of our backend APIs from web frontend code:

Calling a REST API with autocompletion

This is also exactly the same code you would write to call our APIs from a unit test, from a mobile app, or from another backend service. We're doing some fancy code generation from our OpenAPI spec to get that sweet, sweet autocompletion, but that's a topic for another blog post.

So - how did we create such an enjoyable development experience?

One Language

Everything we build is written in TypeScript. Our backend runs on Node, our mobile apps are built with React Native, and our websites are built with React. TypeScript has amazing tooling support, bringing back all the helpful IDE features that are missing from the JavaScript development experience, and the ecosystem is massive - I don't think I've ever failed to find a package to do something I didn't want to write myself.

A huge benefit of settling on a single language is that everyone is now a full-stack engineer. Of course, some people know more about writing SQL queries and some people know more about building React UIs, but a large amount of what keeps people from moving freely between areas of the stack is the accidental complexity incurred by using multiple languages and their associated tooling. In the usual multi-language setup, if you're an iOS developer and you want to make even a small change to the backend, you need to go download a new IDE, figure out how to install dependencies, figure out how to actually startup and test the backend, and so on. All of these things will work totally differently than the iOS toolchain you're familiar with, and so you'll probably end up trying to recruit a backend engineer, which slows everyone down, or working around the existing backend behavior in your client code, which causes a buildup of technical debt as every level in the stack works around everyone else.

With a single language and toolset, when you want to make a change to another part of the stack, the only thing standing in your way is the essential complexity, the ways that backends are intrinsically different than mobile apps due to their functionality. Everything else, you already know. You're already an expert user of an editor with excellent language support for the backend, since it's the same one you're using for the app. You already know how to spin up the backend, since it's exactly the same as spinning up the app - you just yarn run dev. And you already know how to manage dependencies, how to run the unit tests, and how to use the language idomatically, since all those things work the same way as well.

Using React everywhere also makes it even easier to move between our various frontends, since all your React skills can transfer directly between platforms. We even use React on the backend to render our emails, React-pdf to create PDF attachments, and Next.js's static rendering to build this very blog with React.

One Repo

Another source of accidental complexity that makes it needlessly difficult to move around the stack is splitting code up into multiple version control (in our case, Git) repos.

The "traditional" way to develop complex TypeScript (or JavaScript) applications would be to break our code up into packages, and then have each package - i.e. a folder with a package.json - live in its own Git repo. So, if we have some shared code in a common-lib package, an api-client package, a backend service, and a website, that's 4 separate repos. This seems like the natural thing to do - you want to maintain separation between your packages, after all - but it adds constant friction to your workflow. Now, when you want to update common-lib, you have to make your change, put it in a pull request, get that merged, publish your new version to NPM, and then go through every package that depends on common-lib, update it to depend on the new version, make a pull request... you get the idea. This friction persists regardless of how small the change you made was. As Babel's "Why is Babel a monorepo?" doc says:

Juggling a multimodule project over multiple repos is like trying to teach a newborn baby how to ride a bike.

If you're using TypeScript, the repo-per-package setup also destroys the usefulness of "Go to Definition" in your editor - since you generally don't publish your TypeScript source to NPM, Go to Definition lands you in a generated .d.ts file with just type definitions and no source code. Being able to jump directly to the implementation of a function is extremely helpful when navigating a complex codebase, and losing that removes a lot of the advantage that TypeScript has over vanilla JavaScript.

Furthermore, when you change a common package in a multi-repo setup, it's difficult to know whether you're breaking anything that depends on that package. You generally need to publish your change, go through all the repos that depend on it, update them to the new version, and then see if it works. Then, if the update reveals an error in your new code, you've got to go back to the common package, publish another version, etc. This pain tends to encourage people to make as few packages as possible, and discourages moving duplicated code into shared packages.

But what if we just... didn't make a new repo for every package? Upon closer inspection, all the benefits of splitting up your code - modularity, reusability, separation of concerns, etc. - result from breaking things up into packages, not from breaking things up into repos. TypeScript prevents accidental cross-package imports, so our packages are just as isolated from one another as they were in the multi-repo setup. In fact, we've found that having all our packages in one repo actually makes it easier to split things up. Since breaking some code out into a new package is as easy as creating a directory, it's much less onerous to do so, and so it happens that much more often. Moving everything into a single Git repo has actually encouraged us to increase the modularity of our code.

There are many other advantages to having everything together in a single repo. You can make a single pull request that changes the database schema, adds some new data to an API response, and adds client logic to make use of that new API. This entire unit of work can easily be reviewed as a unit, making it much easier to notice a bug whose cause spans across multiple packages. Developers also get immediate feedback if they make a breaking API change to a shared package - the TypeScript compile will fail, so there's no chance of the change getting merged until everything that depends on that change is updated accordingly. The monorepo also makes building the yarn run everything magic at the beginning of this post super easy.

Of course, good things never come easy, and getting this monorepo setup to work smoothly required us to solve some tooling issues, but that's also a topic for another blog post. The short version is, it's Yarn workspaces + Lerna + webpack + Monopack.

The Future

Reducing unnecessary platform-switching friction lets us move faster. It makes our code better, because people are more likely to make a change in the right part of the stack rather than making suboptimal changes to the parts they're most familiar with. Using a single language means that we're always working with a language and tooling that we're familiar with, and there's just fewer things to learn because so much knowledge can transfer between areas of the stack.

As of the writing of this post, Mable is an early-stage company with a four-person engineering team. That means we have a lot of growth in our future, which will inevitably come with a lot of technology changes. Complexity growth in software is always difficult to control, but I think that setting a good direction early on can save us a lot of technology pain in the future. We have a great foundation to build on, and I'm very excited to see how this setup can evolve with the company.

If any of this sounds interesting to you, we're hiring engineers.