Between March 2025 and April 2026 I worked across a set of sibling client platforms built in Angular. The measured outcome I can point to is this: duplicated frontend logic cut by 35%, by consolidating it into reusable Angular component libraries and shared modules inside an Nx monorepo. The clients stay unnamed. The method does not have to.
How duplication actually accumulates
Nobody sits down and decides to write the same authentication guard four times. It happens like this.
Client A's platform ships. Six weeks later Client B signs, and the fastest way to start is to clone A's repository, rename it, and delete the parts that do not apply. Under a deadline that is a genuinely rational decision — and at that moment the duplication is free, because the two codebases are identical and nobody is maintaining two diverging copies yet.
The bill arrives about four months in. Someone finds a bug in the date-range picker. It gets fixed in A. B still has it. C has a variant that was refactored by someone else and now behaves differently. A security fix to the token-refresh interceptor has to be applied by hand in three places by three people who each have to re-read code they did not write. The insidious part is that nothing feels broken — every individual repository looks clean. The cost lives entirely in the space between them, which is exactly the space no repository owner is responsible for.
Duplication does not slow you down when you create it. It slows you down every time you fix something, forever, and it does it in a way no single project's metrics will ever show you.
How I measured the 35%
Anyone can write "cut duplication by a third" on a CV. Here is exactly what that sentence means, so you can decide how much weight to give it.
I did not run a repo-wide clone detector and publish its score. I did something narrower and more defensible:
- Inventory. I catalogued the modules that existed in more than one project — HTTP interceptors, auth guards, form-validation helpers, the paginated data-table wrapper, the file-upload widget, notification plumbing, i18n and RTL utilities, shared API models. For each one: the file, the projects it appeared in, and its line count.
- Baseline. The duplicated portion is every copy after the first. A 180-line table wrapper living in four projects contributes 3 × 180 = 540 duplicated lines, not 720. The first copy is not duplication; it is the code.
- Consolidate. One implementation moves into a shared library, call sites switch to the library's public entry point, the copies get deleted.
- Re-count. Not every copy could be fully removed. Some carried a client-specific override that had to stay behind. Those surviving lines still count as duplicated — subtracting them is how the number stays honest.
So 35% is duplicated lines removed ÷ duplicated lines at baseline, across that inventory. It is a scoped before-and-after count, not a whole-repository static-analysis score, and that is exactly how I would describe it in an interview. If you want a number for your own codebase, do the inventory first — it takes about a day, and it will also tell you, before you commit to anything, whether a monorepo is even the right answer for you.
Draw the boundaries before you move a single file
The failure mode of a shared library is not that it is hard to build. It is that it becomes @acme/shared — one library everything imports, which means every change invalidates every cache and every team owns none of it. You avoid that by deciding the taxonomy first and encoding it in tags.
Two axes are enough for most Angular estates:
- scope — who owns it:
scope:shared,scope:client-a,scope:client-b. - type — which layer it is:
type:app(thin shell, routing, providers),type:feature(smart components bound to routes),type:ui(presentational only, no HTTP, no store),type:data-access(HTTP clients, state, models),type:util(pure functions and pipes).
// libs/shared/ui/project.json
{
"name": "shared-ui",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/shared/ui/src",
"prefix": "shared",
"tags": ["scope:shared", "type:ui"],
"targets": {
"lint": { "executor": "@nx/eslint:lint" },
"test": { "executor": "@nx/jest:jest" }
}
}
The tag is only half of it. The other half is the path alias that turns a directory into an importable package with a single public entry point:
// tsconfig.base.json
{
"compilerOptions": {
"paths": {
"@acme/shared/ui": ["libs/shared/ui/src/index.ts"],
"@acme/shared/data-access": ["libs/shared/data-access/src/index.ts"],
"@acme/shared/util-forms": ["libs/shared/util-forms/src/index.ts"]
}
}
}
The barrel file is the contract. Anything not exported from src/index.ts is private, and Nx will reject deep imports that try to reach past it. Here is the shape of a real extraction — the HTTP error interceptor that had been copy-pasted into every project:
// libs/shared/data-access/src/lib/api-error.interceptor.ts
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { NotificationService } from './notification.service';
export const apiErrorInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(
catchError((error: HttpErrorResponse) => {
const notifications = inject(NotificationService);
if (error.status === 422) {
notifications.validation(error.error?.errors ?? {});
} else if (error.status >= 500) {
notifications.error('errors.server_unavailable');
}
return throwError(() => error);
}),
);
// libs/shared/data-access/src/index.ts — the entire public surface
export * from './lib/api-error.interceptor';
export * from './lib/notification.service';
export * from './lib/paginated-resource';
Enforce boundaries with lint, not with etiquette
A convention that lives in a README is a convention that survives until the next deadline. @nx/enforce-module-boundaries turns the taxonomy into a build error:
// eslint.config.js
const nx = require('@nx/eslint-plugin');
module.exports = [
{ plugins: { '@nx': nx } },
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'type:app',
onlyDependOnLibsWithTags: [
'type:feature', 'type:ui', 'type:data-access', 'type:util',
],
},
{
sourceTag: 'type:feature',
onlyDependOnLibsWithTags: [
'type:feature', 'type:ui', 'type:data-access', 'type:util',
],
},
{ sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui', 'type:util'] },
{
sourceTag: 'type:data-access',
onlyDependOnLibsWithTags: ['type:data-access', 'type:util'],
},
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
// A shared library may never reach into a client's code.
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
{
sourceTag: 'scope:client-a',
onlyDependOnLibsWithTags: ['scope:client-a', 'scope:shared'],
},
{
sourceTag: 'scope:client-b',
onlyDependOnLibsWithTags: ['scope:client-b', 'scope:shared'],
},
],
},
],
},
},
];
Read those constraints as sentences. A UI library may depend on other UI libraries and on utilities — never on data-access — so a presentational component can never quietly start doing HTTP. A client-scoped library may depend on shared libraries; a shared library may not depend on any client's code, which is what stops scope:shared from accreting client-specific branches. Those two rules do more for long-term maintainability than any volume of code review, because they fail in CI at 2 a.m. without needing a human to care.
nx affected, and what it does to CI
The standing objection to monorepos is always the same: "so now every pull request runs every test". It does not, and the reason is the project graph. Nx derives a dependency graph from your actual imports, works out which projects changed relative to a base commit, and runs targets only for those projects and everything downstream of them.
# What would run, before running it
npx nx show projects --affected --base=origin/main
# Run lint, test and build for the affected set only
npx nx affected -t lint test build --base=origin/main --parallel=3
# See the graph you are actually enforcing
npx nx graph
In GitHub Actions the only fiddly part is establishing the base SHA correctly. You need full history and the right comparison point:
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # nx affected needs history, not a shallow clone
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- uses: nrwl/nx-set-shas@v4 # resolves NX_BASE / NX_HEAD correctly for PRs and pushes
- run: npx nx affected -t lint test build --parallel=3
Two effects compound here. First, affected narrows which projects run at all. Second, the computation cache means that even among affected projects, any target whose inputs hash to something already seen is replayed from cache instead of executed. Get namedInputs right and a README edit stops invalidating your build target entirely:
// nx.json
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s",
"!{projectRoot}/**/*.md"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
}
}
}
I am not going to put a number on the CI improvement, because I did not measure it in a way I would defend. The mechanism is worth understanding on its own: the work per pull request stops being a function of repository size and becomes a function of blast radius.
Internal, buildable, publishable — pick deliberately
- Internal (the default). No build target. Consumers import TypeScript source through the path alias and the application's own build compiles it. Fastest inner loop, simplest mental model. This should be the default and, inside a single repository, it is usually also the end state.
- Buildable. Has its own build target producing compiled output. Worth it when a library is large and stable enough that caching its build output saves more than the added complexity costs, or when the consuming app genuinely cannot compile raw source.
- Publishable. Produces a distributable package with its own
package.json, a version and an npm publish step. Only worth it when a consumer lives outside the monorepo. The moment you publish, you have signed up for semantic versioning, a changelog, a deprecation policy and consumers stuck on old versions — precisely the coordination cost you moved to a monorepo to escape.
The mistake I see most often is making libraries publishable because it feels more professional. It is not more professional; it is more expensive. Publish when something outside the repository needs the package, and not one day earlier.
Migrating an existing multi-repo estate
Do not attempt a big-bang merge. The sequence that works is incremental and keeps every project shippable at every step:
- Import, change nothing else. Create the workspace and bring each existing application in as its own project, preserving git history where it matters (
nx importdoes this; a subtree merge does it by hand). At this point you have a monorepo with zero sharing and, correctly, zero benefit — but everything still builds and deploys exactly as before. - Tag everything and turn the boundary rule on immediately, with permissive constraints. Installing the rule early is what stops the next six weeks of work from creating new violations you will have to unpick later.
- Extract from the inventory, highest-duplication item first. One library per pull request: move, re-point imports, delete copies, ship. Resist redesigning the API while you move it — a move that also changes behaviour is a move nobody can review.
- Tighten the constraints once the obvious extractions are done and the real dependency shape is visible in
nx graph.
What it costs you
An honest write-up has to include the bill.
- Ownership gets harder before it gets easier. A shared library has no natural owner. Without
CODEOWNERSand a review rule, "shared" degrades into "whoever touched it last". - Blast radius is real. A change to a widely-depended-on library rebuilds and retests everything downstream. That is correct behaviour — it is telling you the truth about your coupling — but it means a careless commit in
type:utilturns a two-minute pipeline into a twenty-minute one. - Release coupling. If clients must deploy independently on different schedules, you need per-project deploy pipelines driven by affected. Otherwise the monorepo quietly imposes lockstep releases nobody agreed to.
- Tooling gravity. Generators, executors, migrations, CI plugins — Nx is a real dependency with real upgrade work.
nx migrateis good, but it is not free. - It does not fix a bad abstraction. Consolidating four copies of a badly-designed component gives you one badly-designed component that four teams now depend on. Deduplicate code that is genuinely the same thing, not code that merely looks similar this quarter.
None of that changes the conclusion for this estate. Several sibling Angular products maintained by one small team is the textbook case for a monorepo, and the measurable result was a 35% cut in duplicated frontend logic. But the tool did not produce that number — the inventory did. Nx made the boundaries enforceable and the CI affordable; deciding what genuinely belonged in a shared library was the actual engineering.
Login to comment
To post a comment, you must be logged in. Please login. Login
Comments (0)