Stop Copy‑Pasting: A Principles‑First Guide to Packages, Submodules, and Sustainable Code Reuse
You’ve noticed a pattern: you keep copying the same helpers, UI fragments, and backend glue into new repos. It works—until it doesn’t. The “fast” path spawns a garden of slightly different variants that all need to be updated, tested, and explained. You’re not alone. Most teams fall into this trap, and many stay there for years because the fix appears to be a tooling choice (“Should I use Git submodules?” “Should I publish a package?”) when the real solution is a way of thinking about reuse.
This lab is a conceptual deep dive (not a step‑by‑step tutorial) on the principles that create durable reuse. We’ll compare packages, submodules, subtrees, and monorepos; but the goal is clarity, not dogma. By the end, you should be able to answer:
- What should be extracted and why?
- Where should that code live?
- How does it evolve without breaking everything?
- How do I keep speed today while compounding quality tomorrow?
Why duplication hurts more than it seems
Copy‑pasting code creates forked histories. Each fork accumulates small local tweaks until the shared abstraction is unrecognizable across repos. The pain shows up as:
- Operational drag – the same bug must be fixed N times.
- Inconsistent behavior – tiny differences confuse users and future you.
- Blocked changes – a better design can’t roll out because you can’t touch every copy safely.
- Lost learning – fixes are not socialized; they die with the branch that needed them.
The question is not “How can I avoid duplication forever?” (you can’t) but rather “Where do I want change to flow?” You want change to flow through a single, visible channel where it can be tested, versioned, and reviewed once—then consumed everywhere.
The spectrum of reuse strategies (from least to most managed)
1) Vendoring / copy‑paste (ad hoc)
Fastest to start, slowest to maintain. Fine for throwaway experiments. Dangerous as a default.
Use sparingly: true spikes; code you will delete within days.
2) Git Submodules
A submodule pins a repo inside another repo at a specific commit. Think of it as “git dependencies.”
Strengths
- Clear separation of history and permissions.
- Consumers can pin to a known commit—great for regulated or long‑lived products.
- Works with any language/ecosystem.
Trade‑offs
- Operational friction: updating and syncing submodules is not intuitive.
- Tooling UX varies; many devs avoid them.
- Harder to test cross‑repo changes atomically without extra process.
When it shines: embedding a large, independent project (docs site, assets, firmware, legal content) where you need strict pinning and rare updates.
3) Git Subtree
Subtree merges one repo’s content into another while keeping the ability to sync changes in both directions.
Strengths
- No special checkout state; feels like normal files.
- Version pinning possible via merge commits.
- Good for pulling in code that changes occasionally but should live elsewhere.
Trade‑offs
- History becomes heavier; sync is manual discipline.
- Still not a “package contract” in the language ecosystem sense.
When it shines: sharing a small foundation repo across a handful of projects where tooling for packages is overkill.
4) Packages (language‑native distribution)
Publishable artifacts versioned in the ecosystem (npm, PyPI, Maven, etc.). This is the gold standard for APIs with consumers.
Strengths
- Contracts by default: semantic versions encode compatibility.
- Easy to consume, lock, and audit.
- Enables CI, changelogs, and deprecation flows.
- Works great for UI kits, utilities, data clients, SDKs.
Trade‑offs
- Up‑front design of public API surface.
- Release discipline (changelogs, version bumps).
- Private distribution adds auth and registry concerns.
When it shines: anything multiple codebases rely on and that benefits from stable interfaces—design systems, core utilities, auth modules, data clients, event schemas.
5) Monorepo + internal packages
A single repo hosting multiple packages/services with shared tooling. Popular in JS/TS with workspaces.
Strengths
- Atomic commits across packages; easy to refactor sweeping changes.
- Shared lint/test/build infra (“paved roads”).
- Local developer experience is ergonomic.
Trade‑offs
- Repo size and CI complexity need care.
- Access control is coarser (though code owners help).
- Requires cultural discipline to respect package boundaries inside the same repo.
When it shines: teams building a platform with many small libraries and apps that should evolve in lockstep—while still publishing versioned artifacts to consumers.
Principles that outlive tools
1) Design for change flow
Ask: Where will this code change most? Who needs those changes?
- Volatile utilities belong in a monorepo/internal package to refactor quickly.
- Stable, broadly useful APIs belong in versioned packages to protect consumers from churn.
- Heavy, rare dependencies can live as submodules or subtrees to pin and forget.
2) Name and narrow the surface
A good reusable unit has a clear promise (what it does) and narrow IO (what it needs/provides). The narrower the surface, the easier it is to change internals without breaking consumers.
- Prefer functions and adapters over global singletons.
- Prefer composition over inheritance.
- Keep configuration explicit (don’t read process env inside a generic library; accept a config object).
3) Treat tests as contracts
For a reusable unit, tests are not only correctness; they are documentation of the contract.
- Write black‑box tests that exercise public API only.
- Add example‑style tests that read like snippets from docs.
- Use snapshot tests carefully; prefer semantic assertions.
4) Semantic versioning is a promise
- MAJOR: break consumers only with intent and migration notes.
- MINOR: add features safely.
- PATCH: fix without surprises.
The point is not pedantry; it’s predictability for everyone pulling the code.
5) Prefer “paved paths” over freedom
Codify defaults: linting, formatting, release script, test runner, folder layout. Reuse is social; the easiest path wins.
6) Avoid hidden coupling
A package that silently reaches into your environment (global state, process, DOM) is hard to reuse. Every implicit dependency is an invisible wire you’ll trip over later.
What should become a package? A practical lens
Ask three questions:
- Repeatability – Does this show up in 3+ projects? (Two is a coincidence; three is a pattern.)
- Stability – Is the behavior well understood so we can commit to an API?
- Audience – Who depends on it? Just you, or other teams/clients? The broader the audience, the more it benefits from packages and versioning.
Great candidates
- Design tokens + UI primitives (buttons, typography, layout utilities).
- Cross‑cutting utilities (date, money, validation, logging, feature flags).
- Data clients/SDKs (wrapping fetch/sql/redis/kafka with consistent error handling).
- Schema packages (types, zod/JSON schemas) shared by server and client.
Weak candidates
- High‑velocity features still in discovery.
- App‑specific glue (routing quirks, one‑off APIs).
- Anything that needs heavy app context to function.
Submodules vs packages: choose by operational model
- Choose submodules when you need pinned, auditable snapshots of an external project and do not plan to publish a stable API surface. Think “embed this repo at commit X and rarely change it.”
- Choose packages when you need stable consumption by many projects, quick installation, and semantic versioning. Think “I’m publishing a reusable capability with a contract.”
- Choose monorepo + internal packages when you need rapid co‑evolution across many libraries and apps with shared tooling—and still want the option to publish versioned artifacts outward.
There is no universal right answer; there is a right operational fit for your stage and audience.
Evolution patterns (how reuse grows without breaking)
Start local, extract later
Build in the app until the shape stabilizes; then extract the smallest coherent abstraction. Don’t prematurely publish raw experiments as “core.”
Strangler fig for shared code
When you notice duplication, publish a new package and migrate each repo piece‑by‑piece. During migration, leave adapters in old repos to avoid big‑bang rewrites.
Anti‑corruption layer
When consuming a messy third‑party API, wrap it in your own clean adapter inside a package. Your code depends on your interface, not theirs.
Deprecation with empathy
Announce a deprecation in the changelog. Provide a codemod or snippet to migrate. Give timelines. Old consumers shouldn’t wake up to broken builds.
Common anti‑patterns and how to avoid them
The God Package
A giant “utils” library that knows everything about everything. It grows until nothing is safe to change.
→ Split by domain (auth, formatting, storage) with crisp contracts.
Hidden cross‑repo coupling
Two packages quietly import each other or reach into each other’s internals.
→ Enforce dependency rules (no cycles); publish only the public surface.
Leaky environment assumptions
Library reads process.env, fetches global window, or assumes a specific framework version.
→ Accept dependencies via constructor/config; push framework coupling to adapters.
Lockstep version pinning everywhere
Everything bumps major versions together, forcing synchronized releases.
→ Only bump what breaks; use change detection to release the minimal set.
Premature publication
Publishing experiments too early calcifies bad shapes.
→ Prove value in one codebase, extract after patterns emerge.
Thinking like a platform: a mental model
Your codebase is an ecosystem. At the center are paved paths: the standards you want every app to use (logging, auth, monitoring, UI tokens). Those paved paths live as packages with clear contracts and docs. Around them are apps and services that compose these packages.
- Packages are products with users. They need versioning, docs, and support.
- Apps are consumers; they depend on a stable experience and minimal surprise.
- The repo structure (monorepo, multi‑repo with packages, submodules) is the marketplace that moves code between producers and consumers.
This mindset shifts decisions from “What can we technically do?” to “What experience do we want our consumers (including future us) to have?”
Documentation that scales with reuse
- README as an entry point – one‑screen overview, install, quick start, example usage.
- CHANGELOG as narrative – what changed, why, and how to migrate.
- Examples folder – small, real scenarios that compile.
- Types as docs – in TS, exported types and JSDoc are a living spec.
- Design notes – a short ADR (architecture decision record) stating the package’s purpose and trade‑offs.
Good docs reduce support load and make your package the obvious choice instead of a mysterious internal secret.
Security & governance (the unglamorous multipliers)
- Least privilege – private repos/registries for private packages; separate tokens.
- Provenance – signed commits/releases; verify published artifacts came from CI.
- Licensing – declare how others can use it, even if “internal only.”
- API review – treat breaking changes like an RFC; require approval.
- Telemetry (opt‑in) – error reporting can tell you what breaks in the field.
These aren’t bureaucracy; they are force multipliers once you have many consumers.
A simple decision aid (keep it pragmatic)
- Is this used in ≥3 places and stable? → Extract.
- Do we need semantic versioning and easy install? → Package.
- Do we need pinning and rare updates? → Submodule (or subtree).
- Do we need atomically refactoring 5 libs and 3 apps today? → Monorepo internal packages.
- Still exploring? → Keep local; design the interface while you learn.
Write it down. A one‑page reuse policy removes debate from every PR.
What this buys you (beyond fewer copies)
- Educational leverage – your best patterns are captured and taught by the code itself. New contributors learn by importing good abstractions.
- Exploratory freedom – you can try ideas locally without committing the world; when they mature, they graduate to shared packages.
- Operational calm – a bug is fixed once, in one place, with tests, and flows outward with a version number.
- Strategic compound interest – every small package that stays healthy adds velocity to every future project.
That’s the real promise: not tooling wizardry, but a system where good ideas move easily and stay good as they spread.
If you only remember five things
- Let change flow through a single channel (package, not copies).
- Narrow the public surface; hide everything else.
- Treat tests and docs as contracts.
- Version intentionally; communicate breaking changes.
- Choose submodule / package / monorepo by your operational needs, not fashion.
Build like you’ll be the one maintaining it—because you will be. Reuse is not about avoiding work today; it’s about creating a world where the same work makes every project better, every time.