Converting Shared Code into Internal Workspace Packages
After repositories are merged into a monorepo, shared code usually exists in the worst possible forms: copied folders that have drifted apart, deep relative imports from one application into another, and tsconfig path aliases pointing across the tree. None of it appears in the dependency graph, so task runners order and cache builds wrongly, and a fix in one copy never reaches the others. Converting that code into internal workspace packages — small, private, named packages with a declared public entry point — puts every relationship into the graph. This guide covers finding the shared code, designing the package boundary, choosing between built and source-only packages, and migrating imports without a big-bang change.
Finding the candidates
Shared code hides in three patterns:
# 1. Relative imports that leave the importing package
grep -rnE "from '(\.\./){2,}(apps|packages)/" apps packages | head
# 2. Path aliases pointing into other folders
grep -rn '"paths"' apps/*/tsconfig.json packages/*/tsconfig.json
# 3. Duplicated modules: files with the same name in several packages
find apps packages -name "*.ts" -path "*/src/*" -not -path "*/node_modules/*" \
| xargs -n1 basename | sort | uniq -c | sort -rn | awk '$1 > 1' | head
The third search is crude but effective: formatCurrency.ts or apiClient.ts appearing in four applications is a strong candidate. Compare the copies with a diff tool before consolidating; they often diverged for real reasons that the new package must support. The migration context is covered in Monorepo Migration and Adoption.
Designing the package boundary
A useful internal package has a clear purpose, a small public API and no dependency on the applications that use it. Before moving files, decide:
- Name and scope.
@acme/format,@acme/api-client,@acme/ui. Name for what it does. Use the same scope as published packages so a later publish needs no rename. - Public API. What goes in
src/index.ts. Everything else is internal and should not be importable, which theexportsmap enforces. - Dependencies. The package declares its own. If the shared code imports React or a date library, those become dependencies (or peers for frameworks) of the package.
- Direction. Packages must not import from
apps/. If shared code reaches into an application for configuration or types, invert the dependency by passing them in as parameters.
Keep packages small and cohesive. One @acme/shared grab-bag package recreates the coupling you are trying to remove: every change to it invalidates every consumer's cache, and nobody owns it.
Built packages versus source-only packages
Internal packages that are never published do not have to be built. Two models work well:
A source-only package points its exports at TypeScript files and relies on each consumer's bundler or TypeScript setup to compile them:
{
"name": "@acme/format",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"date-fns": "catalog:"
}
}
A built package compiles to dist/ and exports that, with a development condition if you want source during development:
{
"name": "@acme/api-client",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"scripts": { "build": "tsup src/index.ts --format esm --dts" }
}
Choose source-only for packages consumed only by bundled applications; choose built packages for anything a Node.js service loads directly, anything that might be published, and anything expensive to compile that benefits from caching. The trade-offs are covered in detail in Using Internal Packages Without a Build Step.
Migrating imports incrementally
Move code in small steps so each pull request is reviewable and reversible:
- Create the package with the chosen shared implementation and its tests.
- Add it as a dependency of one consumer with
workspace:*and switch that consumer's imports to the package name. - Delete that consumer's copy once its tests pass.
- Repeat per consumer. Where a copy diverged, either extend the package's API to cover both behaviours or keep the difference in the consumer as a thin wrapper.
- Add a lint rule that forbids the old paths, such as
no-restricted-importsfor**/lib/formatorimport/no-relative-packages, so the copies cannot come back.
Reconciling copies that diverged
The hardest part of consolidation is rarely the move; it is deciding what the shared code should do when the copies disagree. Treat each divergence as a question for the owners of the consumers, not as noise to be flattened.
Start by diffing every copy against the one you chose as the base, and classify each difference. Bug fixes applied to one copy only should be kept — the consolidated package gets the fix, and the other consumers get it for free. Behavioural differences that consumers rely on — a different date format, a stricter validation rule — become options in the package's API, with the most common behaviour as the default. Accidental differences — renamed variables, different code style — are simply dropped. Write a test for every behaviour you keep, using the consumers' existing tests as a source, so the package's contract is explicit before the first consumer switches.
A useful technique when the differences are unclear is to temporarily run both implementations side by side in one consumer: call the new package and the old copy, compare the results in development or in a sampled production path, and log mismatches. After a few days without mismatches, delete the old copy with confidence.
Keeping the package healthy
An internal package is a product with internal customers. Give it an owner in CODEOWNERS, a short README that describes its public API and the built-versus-source decision, and the same lint, type-check and test tasks as every other package. Resist adding consumer-specific code paths; if one consumer needs special behaviour, it can wrap the package locally. And watch its dependency footprint: every dependency added to a shared package becomes a dependency of every consumer, so heavy libraries belong in the consumers that need them, not in the shared layer.
Wiring it into the graph
Once packages exist, the tools can finally see the relationships:
- The task runner builds
@acme/api-clientbefore the applications that depend on it and invalidates their caches when it changes. - Affected detection includes every consumer when a shared package changes, as covered in Fixing Slow Monorepo CI with Affected Builds.
- CODEOWNERS can assign an owner to
packages/format/. - Boundary rules can restrict which packages may depend on it.
Check that edges are declared rather than inferred: every consumer lists the package in dependencies or devDependencies, as explained in Detecting Undeclared Cross-Package Imports.
Worked example: four copies of an API client
After merging four repositories, a team finds four copies of a hand-written API client, each with slightly different retry logic and error types. They create @acme/api-client as a built package from the most complete copy, add configurable retries to cover the differences, and migrate one application per pull request over two weeks. Each migration deletes around 600 lines. When a later security fix to the client's token refresh logic lands, it reaches all four applications in one pull request, tested together — the change that would previously have needed four separate fixes and deployments.
Prevention and guardrails
- Never import across package folders with relative paths; enforce it with lint.
- Keep internal packages small and cohesive, with one owner each.
- Declare every internal dependency with the workspace protocol.
- Decide built versus source-only per package, and document the choice in its README.
Frequently Asked Questions
Should internal packages have version numbers?
Private packages can stay at 0.0.0 and use workspace:*. Version numbers matter only if a package is published.
How small is too small for a package? A package per function is too fine-grained; the overhead of manifests and configuration outweighs the benefit. Group code that changes together and is owned by the same people.
What about shared types only?
A types-only internal package is fine and common. Export types from src/index.ts, mark imports with import type, and it adds no runtime code to consumers.
Can an internal package later be published?
Yes, if it is a built package with a correct exports map and declared dependencies. Remove "private": true, give it a real version, and run the validation checks in Testing and Validating Packages Before Publishing before the first release.
What if two applications need incompatible versions of the shared code? That is usually a sign the code is really two things. Split it into two packages, or keep the divergent part in each application and share only the common core.
Related
- Monorepo Migration and Adoption places this step in the migration plan.
- Using the workspace: Protocol Correctly explains how consumers reference internal packages.
- Using Internal Packages Without a Build Step covers source-only packages in depth.
- Cross-Package Dependency Management describes dependency patterns between packages.