How We Turned 636 Code Clones into Solid Architecture

A fantastic tool used by the JavaScript community to preserve code quality is jscpd. Using that tool, we can check if we have code duplication in our projects.

Using the jscpd, we ran a duplication test on our monorepo. Our first report for the project found 636 clones in production code. The clear goal was to reduce that number. The obvious solution was to find similar blocks, extract functions, and keep adding parameters until the report looked better like we learned when reading the Refactoring book from Martin Fowler and Kent Beck.

The project has web and mobile applications, shared packages, APIs, and operational scripts. Similar code across those places does not always carry the same responsibility. Some copies were shared rules waiting for an owner. Others kept authorization, navigation, or domain decisions visible where they mattered.

This is the same project I wrote about in What I Found and Had to Fix After Joining a Vibecoded Project. That post covers what I found when I joined and read the code for the first time. This one follows one of the maintenance efforts that came after: deciding which duplicated code should become shared architecture and which duplication should remain visible.

We finished with 168 clones. By then, every clone either had one clearly responsible module (what we called its canonical owner) or a recorded reason to remain duplicated. Teach your agents to create documentation, please. You will need it when a report contains hundreds of findings.

jscpd finds similar code. We still have to understand it

jscpd can tell us that two blocks look alike. It cannot tell us why they exist, who should own the rule, or whether both copies need to change together.

Two identical validation schemas in an application and a package probably point to a shared responsibility. Two similar authorization guards in different endpoints may mean the opposite: they can be local controls that we want to keep easy to audit.

The same thing happens in user interfaces. Two screens may share states and styles while serving different roles and navigation flows. We could remove the repeated lines with a component full of callbacks, but that would replace duplication with coupling.

So we stopped treating every match as debt and gave each group one of four outcomes:

SituationDecision
Same rule and same reason to changeCreate a canonical owner
Similar code, but different domains or rolesKeep it as intentional duplication
Repeated structure that leaves a control point explicitAccept it as boilerplate
Textual similarity without shared responsibilityRecord it as a false positive

The question was no longer “How do we remove this clone?” It became “Where should this responsibility live?” Sometimes the answer was right where it is.

First iteration: make the report trustworthy

Our first report was reproducible and still wrong for what we wanted to measure. It mixed production code, tests, and a script used to carry a patch into another repository.

Before running the analysis, we used the Node.js version defined by the repository and checked which jscpd version pnpm dlx would execute:

nvm use
pnpm --version
pnpm dlx jscpd@5 --version

At the time, jscpd@5 resolved to 5.1.0. We then corrected the scope and documented this command in the repository:

pnpm dlx jscpd@5 \
	apps/web/src \
	apps/mobile/app \
	apps/mobile/src \
	packages \
	scripts \
	--min-lines 10 \
	--min-tokens 70 \
	--format typescript,tsx,javascript,jsx,bash,css \
	--ignore "**/*.test.*,**/*.spec.*,**/*.e2e.*,**/__test__/**,**/__tests__/**,**/test/**,**/tests/**,**/test-isolation/**,**/test-fixtures/**,**/coverage/**,**/.next/**,**/dist/**" \
	--reporters console,html,json \
	--output reports/jscpd

The command printed a summary in the terminal and generated two files: a navigable HTML report at reports/jscpd/jscpd-report.html and structured data at reports/jscpd/jscpd-report.json. After correcting the scope, the production baseline contained 636 clones and 15,887 duplicated lines across 144,979 lines of code, a duplication ratio of 10.95%.

That percentage gave us a reason to investigate. It did not mean that 10.95% of the code should move into shared modules, and it certainly did not mean that removing it would reduce maintenance costs by the same amount.

We used that exact command for every measurement that followed. The generated reports remained local, but the JSON became the source for a versioned inventory. We did not want the work to depend on a number copied from the terminal.

The inventory was a Markdown file in the repository. For each group, it recorded the paths, domain, responsibility, risk, and decision. A later change had to explain whether we extracted the code, kept the duplication, accepted boilerplate, or found a false positive. Making a clone disappear from the total was not enough.

We also froze the production exclusions. Otherwise, we could improve the graph by excluding another directory and pretend we had improved the architecture. Every group needed a decision; “review later” was not one of them.

From clones to canonical owners

For each group, we answered the same questions:

  1. Would a rule change require us to edit every copy?
  2. Is there a shared contract, or only similar syntax?
  3. Which layer should own this behavior?
  4. Does the extraction reduce how many facts the caller needs to know?
  5. Does the abstraction preserve authorization, isolation, and domain differences?

The answers did not always lead to a new abstraction. When a service was duplicated between the web application and a package, and both copies represented the same rule, the package became the owner. Shared types and date utilities followed the same path. In mobile, messages, notifications, and settings shared common flows, but roles and navigation stayed in their screens.

The Zod schemas show how this worked. jscpd found 14 clones between the web application and the package that already owned the shared validation contracts. We moved the missing contracts into that package and removed the web copies. Where code still used an old import, we left a thin reexport in place.

Then we ran the 223 tests in the schema package and another 28 for the validation that remained specific to the web application. Only after those tests passed did we run jscpd again. The next report had 14 fewer clones.

The API work required a stricter boundary. We extracted repeated code for resolving clinic context, loading documents, and preparing shared data. Authentication, role checks, rate limits, mutations, and response details stayed in the route handlers, where we could still audit them without jumping through helpers.

Those thin reexports were temporary facades, not second implementations. They kept public imports working while consumers migrated. The validation rule itself existed in one place.

The payoff comes with the next change. We know where the shared rule lives and which tests describe it. We no longer have to find every copy and guess which one is current, while the differences that belong to one application remain there.

The report found the copies. Tests told us what to keep

Tests did not count toward the production metric because fixtures and setup code would distort it. Once we started moving code, however, tests helped us decide which copy was correct.

One migration made that clear. A package looked like the natural owner for a shared rule, but its implementation had fallen behind the version used by the web application. Making the package canonical without checking the behavior would have preserved the wrong copy.

We wrote down the expected behavior as a test and ran it against the package. It failed. After bringing the package implementation up to date, we ran the test again and then moved the consumers. The directory looked like the right owner, but the failing test showed that its code was not yet the source of truth.

The same approach pushed us to pull rules out of routes and screens into smaller functions that received their dependencies. Instead of setting up an entire route, screen, or real client, we could test the rule directly.

Each batch covered one related family. We ran its focused tests, type checking, linting, and dependency boundary checks before generating the duplication report again. Keeping the commits small also meant we could revert a bad decision without taking unrelated work with it.

Where coding agents helped

The difficult part of the inventory was not reading one clone. It was repeating the same work hundreds of times: resolve both paths, find the consumers, compare the implementations, update the inventory, and do it again. This is where coding agents helped most.

We used them to walk through the report, find consumers, compare copies, migrate imports, write focused tests, and keep the inventory aligned with the code. That removed much of the mechanical work without outsourcing the decision.

I would not give an agent a prompt such as “remove all duplication” and trust the result. It can easily produce generic helpers, components full of options, or functions that hide authorization logic. It will make those changes quickly, which is not helpful when the direction is wrong.

The specification constrained the work before an agent touched a group. Existing contracts had to remain compatible. Isolation through clinic_id and Row Level Security (RLS) could not move silently into a shared helper. Application and package boundaries still applied. Most importantly, similar syntax was not enough: the copies needed the same reason to change.

This is the same problem I discussed in code review with AI agents: we can produce code faster than we can understand it. The inventory and small commits forced us to leave evidence for each decision before moving to the next group.

Zero would have been a worse result

The final report still contained 168 clones. We had reviewed and classified every one as intentional duplication, acceptable boilerplate, or a false positive.

Among them were mobile screens with their own state and navigation, authentication forms with different destinations, API guards, and role updates that should remain visible during an audit. Other blocks looked alike but belonged to separate domains.

We could have forced those numbers down with more parameters and indirection. The report would look cleaner, but the code would hide more knowledge and become harder to change.

That is the trap I wrote about in The Myth of Perfect Code. An architecture that looks elegant on paper is not automatically useful. I would rather leave a justified copy visible than create an abstraction the team has to decode every time it changes.

What I would repeat

I would use the same process again without waiting for duplication to become a project of its own:

  1. Define what counts as production code and freeze the analysis command.
  2. Generate the report and resolve every clone to real file paths.
  3. Group occurrences by responsibility, not only by file.
  4. Write a discriminating test before choosing the canonical implementation.
  5. Refactor one cohesive family at a time.
  6. Run tests, type checking, linting, and boundary rules after each batch.
  7. Classify what remains and record why it should stay explicit.

Agents can help with almost every step in that list. The percentage only tells us where to look. We still have to decide what belongs together.

Share only what truly changes for the same reason.

References

This article, images or code examples may have been refined, modified, reviewed, or initially created using Generative AI with the help of LM Studio, Ollama and local models.