DEV Community

Cover image for Bringing shared foundations into one place — merging five shared repositories into one
uehara
uehara

Posted on

Bringing shared foundations into one place — merging five shared repositories into one

In June 2026, I had five separate repositories holding shared logic, scattered across my workspace. A utility toolkit, DynamoDB helpers, a rate limiter, a block-editor engine, and notifications. Each was born at a different time, was referenced from a different product, and had no consistency in its dependency direction or publish target. This article covers how I consolidated them into a single monorepo (one repository that holds multiple packages), how I redrew the boundary between "the shared foundation (provider) and the products (consumer)," and which design decision mattered most.

Here are the conclusions first (reading the body takes about 7 minutes).

  • Consolidate five scattered repositories into one place — I gathered the utilities, DynamoDB common, shared library, block editor, and notifications into a single monorepo (provider).
  • The shared foundation holds no runtime — the API, the screens, and the actual data stay in the consumer-side products; the provider holds only the code that gets reused.
  • A shared package does not read process.env — environment-dependent values, such as table names, are injected from the consumer. This was the design decision that mattered most, and it lets the package be portable to any environment.
  • Consolidation only pays off once there are two or more consumers — in the v1.0.0 migration, about 60 files changed on one side, and on the other I was able to delete the 15 files of editor implementation I had been holding locally. What sharing produces is not new shared code but the disappearance of the duplicated copies each product was holding separately. Consolidating for a single consumer is not worth the effort, but the moment a second one needs the same code, it starts to pay off.

The five shared repositories that had scattered

Taking inventory of the state before consolidation, it looked like this.

  • Utility toolkit (August 2024, the oldest): a public npm toolkit bundling small parts that help you write type-safe code — Result/Option for handling success and failure through types, ts-pattern for writing branches exhaustively, zod for validating input, structured logging, and so on.
  • DynamoDB common (January 2026, v1.0.2): DynamoDB helpers already published to GitHub Packages (a package registry hosted on GitHub).
  • Shared library (May 2026): a bundle of pieces such as a rate limiter that caps the number of requests per unit of time (a mechanism that makes calls over the limit wait, or rejects them).
  • Block editor (June 2026): a set of packages such as block-cms and block-editor. The "single source" that two products reference at the same time.
  • Notifications: originally born as a separate service, and later moved into the shared foundation.

The problem was that "shared logic was scattered across five places, and each product referenced it in a different way." Some via public npm, some via GitHub Packages, some copied locally. Every time something was updated, I ended up hunting for "which one is the source of truth."

Options A / B / C, and the shape I chose

In organizing this, I considered three paths.

  • A: Keep each repository independent and keep publishing them separately. This is close to the existing shape and has a low migration cost, but it means looking after CI (continuous integration), versioning, and dependency graphs for five repositories separately, forever.
  • B: Consolidate into a single monorepo and publish it as @org/* to GitHub Packages. This lets me pull CI and versioning into one place.
  • C: Do nothing and keep duplicating with local copies. Out of the question — in fact, local copies of the block editor were piling up on the product side and heading toward duplicate maintenance.

I chose B. On June 25 I scaffolded the shared-package monorepo and gathered the utilities, DynamoDB common, shared library, block editor, and notifications under packages/. One sentence I placed in the README defines the character of this foundation.

It holds no runtime (API / admin UI / data). Those live alongside the product.

In other words, this monorepo stays strictly a provider (a supplier of libraries) and holds no runtime. The API, the screens, and the actual data-access implementations all belong to the consumer-side products. I drew the line so that what gets consolidated is "only the code that gets reused."

The packages/ layout of the shared-package monorepo consolidated into one (the screen is reconstructed and anonymized; the structure is from real measurement). The utilities, DynamoDB common, shared library, block editor, and notifications live together, and it holds no runtime such as an API or screens

The design decision that mattered most: packages do not read process.env

What mattered more than the consolidation itself was where I drew the boundary between the shared foundation (provider) and the consumer side. The block editor's README states the principle explicitly.

| @org/block-cms | Block CMS (types, repository, dynamodb-client,
  seeds, theme-presets. Table names are injected from the consumer.
  Does not read process.env inside the package.) |
Enter fullscreen mode Exit fullscreen mode

Do not read process.env inside a shared package. Environment-dependent values, such as a DynamoDB table name, are not picked up by the package itself from environment variables; instead, they are injected from the consumer (the product) as an argument or a config object. This is the center of this design.

Why does this matter? If a shared package reads process.env.DYNAMODB_TABLE internally, that package forces an implicit contract — "a specific environment-variable name" — onto the consumer. If product A uses DYNAMODB_TABLE and product B uses TABLE_NAME, you can no longer share it. On top of that, there are PaaS-specific traps, such as Amplify SSR (server-side rendering on AWS Amplify) not propagating env at runtime, which makes "when the library reads env" depend on the consumer's deployment form. By centralizing the responsibility of reading env onto the consumer, the shared package can stay a set of pure functions, portable to any environment. Tests, too, can be written by just passing arguments instead of mocking env.

The consumer side places an .npmrc (npm's config file) pointing at GitHub Packages to fetch them.

# consumer-side .npmrc
@org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
Enter fullscreen mode Exit fullscreen mode

And because the provider-side build and publish are consolidated into the monorepo, pnpm -r build (pnpm's command to build recursively across all workspace packages) builds every package at once. React UI and pure logic live together, but because pnpm workspaces separate dependencies and tsconfig per package, a UI package's dependencies never leak into the pure-logic side.

The design decision that mattered most. The shared package does not read  raw `process.env` endraw  itself; environment-dependent values such as table names are injected from the consumer (the screen is reconstructed and anonymized; the principle is from the actual README). On the left is the provider's principle, on the right the consumer-side  raw `.npmrc` endraw  and the shape of the injection

What running the migration taught me

The peak of the consolidation was the migration that switched the product side from local implementations to referencing the shared packages. In early June, in the phase where I migrated the LP (landing page) feature of two products to the shared package v1.0.0, about 60 files changed on one side, and on the other I deleted the 15 files of editor implementation I had been holding locally. "Sharing" shows up not as addition but as subtraction that erases duplicated code on the consumer side. Only at the moment this subtraction takes effect do you get the benefit of "one place."

The changed lines of the migration PR to shared package v1.0.0 (the screen is reconstructed and anonymized; the numbers are from real measurement). About 60 files changed in one product, and in the other the 15 files of editor implementation held locally were deleted. The result of sharing shows up as deletion, not addition

One more thing: moving the notification service was a "transfer of responsibility." I took what had been a standalone service into the shared foundation's packages/, split the sending logic from the client (notify / notify-client), and made the products use it through a thin client. When pulling things toward the provider, the way I decided what to place on the provider and what to leave on the consumer — in the case of notifications — was to split it as "the mechanism for sending is shared, the content and recipients are product-specific."

The conditions under which this transfers

This consolidation pays off only when "multiple consumers reference the same logic." If there is only one consumer, the overhead of monorepo consolidation (publishing, versioning, CI) does not pay for itself, and it is faster to keep the code alongside the product. Conversely, once two or more start holding local copies of the same code, that is the sign to consolidate. In my case, the turning point was when two products started referencing the block editor.

Lessons you can reuse

  • A shared foundation holds "only the code that gets reused," and does not hold runtime (the actual API, screens, and data). Separate the provider and consumer roles first.
  • Do not read process.env inside a shared package. Have environment-dependent values injected from the consumer. This centralizes the responsibility of reading env and lets the package be portable to any environment.
  • The result of "sharing" shows up not as addition but as subtraction that erases duplicated code on the consumer side. When deleted lines increase in a migration PR, that is the sign of success.
  • Only consolidate once there are two or more consumers. With one consumer, keeping it alongside is faster. The moment a second local copy appears is the turning point.

EarthLink Network builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:

The 18 products EarthLink Network builds in-house

For more about the company and each product, visit www.eln.ne.jp.

Top comments (0)