← Back to Tips & Tricks

Tips & Tricks

DRY Means Process, Not Content

Most teams interpret DRY as “never repeat code.” In a mono-repo, that instinct creates more problems than it solves.

The misreading of DRY

“Don’t Repeat Yourself” has become shorthand for “if two things look the same, extract them.” In a mono-repo this leads to a web of shared packages where a change in one place triggers rebuilds, retests, and redeployments across projects that didn’t ask for them.

The original DRY principle was about knowledge, not text. Two functions can have identical bodies and still represent different decisions that change for different reasons. Merging them couples things that should evolve independently.

In a mono-repo, DRY should apply to process— don’t repeat your build steps, don’t rebuild what hasn’t changed, don’t re-test packages that weren’t affected. Keep the machinery lean, not the line count.

Split code by change boundary, not by similarity

The temptation is to group code that looks alike. A better heuristic is to group code that changes together. If two packages always ship at the same time, they should probably be one package. If two functions look identical but serve different products, leave them separate — coupling them means a change for Product A forces a release of Product B.

The goal is to minimise the number of packages that are affected when one team makes a change. Every dependency edge between packages is a blast radius multiplier.

Cache aggressively

Once packages are correctly split, caching becomes the biggest performance lever. Tools like Turborepo and Nx hash inputs and skip tasks whose inputs haven’t changed. But caching only works if your dependency graph is honest — a package that imports half the repo will never get a cache hit because something upstream is always changing.

This is where the DRY-as-process idea pays off. Instead of obsessing over shared code, obsess over the dependency graph. Fewer edges mean more cache hits, faster CI, and fewer surprised teams.

Don’t build what doesn’t need building

Most mono-repo CI pipelines start with “build everything” and optimise from there. Flip it: start with “build nothing” and only add what changed. Use affected-file detection to scope which packages need to rebuild. If your dependency graph is tight, the affected set stays small.

The prerequisite is limiting cross-package dependencies. If Package A reaches into the internals of Package B, a change to B forces a rebuild of A even if the public API didn’t change. Keeping the surface area of each package small keeps the build graph small.

The Vite trick: external + subfolder bundles

A practical technique for keeping packages independent is to use Vite with Rollup’s externalconfig. Mark sibling packages as external so they’re not bundled into your output — they’re resolved at runtime or by the consuming app’s bundler.

// vite.config.ts
import { defineConfig } from "vite";
import { resolve } from "path";
import { readdirSync } from "fs";

const subfolders = readdirSync(resolve(__dirname, "src"), {
  withFileTypes: true,
})
  .filter((d) => d.isDirectory())
  .map((d) => d.name);

export default defineConfig({
  build: {
    lib: {
      entry: Object.fromEntries(
        subfolders.map((name) => [
          name,
          resolve(__dirname, "src", name, "index.ts"),
        ])
      ),
      formats: ["es"],
    },
    rollupOptions: {
      external: [
        /^@myorg\//,  // treat all workspace packages as external
        "react",
        "react-dom",
        "react/jsx-runtime",
      ],
    },
  },
});

Each subfolder in src/ gets its own entry point and produces its own chunk. Consumers import from @myorg/package/subfolderand only pull in what they use. Because sibling packages are external, a rebuild of this package doesn’t re-bundle its dependencies — it just references them.

Pair this with the exports field in package.json to map subfolder imports to their built output:

{
  "exports": {
    "./buttons": "./dist/buttons/index.js",
    "./forms": "./dist/forms/index.js",
    "./layout": "./dist/layout/index.js"
  }
}

The result is a package that builds fast, caches well, and doesn’t drag half the mono-repo into its bundle. DRY where it matters — in the process — without pretending every line of code needs to be shared.