Skip to content
See all notes
monorepoturborepotypescriptarchitecture

A monorepo with web, mobile, and backend sharing a domain

Sharing types between backend and clients is free. Sharing values — enums, error codes, constants — is not, and that is where monorepos break in production with the build showing green.

· 11 min read

The monorepo promise sounds obvious: if the backend and the clients talk about the same domain, let them share the code that describes it. A contract change breaks the build of whoever consumes it, in the same commit, before deploying.

It works. But there is a distinction almost nobody makes explicit, and it decides whether the monorepo helps you or blows up three weeks in: the difference between sharing types and sharing values.

The structure#

apps/
├── backend/          NestJS
├── admin/            Vite + React
├── portal/           Next.js
└── mobile/           Expo
packages/
├── api-contracts/    DTOs, enums, error codes  ← the contract
├── shared-kernel/    Result, Money, date helpers
├── ui-kit/           web design system
└── mobile-kit/       native design system

api-contracts is what DDD calls a Published Language: the vocabulary the backend exposes and the clients consume. It holds no business logic; it holds the shape of the data crossing the boundary.

Types are free#

import type { ExpenseResponseDto } from '@app/api-contracts';

There is no risk here whatsoever. TypeScript erases types at compile time: in the resulting JavaScript that import does not exist. It does not matter how each application bundles, or whether the package is transpiled or raw source.

Sharing types across four applications is the easy part, and it pays for the monorepo on its own: renaming a DTO field breaks all three clients' builds immediately.

Values are not#

import { TREASURY_ERROR_CODES } from '@app/api-contracts';

This is a different animal. TREASURY_ERROR_CODES is an object that must exist at runtime, so the import survives compilation and now depends on how each application bundles.

And the four do not behave the same:

ApplicationBundlerWhat it does with the package
admin, portalVite / NextBundles it from source. Works
mobileMetroBundles it from TypeScript. Works
backendNest's webpackExternalises it: expects to find it in node_modules at runtime

The NestJS backend bundles to a single dist/main.js and externalises workspace dependencies except those on an explicit allowlist. If you import a value from a package not on that list:

  • tsc --noEmit passes. The types are fine.
  • The tests pass. Jest resolves packages from source.
  • nest build passes. It only bundles.
  • And the process dies on boot with ERR_MODULE_NOT_FOUND.

All green, and the backend does not start. It is the most expensive failure I have had in a monorepo, because the three gates that should catch it are all looking somewhere else.

How I solve it: a mirror with a parity test#

The values the backend needs at runtime live in its domain, and api-contracts keeps a copy for the clients:

// apps/backend/src/modules/treasury/domain/treasury.errors.ts
export const TREASURY_ERROR_CODES = {
  INVALID_INPUT: 'TREASURY_INVALID_INPUT',
  EXPENSE_NOT_FOUND: 'TREASURY_EXPENSE_NOT_FOUND',
  CURRENCY_MISMATCH: 'TREASURY_CURRENCY_MISMATCH',
} as const;
// packages/api-contracts/src/treasury/errors.ts — the same set, for clients
export const TREASURY_ERROR_CODES = { /* … */ } as const;

Duplicating code like this is normally a smell. Here it is deliberate, and what makes it safe is that the duplication is watched:

// Fails the build if the two drift apart
import { TREASURY_ERROR_CODES as domain } from './treasury.errors';
import { TREASURY_ERROR_CODES as published } from '@app/api-contracts';

it('domain and published contract do not diverge', () => {
  expect(domain).toEqual(published);
});

A seven-line test turns dangerous duplication into boring duplication. If someone adds a code in one place and not the other, they find out in seconds instead of in production.

Metro has its own rules#

The mobile app consumes the packages as TypeScript source, not compiled. That brings a rule every shared package has to respect:

No shared entry point may have browser side effects: no window, document, or localStorage at import time.

A helper that checks window.matchMedia on load works perfectly in all three web apps and breaks the native bundle. And the error it throws does not mention window: it says a module is missing, or it blows up somewhere unrelated.

What Turborepo does and does not do#

Turborepo speeds up nothing by itself: it caches tasks according to what you declare.

{
  "tasks": {
    "build":      { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] },
    "type-check": { "dependsOn": ["^build"] },
    "test":       { "dependsOn": ["^build"] }
  }
}

The ^build means "build my workspace dependencies first". Without it, an app's type-check can run against a stale version of a package and report a false green.

And the cache has a known trap: if a task produces outputs you did not declare in outputs, Turbo caches an incomplete result and the next "successful" run does not generate the files. When something works clean and fails cached, suspect that before anything else.

Boundaries have to be policed#

A monorepo makes sharing easy, and that ease is exactly the risk: nothing stops the design system from importing the content engine, or one feature from importing another's internals. It gets crossed by accident, silently.

// dependency-cruiser: severity error, not warning
{
  name: 'ui-must-not-know-content',
  severity: 'error',
  from: { path: '^packages/ui' },
  to:   { path: '^packages/content' },
}

A rule written in a document erodes; a rule that breaks the build does not. It is the same principle I apply inside each backend with hexagonal architecture, one level up.

When I would not build a monorepo#

With a single application, never. The monorepo solves the problem of sharing between several; with one, it only adds configuration.

When the applications do not share a domain. Two different products whose only connection is the same authors gain nothing from living together: they gain coupling in the CI pipeline.

When deployment cycles are incompatible. If one part ships ten times a day and another once a quarter with certification in between, the monorepo forces coordination on things that do not want to be coordinated.

It pays off when several applications talk about the same domain and that domain changes. Which is exactly the case of a backend with its web and mobile clients: the day a workflow state gets renamed, you want to find out in the build and not from a client that can no longer approve an expense.

Got a system with this kind of problem?

Tell me what you are working on and I will tell you how I would approach it.

Let's talk about your system