From 10+ copied Angular projects to one Nx monorepo
On this page
The problem: a copy is a debt that grows
The pattern is familiar. There’s an app for the first customer. A second one comes along: “we want the same, but with our logo and a couple of differences”. The quickest thing is to copy the folder. A year later there are ten copies, and:
- A bug lives in N places. Fix it in one copy and it stays in the others. You find out from a user.
- The copies drift apart. One got a fixed form, another a fixed table. Six months on, these are ten different apps with similar file names.
- Framework upgrades multiply by N. Each copy is upgraded separately, and almost always one of them lags several versions behind.
The key thought: the cost of a copy isn’t at the moment of copying but in every change after it. If a change has to be made in N places, its price is N × (work + chance of forgetting).
What an Nx monorepo is, in plain words
A monorepo is a single git repository that holds several apps and libraries. Nx is a tool that knows what depends on what and can run builds, tests and lint only for what a change affects.
The structure we arrived at:
apps/
region-a/ ← тонкая оболочка: конфиг, логотип, переводы, окружения
admin-region-a/
region-b/
...
shared/
assignments/
data-access/ ← сервисы, HTTP, сторы
feature-assignments/ ← страницы и диалоги
meetings/
users/
...
ui/ ← презентационные компоненты без данных
ui-widgets/ ← компоненты, которым нужны данныеA region’s app contains almost nothing. It plugs in the shared routes, shared interceptors and shared UI. The differences are the logo, the API address, translations and the set of enabled modules.
What to move first
The most common mistake is trying to move everything at once. The order that worked:
1. First the “leafiest”: the UI kit
Buttons, inputs, tables, dialogs. They have no dependencies on business logic, so they can be extracted without breaking anything. And you immediately see how many copies of one button piled up over the years.
2. Then data-access: services and models
HTTP services, response interfaces, stores. This is where copies drift the most: a field renamed here, a parameter added there. Every difference is a question: “is this a customer’s specific need or someone’s bug?” Usually it’s a bug.
3. Then features: whole pages
Only when UI and data are already shared does a page move over almost mechanically.
4. Last, the shell apps
When all modules are in libraries, a region’s app comes down to configuration.
Why this exact order: you can only move something whose dependencies have already been moved. It’s an ordinary topological sort of the graph: first the nodes others depend on, then the ones that depend on them.
Library boundaries: dependencies in one direction only
The rule the whole thing was started for:
apps / feature-* → ui-widgets → data-access → uiAn arrow means “may import”. There are no backward arrows. ui is a leaf of the graph: it knows nothing about services or stores.
Where to put a new component is decided by three questions:
| Question | “Yes” → where |
|---|---|
| Needed by one screen only? | in components/ of its feature library |
| Needs services or stores? | in ui-widgets |
| Pure presentation, needed in several places? | in ui |
How we got burned: an import cycle
At first there was no ui-widgets, and components with data sat right in ui. That made a cycle: ui imports a service from data-access, and data-access imports a type from ui.
While the cycle is small, the builder copes. When it grew, Angular’s NG0919 error (“Cannot read @Component metadata”) appeared at runtime: the component class from imports: [...] hadn’t been initialised yet when it was read, because modules in a cycle load in an unpredictable order.
The cure isn’t “change the order of imports” but breaking the cycle: move the components with data into a separate ui-widgets library that sits between ui and the feature code.
A machine should check the boundaries
Nx has the ESLint rule @nx/enforce-module-boundaries. A teaching example of what it looks like with tags:
// eslint.config.mjs (учебный пример)
'@nx/enforce-module-boundaries': ['error', {
depConstraints: [
{ sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui'] },
{ sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:ui', 'type:data-access'] },
{ sourceTag: 'type:widget', onlyDependOnLibsWithTags: ['type:ui', 'type:data-access'] },
{ sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['*'] },
],
}],Tags are set in each library’s project.json: "tags": ["type:ui"].
To be honest: we have the rule turned on, but the tags aren’t assigned yet, and the constraints are in “anyone can do anything” mode. The direction is held by the README and reviews. That’s the next step, and it’s exactly the case where a rule is cheaper than a review — more in the post custom-eslint-rules.
5 minutes for a new region
When all the code is shared, a new region means:
- Copy the shell of the closest region (≈25 files, of which the code is a couple of hundred lines of configuration).
- Change the name, logo, API address, translations.
- Add a line to the deploy list.
There’s no business logic in these files, so copying them isn’t scary: they don’t drift, there’s nothing in them to drift.
The next logical step is an Nx generator: a command that creates the shell from a template and parameters (nx g region --name=... --api=...). Then even those 5 minutes become one command, and nothing can be forgotten.
What I would do differently
- Boundaries and tags from day one, not after the first NG0919 error. Tagging five libraries is cheaper than later untangling a cycle of thirty.
- Record the differences between copies in a table, rather than deciding in your head. “Customer’s specific need” or “bug” is a decision you’ll want to find later.
- One version of dependencies for the whole repository isn’t a restriction but the main benefit of a monorepo. More on that in the post on upgrading Angular: angular-6-to-22.
Summary
A monorepo doesn’t make code better by itself. It makes a change happen once, and the dependency graph becomes explicit — and checkable. The rest (building only what’s affected, one CI) is built on top of that. About CI — in the post ci-nx-affected.