Jump to content

Openai/695d28c7-94c8-8006-836b-96dbe559b5eb

From freem


This conversation was exported from ChatGPT[1]

gpt-5-2-thinking[edit | edit source]

User: This is a question from Software Engineering Stack Exchange:[edit | edit source]

This is a question from Software Engineering Stack Exchange:

’m working on a very large frontend project organized as a single monorepository.

We have a components/ directory that contains 30+ shared UI components. These components are used across a large number of features, and many independent teams work on different features in parallel.

The core problem:

Any change to a shared component can potentially cause regressions across the entire product, which makes QA extremely expensive and slow. Because of this, teams are afraid to modify shared components, even when improvements or fixes are needed.

We must keep a single repository (splitting shared components into a separate repo is considered a bad option due to the number of teams and coordination overhead).

Different teams should be able to work independently without blocking each other.

Changes to shared components must not trigger full-product regression testing by default.

The solution should scale to dozens of components and teams over time.

I’m looking for architectural patterns or proven approaches to organizing shared UI components in a large monorepo. I stand with versioning of components (can have Button, Button.v2 Button.v3 and etc), what are the pros and cons I might encounter?

I’m especially interested in real-world approaches used in large-scale frontend systems, not just theoretical ideas.

If helpful, the frontend stack is React + TypeScript, but I’m more interested in architectural patterns than framework-specific solutions.

Any guidance, references, or examples would be greatly appreciated.

Assistant: You can make this scale in a monorepo, but you usually need two things at the same time:[edit | edit source]

You can make this scale in a monorepo, but you usually need two things at the same time:

  1. A real “package boundary” for the shared UI library (or libraries) so consumers do not silently pick up breaking changes.
  2. A CI strategy that only tests what is affected, plus strong component-level automated checks, so you stop paying “full product QA” for every change.

Below are patterns that actually work in large React + TS monorepos.

What it is: Keep one repo, but model components/ as one or more workspace packages, published to an internal registry (or at least versioned and consumed like packages). Use semantic versioning and a release tool (Changesets is a common choice in TS monorepos). changesets-docs.vercel.app[2][edit | edit source]

Key property: A feature team only gets a component change when they choose to bump the dependency.

- Teams work independently: Component changes do not immediately affect every consumer. Consumers update on their timeline.[edit | edit source]
  • No default full regression: Your default is “component tests + affected consumer tests,” not “entire product QA.”
  • Scales to many teams: Versioning + changelog + deprecation policy becomes the coordination mechanism.
- packages/ui (design system components)[edit | edit source]
  • optionally packages/ui-tokens, packages/ui-icons, packages/ui-utils
  • apps/features depend on @org/ui with a pinned semver range
  • updates happen via automated “dependency bump” PRs (Renovate-style workflow)
- Blast radius control (biggest win)[edit | edit source]
  • Clear contracts: public API surface becomes explicit
  • Natural deprecation/migration: old versions can live while teams migrate
- You are signing up to maintain versions (release notes, deprecations, support window)[edit | edit source]
  • Local dev ergonomics can be trickier if you are not using workspace tooling well
  • Potential for version skew: different parts of the product on different UI versions (you mitigate this with “supported versions” policy and scheduled migrations)

Even with package boundaries, you still want to avoid massive CI runs.[edit | edit source]

Tools like Nx compute “affected projects” (changed projects plus dependents) so CI only runs the relevant builds/tests. Nx[3] In Bazel ecosystems, the same idea exists via dependency queries and “diff-to-affected-targets” tooling (for example, bazel-diff). GitHub[4]

Reality check: if you change a core Button that everything depends on, “affected” might still be huge. That is not a tooling failure, it is telling you the truth about coupling. The fix is usually more boundaries (split packages) and/or more backward compatibility.

If the fear is “we will break unknown screens,” you need fast, trustworthy signals closer to the component library:[edit | edit source]

  • Storybook as the contract surface
  • Visual regression on stories (Chromatic, Percy, etc.). Chromatic integrates with Storybook and adds PR checks for UI changes. chromatic.com[5]
  • Component unit tests (Testing Library), accessibility checks (axe), and type-level API tests (for TS public props)

This does not eliminate all integration risk, but it dramatically reduces “manual QA for every tweak.”

In big orgs, the technical setup fails without governance:[edit | edit source]

  • Component owners/maintainers (not necessarily gatekeepers, but accountable)
  • Backward-compatibility rule by default
  • Breaking-change RFC and a defined support window
  • Deprecation workflow: warn in console/dev builds, codemods, migration guide
  • “No breaking changes on Friday” style operational rules if needed

This is how you keep velocity without making the shared library radioactive.

This is a real pattern, but it has sharp edges.[edit | edit source]

- Zero-risk rollout for consumers: old stays old, new opts in[edit | edit source]
  • Great for big redesigns where compatibility would be messy
  • Good when migration needs to be gradual across many teams
- Permanent maintenance tax: bug fixes, a11y fixes, and behavior changes now have to be duplicated or backported across variants[edit | edit source]
  • Inconsistent product UX: you end up with mixed button styles/behaviors across the app
  • Harder to remove old versions: teams stop prioritizing migrations because “it still works”
  • API fragmentation: docs, examples, and developer intuition get worse over time
Instead of Button.v2 as a naming convention, prefer one of these:[edit | edit source]
  • Package major versions (@org/ui@1 vs @org/ui@2) with semver and a support window (cleaner contract)
  • New component name when semantics change (PrimaryButton/Button2 is not great, but sometimes Button vs ActionButton is legitimately clearer)
  • Adapter layer: keep Button as a thin wrapper over ButtonNext during migration

If I had to pick one approach that matches your constraints:[edit | edit source]

  1. Make shared UI a workspace package (or a few packages) with semantic versioning (Changesets is a common workflow). changesets-docs.vercel.app[6]
  2. Consumers pin versions and update via automated bump PRs.
  3. Require Storybook stories for every component and run visual regression on PRs. Storybook[7]
  4. Use affected-only CI so the default pipeline is fast. Nx[8]
  5. Establish a deprecation and support policy (for example: “we support the last 2 major UI versions; older majors get security/a11y fixes only, then are removed on a schedule”).

That combination gives you independence, reduces QA cost, and scales socially.

If you want, I can sketch a concrete repo layout + CI pipeline outline (GitHub Actions style) for this setup, including how teams consume a pinned UI version while still developing locally against workspace packages.

User: Is the actual problem that they're using a monorepo?[edit | edit source]

Is the actual problem that they're using a monorepo?

Assistant: Not really.[edit | edit source]

Not really. A monorepo can make it easier to accidentally ship breaking UI changes everywhere at once, but the underlying problem is usually:

  • No real boundary between “library” and “consumers.” If every app/feature imports shared components as “just another folder,” any change is instantly live everywhere.
  • No release/compatibility contract. Without semantic versioning (even inside a monorepo), there’s no “opt in” moment for consumers. That’s what forces “test the whole world” every time.
  • Insufficient automated signals at the component layer. If you don’t have strong Storybook coverage, visual regression, a11y checks, and interaction tests, the only way to be confident is broad manual regression.
  • Coupling is too high. A Button used by everything should require a lot of confidence to change. If it’s not designed for backward compatibility, every tweak becomes a product-wide risk.

A multirepo can hide this temporarily (because updates don’t propagate automatically), but it doesn’t fix the root issue. It just forces a boundary by making dependency updates explicit. You can get the same benefits in a monorepo by treating shared components as versioned packages (workspace package + semver) and adopting an “affected-only” CI strategy plus strong component-level regression testing.

So: the monorepo isn’t the problem; the lack of boundary + contracts + tests + upgrade mechanics is.

User: Do your suggested changes fix those problems, or do they need to step back and actually make enforceable contracts?[edit | edit source]

Do your suggested changes fix those problems, or do they need to step back and actually make enforceable contracts?

Assistant: My suggestions only work if they create enforceable contracts.[edit | edit source]

My suggestions only work if they create enforceable contracts. The tools (workspace packages, semver, “affected” CI, Storybook/visual tests) are just the mechanics that make a contract real.

If they only “reorganize folders” inside the monorepo, nothing is fixed.

At minimum, it has to be true that:[edit | edit source]

  1. There is a defined public API surface
  • Only exported components/types are allowed for consumers.

Consumers cannot reach into internal files (components/Button/internal/).

  • Styling tokens, CSS variables, DOM structure expectations are either part of the contract or explicitly not.
  1. Breaking changes are detectable and gated
  • A change that would break consumers requires an explicit “major” bump.
  • PR checks can fail if someone introduces a breaking change without bumping appropriately.
  1. Consumers can opt in
  • They don’t automatically get breaking changes “because main changed.”
  • Upgrading is an explicit dependency bump (even inside a monorepo).
  1. There’s a support/deprecation policy
  • You can’t keep Button.v1 forever. You need a window: “2 majors supported” or similar.

###[edit | edit source]

Yes — this is the core contract mechanism. It makes upgrades explicit and creates a place to put “this is breaking” vs “this is safe.” Without it, you’ll always have fear-driven QA.

Helpful, but it’s not the contract. It just prevents wasting time when changes are localized. If you change a widely-used component, “affected” will still be big, and that’s correct.[edit | edit source]
This is how you validate the contract cheaply. It reduces the need for manual full-product QA for non-breaking changes. But it doesn’t replace versioning.[edit | edit source]

So the real answer is: they need contracts, and the suggested architecture is how you enforce them.

If they want the shortest path to “teams stop being afraid”:[edit | edit source]

  1. Turn components/ into a package (@org/ui) with strict exports
  • Enforce with lint rules like “no deep imports” and TS path restrictions.
  • Treat anything not exported as private.
  1. Adopt semver releases inside the monorepo
  • Every component change comes with a patch/minor/major classification.
  • Consumers update by bumping the package version (can be automated PRs).
  1. Define “what is breaking” Examples that are usually breaking:
  • removing/renaming props
  • changing default behavior in a user-visible way
  • changing DOM in a way tests or CSS selectors rely on
  • token changes that alter appearance beyond tolerance
  1. Add fast regression at the component boundary
  • Visual snapshots on every story (PR-gated)
  • a11y checks
  • interaction tests for key components
  1. Set a deprecation window
  • “We support the last 2 majors.”
  • Old majors get only critical fixes (security/a11y), then get removed on a schedule.

If their “shared components” are actually a grab-bag of UI + business rules + feature-specific behavior, contracts will keep failing. In that case, they need to split the library (still in the monorepo) into:[edit | edit source]

  • pure primitives (Button, Input, Modal)
  • composed design-system patterns (FormField, DataTable wrapper)
  • feature-level components (should live with the feature)

That’s also contract work: deciding what’s truly shared.

If you want, I can write what a minimal contract doc looks like (“UI library compatibility policy”) plus the CI gates that enforce it.

User: Show me that.[edit | edit source]

Show me that.

Assistant: Below is a “minimal but enforceable” contract doc you can drop into the repo e.g., packages/ui/COMPATIBILITY.md, follow…[edit | edit source]

Below is a “minimal but enforceable” contract doc you can drop into the repo (e.g., packages/ui/COMPATIBILITY.md), followed by concrete CI gates (GitHub Actions + a couple config snippets) that actually enforce it.

UI Library Compatibility Policy (Contract)[edit | edit source]

# UI Library Compatibility Policy (Contract)

Applies to: @org/ui (and any sibling packages like @org/ui-tokens)

Goal: Component teams can ship improvements without forcing full-product regression testing,
while consumer teams can upgrade on their schedule with predictable risk.

==== 1) What is Public API vs Private ====

===== Public API =====
Only these are considered supported and stable:
* Package entrypoints exported from:
* <code>@org/ui</code> (root)
'' <code>@org/ui/<subpath></code> ''only if explicitly listed in <code>package.json#exports</code>*
* Types re-exported from public entrypoints.
* CSS variables / design tokens explicitly documented in @org/ui-tokens.
* Storybook stories are the canonical behavioral + visual examples for public components.

===== Private API (NO consumer dependencies) =====
Consumers must NOT import:
* deep paths like <code>@org/ui/src/...</code> or <code>@org/ui/components/...</code>
* any file not reachable via <code>package.json#exports</code>
* internal DOM structure (e.g., <code>.kebab > div:nth-child(2)</code> selectors)
* internal class names (unless explicitly documented as stable)
* test ids unless explicitly documented as stable (prefer role/label-based selectors)

Breaking this rule means the consumer owns the breakage risk.

==== 2) Semantic Versioning Rules ====

We use SemVer for @org/ui:

===== Patch (x.y.Z) =====
* bug fixes that do not change intended behavior
* performance improvements
* internal refactors
* docs/story fixes
* a11y improvements that do not change visual layout materially

===== Minor (x.Y.z) =====
* new components
* new optional props with safe defaults
* new variants/slots that do not change existing default rendering
* additive token additions

===== Major (X.y.z) =====
Any change that can reasonably break consumers or change user-visible behavior by default:
* removing or renaming exports, components, props, events
* changing default prop values in a user-visible way
* changing DOM in a way that breaks common integration (e.g., removes an element
  frequently targeted by consumer CSS) unless explicitly declared private
* significant visual change outside defined tolerances
* token changes that alter appearance beyond tolerances

==== 3) Definition of “Breaking” (Practical) ====

A change is breaking if it causes any of these:
* TypeScript compile errors for consumers
* runtime errors for consumers
* layout/visual regressions outside approved tolerances
* behavioral regressions (click, focus, keyboard nav, validation)
* a11y regressions (color contrast, missing labels, keyboard trap, etc.)

If in doubt, treat as breaking (major) or provide a compatibility path.

==== 4) Compatibility Paths (Preferred over Majors) ====

Before a major, prefer:
* keep old prop, introduce new prop; deprecate old prop
* support both old and new values for a release window
* adapter/wrapper exports (temporary) to keep old behavior
* codemod + migration guide

==== 5) Deprecation Policy ====
* Deprecated APIs remain supported for at least:
* 2 minor releases OR 60 days (whichever is longer) within the same major.
* Deprecated APIs must include:
* JSDoc <code>@deprecated</code> with the replacement
* a migration note in CHANGELOG
* We support at most the last 2 majors at any time.
* Older majors receive only critical security/a11y fixes, then are removed on schedule.

==== 6) Consumer Upgrade Policy ====
* Consumers pin versions (no auto-floating to new majors).
* Upgrades happen via explicit dependency bump PRs.
* Any component library major upgrade requires consumer-side validation.
* Patch/minor upgrades should be safe by default; validation can be localized.

==== 7) Required Evidence for Changes ====

Every change to public components must include at least one:
* updated Storybook story (new/changed behavior)
* unit/integration test (Testing Library / Playwright component test)
* visual regression approval (if visuals changed)

==== 8) Ownership ====

Each component has an owner group responsible for:
* reviewing breaking changes
* maintaining deprecations/migrations
* ensuring test coverage is adequate

Enforced boundaries (the part that makes this real)[edit | edit source]

1) Hard export boundary via package.json#exports[edit | edit source]

In packages/ui/package.json:

{
  "name": "@org/ui",
  "version": "1.12.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./button": {
      "types": "./dist/button.d.ts",
      "default": "./dist/button.js"
    }
  }
}

This is the key: if it’s not in exports, it’s not importable (or at least you can make it fail in modern Node/bundlers).

2) Ban deep imports with ESLint[edit | edit source]

In the consumer apps’ ESLint config:

module.exports = {
  rules: {
    "no-restricted-imports": [
      "error",
      {
        "patterns": [
          {
            "group": ["@org/ui/''/''", "@org/ui/src/''", "@org/ui/dist/''"],
            "message": "Deep imports from @org/ui are forbidden. Import only from public entrypoints defined in @org/ui package exports."
          }
        ]
      }
    ]
  }
};

(Adjust patterns to your repo conventions.)

3) Require a Changeset for any UI public change[edit | edit source]

If you use Changesets, you can enforce “no changes to packages/ui without a changeset” in CI.

CI gates: what to run on every PR[edit | edit source]

The checks you want[edit | edit source]
  1. Lint + typecheck (affected scope if you’re using Nx/Turborepo; otherwise run for ui + touched apps)
  2. Unit/integration tests for @org/ui
  3. Build @org/ui (so the export boundary is real)
  4. Public API diff check (type-level breaking changes)
  5. Storybook build + visual regression (Chromatic/Percy)
  6. Changeset presence check for UI changes
GitHub Actions PR workflow (example)[edit | edit source]
name: PR Checks

on:
  pull_request:

jobs:
  ui_contract:
    runs-on: ubuntu-latest
    steps:
* uses: actions/checkout@v4
* uses: pnpm/action-setup@v4
        with:
          version: 9
* uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
* run: pnpm install --frozen-lockfile

      # 1) Enforce no deep imports via lint
* name: Lint
        run: pnpm -r lint

      # 2) Typecheck (catch breaking prop/type changes early)
* name: Typecheck
        run: pnpm -r typecheck

      # 3) UI tests
* name: UI tests
        run: pnpm --filter @org/ui test

      # 4) Build UI (ensures exports/types are generated and valid)
* name: Build UI
        run: pnpm --filter @org/ui build

      # 5) Require a changeset if UI changed
* name: Require changeset for UI changes
        run: pnpm changeset status --since origin/${{ github.base_ref }}
        # Optional: add a small script that fails if packages/ui changed but no changeset exists.

      # 6) Public API report/diff (example using API Extractor)
* name: API report (detect breaking API)
        run: pnpm --filter @org/ui api:check

      # 7) Storybook + visual regression (Chromatic)
* name: Chromatic
        uses: chromaui/action@v11
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          workingDir: packages/ui
          exitOnceUploaded: true

Public API diff gate (TypeScript contract enforcement)[edit | edit source]

If you want a strong “did we break the public API?” check, a common approach is Microsoft’s API Extractor (it produces an API report you can diff). In packages/ui:

package.json scripts[edit | edit source]
{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "api:check": "api-extractor run --local --verbose"
  },
  "devDependencies": {
    "@microsoft/api-extractor": "^7.0.0"
  }
}
api-extractor.json (minimal)[edit | edit source]
{
  "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
  "mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts",
  "apiReport": {
    "enabled": true,
    "reportFileName": "ui.api.md"
  },
  "docModel": {
    "enabled": false
  },
  "dtsRollup": {
    "enabled": false
  }
}

Commit the generated API report (e.g., packages/ui/etc/ui.api.md). Now PRs that change the public API produce a diff that reviewers can treat as “the contract changed.”

What this buys them (and what it doesn’t)[edit | edit source]

  • If they implement exports boundary + no-deep-import lint + semver/changesets + API diff + component-level visual regression, they’ve created an enforceable contract.
  • They will still sometimes have “large affected surface area” for foundational components, but now: - Breaking changes become explicit and opt-in (major bumps). - Non-breaking changes have strong automated confidence so you don’t default to full manual regression.

If you want, I can add the “fail PR if packages/ui/** changed but no changeset exists” script (it’s ~20 lines in Node) and/or show the “two majors supported” policy operationalized (how to remove old majors without drama).

References[edit | edit source]

  1. "Monorepo UI Component Versioning". ChatGPT. Retrieved 2026-01-06.
  2. "changesets-docs.vercel.app". changesets-docs.vercel.app. Retrieved 2026-01-06.
  3. "Nx". nx.dev. Retrieved 2026-01-06.
  4. "GitHub". github.com. Retrieved 2026-01-06.
  5. "chromatic.com". chromatic.com. Retrieved 2026-01-06.
  6. "changesets-docs.vercel.app". changesets-docs.vercel.app. Retrieved 2026-01-06.
  7. "Storybook". storybook.js.org. Retrieved 2026-01-06.
  8. "Nx". nx.dev. Retrieved 2026-01-06.