Every mature design system eventually discovers a weird law of physics: the better it works, the harder it becomes to change. At first, changes are easy. A component library has one or two consumers, everyone ships together, and breaking changes are just “Tuesday.” Then adoption spreads. More teams, more surfaces, more release trains, more “small” assumptions buried in thousands of call sites. A single prop rename becomes a negotiation. A subtle focus change becomes a production incident. Maintainers learn that “safest” often means “do nothing,” and the design system enters its final form: permafrost. permafrost The way out isn’t heroics or a rewrite. It’s mechanics. This article is a migration playbook for senior engineers and tech leads who maintain shared UI libraries: define what “breaking” actually means (spoiler: not just types), publish a deprecation policy with real deadlines, ship codemods that turn weeks of manual edits into minutes, and roll out CI guardrails in phases so you can evolve the library without freezing it. TL;DR A design system freezes when change becomes coordination-heavy and risk becomes asymmetric.“Breaking” includes API, runtime behavior, visual contract, accessibility, and sometimes test/automation hooks.Deprecation is a lifecycle: mark → warn → block new usage → budget down → remove.Codemods are the adoption lever: design changes so 80–95% can be migrated mechanically.CI should prevent spread first, then enforce reduction: warn → block new usage → budget → remove.For behavioral/a11y changes, combine adapters with feature flags / compat modes and stage rollouts. A design system freezes when change becomes coordination-heavy and risk becomes asymmetric. coordination-heavy asymmetric “Breaking” includes API, runtime behavior, visual contract, accessibility, and sometimes test/automation hooks. API runtime behavior visual contract accessibility test/automation hooks Deprecation is a lifecycle: mark → warn → block new usage → budget down → remove. mark → warn → block new usage → budget down → remove Codemods are the adoption lever: design changes so 80–95% can be migrated mechanically. 80–95% CI should prevent spread first, then enforce reduction: warn → block new usage → budget → remove. warn → block new usage → budget → remove For behavioral/a11y changes, combine adapters with feature flags / compat modes and stage rollouts. feature flags / compat modes 1) Why successful component systems freeze Design systems don’t freeze because maintainers lack courage. They freeze because they’re successful. Success looks like: Many consuming repos and teams.Lots of implicit contracts (DOM structure, default styles, event timing, focus behavior).More environments (web, native shells, embedded views, docs, internal tools).A dependency graph where your library upgrade is someone else’s deadline risk. Many consuming repos and teams. Lots of implicit contracts (DOM structure, default styles, event timing, focus behavior). More environments (web, native shells, embedded views, docs, internal tools). A dependency graph where your library upgrade is someone else’s deadline risk. someone else’s deadline risk That creates a predictable failure mode: Blast radius grows. A small change can ripple across dozens of products.Coupling becomes invisible. Consumers depend on “quirks” that were never documented as API.Ownership becomes lopsided. Maintainers “own” breakages; consumers “own” delivery dates.Migration work has no home. Consumers treat upgrades as “overhead,” so adoption slows.Change becomes socially expensive. Every breaking change requires coordination, meetings, and exceptions. Blast radius grows. A small change can ripple across dozens of products. Blast radius grows. Coupling becomes invisible. Consumers depend on “quirks” that were never documented as API. Coupling becomes invisible. Ownership becomes lopsided. Maintainers “own” breakages; consumers “own” delivery dates. Ownership becomes lopsided. Migration work has no home. Consumers treat upgrades as “overhead,” so adoption slows. Migration work has no home. Change becomes socially expensive. Every breaking change requires coordination, meetings, and exceptions. Change becomes socially expensive. Eventually, maintainers stop shipping meaningful improvements because they can’t predict adoption. Consumers stop upgrading because every upgrade feels like unbounded churn. The system becomes stable in the way a fossil is stable. A migration playbook turns upgrades into a pipeline with predictable steps. The goal isn’t zero breakage. The goal is controlled, observable breakage with mechanical adoption—so the library can keep evolving without asking the organization to pause. controlled, observable breakage with mechanical adoption 2) Define what counts as “breaking” (API vs behavior vs a11y) If your definition of “breaking change” is “TypeScript errors,” your users will still suffer—just later, at runtime. For shared UI libraries, treat “breaking” as a set of contracts: API contract (compile-time) API contract (compile-time) Removed exports, renamed symbols, moved packages.Prop rename/removal, type narrowing, required props added.Event handler signature changes. Removed exports, renamed symbols, moved packages. Prop rename/removal, type narrowing, required props added. Event handler signature changes. Behavior contract (runtime) Behavior contract (runtime) A form component changes submit behavior.A modal changes focus trapping, Escape behavior, scroll locking.A dropdown changes keyboard navigation or selection timing. A form component changes submit behavior. A modal changes focus trapping, Escape behavior, scroll locking. A dropdown changes keyboard navigation or selection timing. Visual contract (rendering/layout) Visual contract (rendering/layout) Default spacing, typography, or sizing changes.CSS specificity changes that break overrides.Token changes that alter layout in common configurations. Default spacing, typography, or sizing changes. CSS specificity changes that break overrides. Token changes that alter layout in common configurations. Accessibility contract (a11y) Accessibility contract (a11y) Focus order changes, roving tabindex behavior changes.ARIA attributes change, roles change, names/labels change.“Disabled” semantics change (disabled vs aria-disabled), affecting keyboard and screen readers.Contrast shifts due to token changes. Focus order changes, roving tabindex behavior changes. ARIA attributes change, roles change, names/labels change. “Disabled” semantics change (disabled vs aria-disabled), affecting keyboard and screen readers. disabled aria-disabled Contrast shifts due to token changes. Automation/integration contract (optional but real) Automation/integration contract (optional but real) Test selectors, data attributes, predictable element structure used by automation.Event names or payload shapes used by analytics layers (if those are consumer-visible contracts). Test selectors, data attributes, predictable element structure used by automation. Event names or payload shapes used by analytics layers (if those are consumer-visible contracts). The point isn’t to promise you’ll never change these. The point is to classify the break so you can pick the right mitigation: classify the break API breaks: adapters + deprecations + codemods.Behavior/a11y breaks: compat flags + staged rollout + explicit docs.Visual breaks: opt-in themes, versioned tokens, clear change notes. API breaks: adapters + deprecations + codemods. Behavior/a11y breaks: compat flags + staged rollout + explicit docs. Visual breaks: opt-in themes, versioned tokens, clear change notes. When you don’t name these categories, consumers will name them for you—usually in incident reports. 3) Deprecation policy in writing (a contract, not folklore) A deprecation policy is an API constitution. If it’s not written down, you don’t have policy—you have vibes and tribal memory. A usable policy answers five questions: How do we mark deprecations? (types, docs, runtime warnings)How long do deprecations live? (time window or number of minor versions)What replaces the deprecated thing? (a direct replacement or a migration path)How do consumers migrate? (codemod + manual steps for edge cases)How do we enforce removal? (CI phases and a major-release boundary) How do we mark deprecations? (types, docs, runtime warnings) How do we mark deprecations? How long do deprecations live? (time window or number of minor versions) How long do deprecations live? What replaces the deprecated thing? (a direct replacement or a migration path) What replaces the deprecated thing? How do consumers migrate? (codemod + manual steps for edge cases) How do consumers migrate? How do we enforce removal? (CI phases and a major-release boundary) How do we enforce removal? A pragmatic policy you can publish in a README might look like this (in plain language): Marker: Deprecated APIs are annotated in types/docs and emit a dev-only warning the first time they are used.Support window: Deprecated APIs remain supported for at least N minor releases (or X months) after the replacement ships.Removal target: Every deprecation includes a target removal major version (and optionally an earliest removal minor).Migration path: Every deprecation includes a replacement example and (when feasible) a codemod.Enforcement: CI enforcement ramps in phases: warn → block new usage → budget down → remove. Marker: Deprecated APIs are annotated in types/docs and emit a dev-only warning the first time they are used. Marker: dev-only warning Support window: Deprecated APIs remain supported for at least N minor releases (or X months) after the replacement ships. Support window: N minor releases X months Removal target: Every deprecation includes a target removal major version (and optionally an earliest removal minor). Removal target: target removal major version Migration path: Every deprecation includes a replacement example and (when feasible) a codemod. Migration path: Enforcement: CI enforcement ramps in phases: warn → block new usage → budget down → remove. Enforcement: Two details matter more than everything else: Deprecations need a clock. “Deprecated” without a removal target becomes permanent.Deprecations need a map. “Deprecated” without a replacement forces consumers to invent their own migration. Deprecations need a clock. “Deprecated” without a removal target becomes permanent. Deprecations need a clock. Deprecations need a map. “Deprecated” without a replacement forces consumers to invent their own migration. Deprecations need a map. Policy is how you turn “please migrate” into “this is how migrations work here.” 4) Build compatibility: adapters, feature flags, and two-speed APIs Migration-friendly design systems don’t rely on consumers to pause their roadmap. They ship changes in a way that consumers can adopt incrementally. Adapter layers (compat wrappers) An adapter keeps old call sites working while internally translating to the new API. Common patterns: Map old prop names to new ones.Preserve old defaults while allowing opt-in to new behavior.Alias old exports to new implementations while emitting warnings. Map old prop names to new ones. Preserve old defaults while allowing opt-in to new behavior. Alias old exports to new implementations while emitting warnings. Adapters buy you time. They also give you a place to: Warn in development.Normalize edge cases.Keep the underlying implementation unified (so you’re not maintaining two completely separate components). Warn in development. Normalize edge cases. Keep the underlying implementation unified (so you’re not maintaining two completely separate components). Feature flags / compat modes Adapters are great for API shape changes. They’re not always enough for behavior changes—especially focus, keyboard navigation, and timing-sensitive interactions. For behavioral and a11y changes, prefer staged rollout tools: Component-level compat props (e.g., behavior="v2").Provider-level compat modes (e.g., a global setting that keeps legacy focus behavior).Flags that can be toggled in a controlled rollout. Component-level compat props (e.g., behavior="v2"). behavior="v2" Provider-level compat modes (e.g., a global setting that keeps legacy focus behavior). Flags that can be toggled in a controlled rollout. The goal is to ship the new behavior safely, learn from real usage, and give consumers a way to opt in or temporarily opt out while they validate. Two-speed API design Not everything should evolve at the same pace. A migration-friendly library often separates: A stable core contract (rarely breaks).An extension surface designed to evolve (slots, theming hooks, optional capabilities). A stable core contract (rarely breaks). stable core An extension surface designed to evolve (slots, theming hooks, optional capabilities). extension surface If the only way to add capability is to change the core contract, you’ll trigger global migrations too often. Two-speed design reduces how frequently you need “everyone change everything” moments. Compatibility isn’t about coddling consumers. It’s about buying enough runway for upgrades to become routine. 5) Codemods: the adoption lever that turns weeks into minutes Most migrations fail because they ask humans to do repetitive edits at scale. Humans are excellent at design judgment and terrible at bulk refactors across thousands of call sites. Codemods (automated code transformations) fix that mismatch. Codemods are best at: Renaming imports and exports.Renaming props with direct mapping.Wrapping components in predictable ways.Rewriting simple JSX patterns. Renaming imports and exports. Renaming props with direct mapping. Wrapping components in predictable ways. Rewriting simple JSX patterns. Codemods are bad at: Inferring product intent.Untangling highly dynamic patterns.Fixing logic that depends on undocumented quirks. Inferring product intent. Untangling highly dynamic patterns. Fixing logic that depends on undocumented quirks. So the maintainers’ job is to design migrations to be codemoddable: design migrations to be codemoddable Prefer changes that are structurally mappable.Make the “new API” a clear rewrite of the old one (not a totally different concept).Ship the new API first, then codemod usage, then remove the old. Prefer changes that are structurally mappable. Make the “new API” a clear rewrite of the old one (not a totally different concept). Ship the new API first, then codemod usage, then remove the old. Operationally: Version codemods alongside the library so consumers can trust tool/library alignment.Make codemods idempotent (running twice should be safe).Include a “report mode” that lists files/edge cases even when it can’t auto-fix them. Version codemods alongside the library so consumers can trust tool/library alignment. Make codemods idempotent (running twice should be safe). Include a “report mode” that lists files/edge cases even when it can’t auto-fix them. A codemod is more than convenience. It’s the difference between a migration being “eventually” and being “this sprint.” 6) CI guardrails: phased enforcement that doesn’t brick the world CI is where migrations stop being a suggestion. But enforcement must be staged, or you’ll train teams to avoid upgrades. A phased model works because it matches organizational reality: Phase 1 — Warn Phase 1 — Warn Deprecation annotations show up in editors.Dev-only runtime warnings surface usage.Docs + codemod exist. Deprecation annotations show up in editors. Dev-only runtime warnings surface usage. Docs + codemod exist. Goal: visibility without pain. Phase 2 — Block new usage Phase 2 — Block new usage This is the highest-leverage phase. You don’t need everyone to migrate immediately. You need to stop deprecated APIs from spreading. A “no new usage” gate prevents the problem from getting worse while teams migrate on their schedule. Implementation ideas: A lint rule that flags deprecated imports/props.A CI job that compares deprecated usage against a baseline and fails only on increases. A lint rule that flags deprecated imports/props. A CI job that compares deprecated usage against a baseline and fails only on increases. Phase 3 — Budget down Phase 3 — Budget down Once you’ve stopped the bleeding, apply steady pressure: Set a deprecated-usage budget (count-based or file-based).Decrease the budget over time (per release or per milestone).Make exceptions explicit and temporary. Set a deprecated-usage budget (count-based or file-based). Decrease the budget over time (per release or per milestone). Make exceptions explicit and temporary. Goal: predictable progress without panic. Phase 4 — Remove (major) Phase 4 — Remove (major) Now removal is boring: Deprecated usage is near zero.Remaining usage is known and intentional.Consumers have already had multiple phases to adapt. Deprecated usage is near zero. Remaining usage is known and intentional. Consumers have already had multiple phases to adapt. The principle: first prevent spread, then enforce reduction. If you skip Phase 2, Phase 3 becomes impossible, and Phase 4 becomes a crisis. first prevent spread, then enforce reduction 7) Versioning + deprecation timeline (make time visible) Version numbers don’t reduce pain by themselves. What reduces pain is predictability: consumers should know when something will stop working and how to fix it. predictability If you use semantic versioning, define what it means for your UI library: Patch: bug fixes; avoid intentional layout changes unless correcting a bug.Minor: additive features + deprecations + opt-in behavior changes.Major: removals and intentional breaking changes (API/behavior/a11y/visual contract). Patch: bug fixes; avoid intentional layout changes unless correcting a bug. Patch: Minor: additive features + deprecations + opt-in behavior changes. Minor: Major: removals and intentional breaking changes (API/behavior/a11y/visual contract). Major: Then publish a deprecation lifecycle that aligns with those releases. Time ────────────────────────────────────────────────────────────────────> v1.8 v1.9 v1.10 v1.11 v2.0 | | | | | | Ship NewAPI | Deprecate | Block new | Budget down | Remove | (additive) | OldAPI | OldAPI usage | OldAPI usage | OldAPI | | | | | |--------------|---------------|---------------|----------------|--------> ^ types/docs + dev warn ^ CI: no-new-uses ^ hard break ^ codemod released ^ adoption continues (expected) Time ────────────────────────────────────────────────────────────────────> v1.8 v1.9 v1.10 v1.11 v2.0 | | | | | | Ship NewAPI | Deprecate | Block new | Budget down | Remove | (additive) | OldAPI | OldAPI usage | OldAPI usage | OldAPI | | | | | |--------------|---------------|---------------|----------------|--------> ^ types/docs + dev warn ^ CI: no-new-uses ^ hard break ^ codemod released ^ adoption continues (expected) What to include in every deprecation notice (in code and docs): Deprecated since: version.Replacement: link or name.Migration: codemod availability and manual steps for edge cases.Removal target: major version. Deprecated since: version. Replacement: link or name. Migration: codemod availability and manual steps for edge cases. Removal target: major version. When you make time visible, migrations stop being a surprise and start being a plan. 8) The migration pipeline: warn → block → remove (turn policy into machinery) A deprecation policy becomes real when it maps to an operational pipeline that maintainers can run repeatedly. The pipeline is a state machine, not a one-off event: You add the new API.You deprecate the old.You make adoption mechanical (codemod + adapter).You gradually enforce.You remove on schedule. You add the new API. You deprecate the old. You make adoption mechanical (codemod + adapter). You gradually enforce. You remove on schedule. ┌──────────────────────────────────┐ │ Replacement API ships │ │ (additive, documented) │ └──────────────┬───────────────┘ │ v ┌────────────────────────────────┐ │ Phase 1: WARN │ │ - @deprecated in types/docs │ │ - dev-only warning (once) │ │ - adapter keeps old working │ └──────────────┬───────────────┘ │ v ┌────────────────────────────────┐ │ Codemod available │ │ - bulk rewrite + report │ │ - leaves TODOs for edge cases │ └──────────────┬───────────────┘ │ v ┌────────────────────────────────┐ │ Phase 2: BLOCK NEW USAGE │ │ - CI fails on usage increases │ │ - lint/import restrictions │ └──────────────┬───────────────┘ │ v ┌────────────────────────────────┐ │ Phase 3: BUDGET DOWN │ │ - CI enforces a decreasing cap│ │ - exceptions are explicit │ └──────────────┬───────────────┘ │ v ┌──────────────────────────────┐ │ Phase 4: REMOVE (major) │ │ - delete old exports/adapters│ │ - archive migration docs │ └──────────────────────────────┘ ┌──────────────────────────────────┐ │ Replacement API ships │ │ (additive, documented) │ └──────────────┬───────────────┘ │ v ┌────────────────────────────────┐ │ Phase 1: WARN │ │ - @deprecated in types/docs │ │ - dev-only warning (once) │ │ - adapter keeps old working │ └──────────────┬───────────────┘ │ v ┌────────────────────────────────┐ │ Codemod available │ │ - bulk rewrite + report │ │ - leaves TODOs for edge cases │ └──────────────┬───────────────┘ │ v ┌────────────────────────────────┐ │ Phase 2: BLOCK NEW USAGE │ │ - CI fails on usage increases │ │ - lint/import restrictions │ └──────────────┬───────────────┘ │ v ┌────────────────────────────────┐ │ Phase 3: BUDGET DOWN │ │ - CI enforces a decreasing cap│ │ - exceptions are explicit │ └──────────────┬───────────────┘ │ v ┌──────────────────────────────┐ │ Phase 4: REMOVE (major) │ │ - delete old exports/adapters│ │ - archive migration docs │ └──────────────────────────────┘ A concrete implementation needs three building blocks: A deprecation marker that developers see while coding (types/docs).A runtime warning that catches non-type usage (dev-only, warn once).A CI gate that shifts from “inform” to “enforce” (block new usage → budget). A deprecation marker that developers see while coding (types/docs). A deprecation marker that developers see while coding A runtime warning that catches non-type usage (dev-only, warn once). A runtime warning that catches non-type usage A CI gate that shifts from “inform” to “enforce” (block new usage → budget). A CI gate that shifts from “inform” to “enforce” Here’s a minimal example of (1) + (2). // Deprecated component wrapper with type deprecation + dev-only warning. // Generic example: mapping legacy props to a new API. import * as React from "react"; import { Button as ButtonV2, type ButtonProps as ButtonV2Props } from "./ButtonV2"; const warned = new Set<string>(); function warnOnce(key: string, message: string) { if (process.env.NODE_ENV === "production") return; if (warned.has(key)) return; warned.add(key); // eslint-disable-next-line no-console console.warn(message); } /** * @deprecated Use `ButtonV2` instead. * Deprecated since: v1.9. Removal target: v2.0. */ export type LegacyButtonProps = Omit<ButtonV2Props, "tone"> & { /** * @deprecated Use `tone`. * Legacy mapping: "primary" -> "brand", "secondary" -> "neutral" */ variant?: "primary" | "secondary"; }; export function LegacyButton(props: LegacyButtonProps) { warnOnce( "LegacyButton", `[DesignSystem] LegacyButton is deprecated (since v1.9). ` + `Use ButtonV2 instead. Removal target: v2.0.` ); const { variant, ...rest } = props; const tone: ButtonV2Props["tone"] = variant === "primary" ? "brand" : variant === "secondary" ? "neutral" : undefined; return <ButtonV2 {...rest} tone={tone} />; } // Deprecated component wrapper with type deprecation + dev-only warning. // Generic example: mapping legacy props to a new API. import * as React from "react"; import { Button as ButtonV2, type ButtonProps as ButtonV2Props } from "./ButtonV2"; const warned = new Set<string>(); function warnOnce(key: string, message: string) { if (process.env.NODE_ENV === "production") return; if (warned.has(key)) return; warned.add(key); // eslint-disable-next-line no-console console.warn(message); } /** * @deprecated Use `ButtonV2` instead. * Deprecated since: v1.9. Removal target: v2.0. */ export type LegacyButtonProps = Omit<ButtonV2Props, "tone"> & { /** * @deprecated Use `tone`. * Legacy mapping: "primary" -> "brand", "secondary" -> "neutral" */ variant?: "primary" | "secondary"; }; export function LegacyButton(props: LegacyButtonProps) { warnOnce( "LegacyButton", `[DesignSystem] LegacyButton is deprecated (since v1.9). ` + `Use ButtonV2 instead. Removal target: v2.0.` ); const { variant, ...rest } = props; const tone: ButtonV2Props["tone"] = variant === "primary" ? "brand" : variant === "secondary" ? "neutral" : undefined; return <ButtonV2 {...rest} tone={tone} />; } And here’s a lightweight CI gate idea for Phase 2/3: fail builds when deprecated usage increases (block new) or exceeds a budget (budget down). The scanning can be implemented via ESLint output, TypeScript compiler API, or a fast pattern-based scan—what matters is the behavior. // scripts/deprecation-gate.js // CI guardrail: prevent new deprecated usage and optionally enforce a usage budget. // // Inputs: // - DEPRECATION_PHASE: "warn" | "block-new" | "budget" // - DEPRECATION_BUDGET: number (used in "budget" phase) // - .deprecations-baseline.json: committed snapshot of deprecated usage counts // // The scanner can be implemented via ESLint JSON output, TS compiler API, or simple patterns. const fs = require("node:fs"); const path = require("node:path"); const BASELINE_PATH = path.join(process.cwd(), ".deprecations-baseline.json"); const PHASE = process.env.DEPRECATION_PHASE || "block-new"; const BUDGET = Number(process.env.DEPRECATION_BUDGET || "0"); function scanDeprecatedUsages() { // Replace this with real scanning logic. // Return an object: { "LegacyButton": 12, "LegacyModal": 2 } return { LegacyButton: 12, LegacyModal: 2 }; } function readBaseline() { if (!fs.existsSync(BASELINE_PATH)) return {}; return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); } function totalCounts(map) { return Object.values(map).reduce((sum, n) => sum + n, 0); } const current = scanDeprecatedUsages(); const baseline = readBaseline(); const currentTotal = totalCounts(current); const baselineTotal = totalCounts(baseline); if (PHASE === "warn") { console.log(`[deprecation-gate] WARN: ${currentTotal} deprecated usages detected.`); process.exit(0); } if (PHASE === "block-new") { if (currentTotal > baselineTotal) { console.error( `[deprecation-gate] FAIL: deprecated usage increased (${baselineTotal} → ${currentTotal}).\n` + `Run the codemod and migrate deprecated APIs.` ); process.exit(1); } process.exit(0); } if (PHASE === "budget") { if (currentTotal > BUDGET) { console.error( `[deprecation-gate] FAIL: deprecated usage budget exceeded (${currentTotal} > ${BUDGET}).\n` + `Migrate deprecated APIs to get under budget.` ); process.exit(1); } process.exit(0); } console.error(`[deprecation-gate] Unknown DEPRECATION_PHASE="${PHASE}"`); process.exit(2); // scripts/deprecation-gate.js // CI guardrail: prevent new deprecated usage and optionally enforce a usage budget. // // Inputs: // - DEPRECATION_PHASE: "warn" | "block-new" | "budget" // - DEPRECATION_BUDGET: number (used in "budget" phase) // - .deprecations-baseline.json: committed snapshot of deprecated usage counts // // The scanner can be implemented via ESLint JSON output, TS compiler API, or simple patterns. const fs = require("node:fs"); const path = require("node:path"); const BASELINE_PATH = path.join(process.cwd(), ".deprecations-baseline.json"); const PHASE = process.env.DEPRECATION_PHASE || "block-new"; const BUDGET = Number(process.env.DEPRECATION_BUDGET || "0"); function scanDeprecatedUsages() { // Replace this with real scanning logic. // Return an object: { "LegacyButton": 12, "LegacyModal": 2 } return { LegacyButton: 12, LegacyModal: 2 }; } function readBaseline() { if (!fs.existsSync(BASELINE_PATH)) return {}; return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); } function totalCounts(map) { return Object.values(map).reduce((sum, n) => sum + n, 0); } const current = scanDeprecatedUsages(); const baseline = readBaseline(); const currentTotal = totalCounts(current); const baselineTotal = totalCounts(baseline); if (PHASE === "warn") { console.log(`[deprecation-gate] WARN: ${currentTotal} deprecated usages detected.`); process.exit(0); } if (PHASE === "block-new") { if (currentTotal > baselineTotal) { console.error( `[deprecation-gate] FAIL: deprecated usage increased (${baselineTotal} → ${currentTotal}).\n` + `Run the codemod and migrate deprecated APIs.` ); process.exit(1); } process.exit(0); } if (PHASE === "budget") { if (currentTotal > BUDGET) { console.error( `[deprecation-gate] FAIL: deprecated usage budget exceeded (${currentTotal} > ${BUDGET}).\n` + `Migrate deprecated APIs to get under budget.` ); process.exit(1); } process.exit(0); } console.error(`[deprecation-gate] Unknown DEPRECATION_PHASE="${PHASE}"`); process.exit(2); Once you have these mechanics, migrations stop being an emergency project and become part of how the library evolves. Pitfalls & Fi xes Pitfall: Deprecations exist, but nobody notices.Fix: Use at least two signals: type deprecation + dev-only runtime warning. Ensure the warning includes the replacement and a removal target version.Pitfall: Warnings spam logs, so teams silence them.Fix: Warn once per session (or once per component key). Make the message short and actionable. Noise trains people to ignore you.Pitfall: You changed behavior, but the API stayed the same.Fix: Treat behavior and accessibility changes as breaking unless guarded. Provide a compat mode or feature flag and document the behavioral delta with examples.Pitfall: You shipped the new API, but the old API is easier to use.Fix: New API must not be a tax. If it requires more boilerplate, add helper utilities, better defaults, or codemods that insert required scaffolding.Pitfall: Codemods create huge unreadable diffs.Fix: Keep transforms minimal (rename/mapping over refactor). Run your formatter afterward. Favor predictable diffs over clever rewrites.Pitfall: CI gates break everything overnight.Fix: Roll out in phases. Start by blocking new usage only. Then introduce a budget. Avoid flipping directly from “allowed” to “forbidden.”Pitfall: Adapters become permanent, doubling maintenance.Fix: Treat adapters as scaffolding with an expiration date. Put the removal target in code comments and track adapters like debt.Pitfall: Consumers disagree on what “breaking” means. Pitfall: Deprecations exist, but nobody notices. Pitfall: Deprecations exist, but nobody notices. Fix: Use at least two signals: type deprecation + dev-only runtime warning. Ensure the warning includes the replacement and a removal target version. Fix: Pitfall: Warnings spam logs, so teams silence them. Pitfall: Warnings spam logs, so teams silence them. Fix: Warn once per session (or once per component key). Make the message short and actionable. Noise trains people to ignore you. Fix: Pitfall: You changed behavior, but the API stayed the same. Pitfall: You changed behavior, but the API stayed the same. Fix: Treat behavior and accessibility changes as breaking unless guarded. Provide a compat mode or feature flag and document the behavioral delta with examples. Fix: Pitfall: You shipped the new API, but the old API is easier to use. Pitfall: You shipped the new API, but the old API is easier to use. Fix: New API must not be a tax. If it requires more boilerplate, add helper utilities, better defaults, or codemods that insert required scaffolding. Fix: Pitfall: Codemods create huge unreadable diffs. Pitfall: Codemods create huge unreadable diffs. Fix: Keep transforms minimal (rename/mapping over refactor). Run your formatter afterward. Favor predictable diffs over clever rewrites. Fix: Pitfall: CI gates break everything overnight. Pitfall: CI gates break everything overnight. Fix: Roll out in phases. Start by blocking new usage only. Then introduce a budget. Avoid flipping directly from “allowed” to “forbidden.” Fix: new Pitfall: Adapters become permanent, doubling maintenance. Pitfall: Adapters become permanent, doubling maintenance. Fix: Treat adapters as scaffolding with an expiration date. Put the removal target in code comments and track adapters like debt. Fix: Pitfall: Consumers disagree on what “breaking” means. Pitfall: Consumers disagree on what “breaking” means. Fix: Publish the breaking-change taxonomy (API/behavior/visual/a11y) and align release notes and versioning to it. Consistency beats perfection. Fix: Adoption checklist Classify the change: API / behavior / visual / a11y / integration contract. Ship the replacement API first (additive), with docs and examples. Add an adapter layer so old usage continues to work temporarily. Mark types/docs with @deprecated and include removal target version. Add a dev-only runtime warning that fires once and points to the replacement. Build and publish a codemod; ensure it’s idempotent and has a report mode. Roll out CI enforcement in phases: warn → block new usage → budget down. Stop spread before pushing reduction: Phase 2 must come before Phase 3. For behavior/a11y changes, provide feature flags or compat modes for staged rollout. Classify the change: API / behavior / visual / a11y / integration contract. Ship the replacement API first (additive), with docs and examples. Add an adapter layer so old usage continues to work temporarily. Mark types/docs with @deprecated and include removal target version. @deprecated Add a dev-only runtime warning that fires once and points to the replacement. Build and publish a codemod; ensure it’s idempotent and has a report mode. Roll out CI enforcement in phases: warn → block new usage → budget down. Stop spread before pushing reduction: Phase 2 must come before Phase 3. For behavior/a11y changes, provide feature flags or compat modes for staged rollout. Remove deprecated APIs in a major release after the support window eny? Conclusion Migrations fail when they rely on hope: “everyone will read the docs and update their code.” They succeed when you make the safe path the default: explicit deprecation metadata, machine-enforced timelines, and automated fixes that land in PRs. If you build one thing first, build the pipeline: a token registry (or component contract registry) that can emit warnings, generate changelogs, run codemods, and block merges when policy says it’s time. Once the guardrails exist, the design system can evolve quickly without turning every release into a fire drill.