The dependency upgrade decision record: documenting the 'why now?' of a breaking migration

Dependency upgrades are almost universally treated as technical tasks, not decisions. A ticket gets filed, an engineer runs the migration, a PR goes up with "upgrade react to 18.3.1" in the commit message. The why-now question — why this quarter rather than last quarter or next, why upgrade rather than pin-and-patch or replace, what adjacent decisions the migration forced — almost never gets written down. What gets committed is the outcome. What disappears is the reasoning that made the outcome the right call at the time it was made.

The gap shows up clearly when a new engineer inherits the codebase. They find the upgraded version in package.json, the migration PR in git history, and zero explanation of why the upgrade happened when it did. Did the team upgrade because of a security vulnerability? Because the old version went end-of-life? Because a peer library started requiring the new version? Because an engineer who knew the migration path was about to leave? The PR description says "upgrade react to 18.3.1 — resolves concurrent rendering issues" but gives no account of why concurrent rendering became a priority in that sprint rather than six months earlier or later, what alternatives to upgrading were considered, or what adjacent decisions the migration forced about which rendering patterns are now expected across the codebase.

The absence is not unusual. It is the standard. Dependency upgrades sit in an awkward category for decision documentation: they feel too routine to merit an architecture decision record but are consequential enough that their timing, rationale, and alternatives are regularly re-litigated. Teams debate upgrading the same dependency across three sprint planning sessions before making a call. Teams inherit migration debt from a predecessor and have no way to learn whether the deferral was deliberate or oversight. Teams discover mid-migration that the upgrade cost has crossed the threshold where replacement would have been the honest comparison — and have no record of whether anyone made that comparison before committing to the upgrade path.

The standard guidance for architecture decision records focuses on foundational decisions — which database, which framework, which deployment model. Dependency upgrade decisions are treated as implementation details sitting below the architectural threshold. This classification is usually wrong, and the cost of being wrong compounds each time the decision gets re-relitigated without a record of the prior evaluation.

The task vs. the decision

The difference between a dependency upgrade as a task and a dependency upgrade as a decision is specific. A task has a clear mechanical definition: the current version is X, the target version is Y, the migration guide lists the breaking changes, the engineer applies the changes, the tests pass, the PR merges. This is complete and correct as a task description. What it omits is everything that determined whether the task was worth doing now versus later versus not at all.

Three real decisions are embedded in every breaking dependency upgrade:

The timing decision. Why this quarter? What changed that made the upgrade timely now rather than earlier or later? This is the question most likely to be answered in AI chat and least likely to appear in any written artifact. The engineer who decided to upgrade Node.js from 18 to 22 in Q2 ran a deliberation — consciously or not — about what made this quarter the right window. The trigger might have been a CVE disclosure with an upcoming support-end date, a peer library that dropped support for the older version, a team composition change that temporarily raised the team's capacity for migration work, or a cost calculation showing that the gap between current and target was about to grow as LTS branches diverged further. None of this appears in "upgrade node to 22.3.0."

The alternatives decision. What else was considered besides upgrading? The standard alternatives to a breaking upgrade are: pin the current version and accept no security updates for a defined period; fork and patch the single breaking change without accepting the full version increment; replace the dependency with one that already has the desired API shape; or wrap the dependency behind an adapter interface that lets the upgrade happen incrementally. Most engineers evaluate at least some of these informally. The one that gets selected is the one that ends up in the commit. The others vanish.

The adjacent decisions forced by migration. Most breaking upgrades force at least one adjacent decision that is implicit in the migration work but never explicitly named. A React 17 → 18 migration forces decisions about which rendering patterns are now expected across the codebase (concurrent mode changes what certain render-phase side effects mean). A Node.js 14 → 20 migration forces decisions about ESM/CJS boundaries that were not forced under the older version. A TypeScript 4 → 5 migration forces decisions about how to handle stricter inference on patterns that were previously legal. These forced adjacent decisions are almost always handled inline as part of the migration PR — the engineer makes the call, the code reflects it, and the ADR for the upgrade contains nothing about the additional decisions made during migration.

The 'why now?' question

The why-now question is the most important content in a dependency upgrade decision record and the most consistently missing. Its absence is not random: the answer is often informal, specific to a moment in time, and feels more like operational context than architectural reasoning. Teams are reluctant to write "we upgraded now because the engineer who had done this before at a previous job had bandwidth this sprint" even though that is sometimes the honest account of the timing.

The upgrade trigger falls into five categories, and naming which category applies is the minimum viable content for the why-now section:

Security trigger. A CVE with a specified severity. The decision record names the CVE, its CVSS score, and the action the team decided was appropriate given the severity. A CVSS 5.5 vulnerability warrants a different urgency than a CVSS 9.8. Teams that upgrade without recording the CVE that triggered it leave no account of why the upgrade was urgent, which creates a gap when a subsequent team member asks whether the current version is on a secure dependency set — they cannot trace which vulnerabilities prompted which upgrades.

End-of-life trigger. The maintainer announced an end-of-support date. The decision record names the date and the team's assessment of what "end-of-support" means for their exposure — security patches only, no critical bug fixes, no compatibility guarantees. Different teams have legitimately different risk tolerances for EOL dependencies, and documenting the reasoning makes the tolerance explicit rather than assumed. A team that accepts three additional months past an EOL date for a low-traffic internal tool is making a different risk call than a team that accepts the same deferral for a customer-facing payment processing library, and the records should reflect that difference.

Blocking dependency trigger. A peer library updated its peer dependency requirement to exclude the current version. The decision record names which library created the blocking requirement and why upgrading that library was itself a priority. This matters because the blocking chain is often non-obvious to later engineers: why did we upgrade library A to version 5? Because library B version 3 requires it. Why did we upgrade library B? Because library C version 7 requires it. Absent a record, this chain is reconstructable only through archaeology.

Gap cost trigger. The cost of waiting another quarter was evaluated and found to exceed the cost of upgrading now. The gap cost calculation works like this: every release cycle that passes between current and target version adds breaking changes that accumulate on top of the existing migration burden. A team deferring a React 16 → 17 upgrade until version 18 ships has to migrate across two major versions simultaneously. A team deferring a TypeScript 3 → 4 upgrade until TypeScript 5 releases is migrating across accumulated deprecations from two major versions. The gap cost trigger is the most analytically honest reason for upgrading and the one that least often gets written down, because it requires an explicit cost calculation that feels like over-engineering for what appears to be a routine upgrade.

Team knowledge trigger. An engineer who knows the migration path well — because they ran it at a previous company, because they contributed to the library, because they have deep familiarity with the version's breaking changes — has bandwidth now and won't in the next quarter. This trigger is accurate and honest and almost never written in a decision record because it sounds like an ad hoc justification rather than an architectural reason. It is both of those things simultaneously. The decision was correct, the timing was knowledge-driven, and documenting it honestly prevents the retrospective confusion of "why did we choose this sprint for the migration?"

The 'why not defer?' question

The inverse of the why-now question is the question that most commonly surfaces in AI chat during migration evaluation: why not wait? Engineers use this framing when evaluating whether the upgrade is worth the disruption in the current period. The answer is the content of the decision record's Consequences section — what accumulates if the upgrade is deferred.

Three accumulation mechanisms make deferral expensive in ways that are not obvious at the time of deferral:

The gap growth problem. The migration cost is not proportional to the version distance. A one-major-version upgrade is a specific set of breaking changes documented in the migration guide. A two-major-version upgrade is not twice the work — it is the breaking changes from two separate design cycles, where the second design cycle often deprecates or reverses choices made in the first, producing changes that are not additive but contradictory. Teams that defer upgrades until the gap is multiple major versions are not deferring work; they are compounding it. The gap growth argument has the highest persuasive value in AI chat deliberations and the lowest presence in ADRs, because it requires the engineer to articulate a non-linear cost model in a commit message.

The version skew problem. When a codebase has services or modules on different versions of a shared dependency, integration assumptions start to diverge. Tests that mock a library's behavior at version 17 may pass incorrectly against a service that has upgraded to version 18 if the mock doesn't account for the behavioral change. A monorepo with some packages on the old version and some on the new creates dependency graph complexity that makes future migrations more expensive. Version skew is often the actual cost that makes a delayed upgrade painful, and it is a cost that was incurred silently in the quarters when the upgrade was deferred.

The knowledge decay problem. The engineers most equipped to execute a migration are the ones who know the old version's edge cases well enough to recognize which behaviors will change. Their familiarity with the old version's quirks — the specific patterns that will break, the test infrastructure assumptions that won't hold, the integration points most likely to surface bugs — degrades as they move on to other work, change roles, or leave the team. The institutional knowledge for running the migration is highest immediately after the decision to upgrade is made. Deferring the migration does not preserve this knowledge; it depletes it. A team that defers a Node.js upgrade by two quarters to wait for a quieter sprint has also deferred until after the two engineers most familiar with the existing configuration have moved to other projects.

The "not building this" record type is the most commonly missing ADR category — and the deferral decision is its dependency-management equivalent. "Upgrading React to 18, but not this quarter — revisiting when the feature freeze ends in Q4" is a closed decision with a revisitation condition. Without writing it down, the deferral becomes indefinite by default. The trigger condition fades. The team relitigates the question in Q3 sprint planning with no record of what was decided in Q1 or why Q4 was the nominated window.

Alternatives to upgrading

The standard treatment of a dependency upgrade is binary: upgrade or defer. The decision record should name all four alternatives and the constraint that drove the selection.

Pin and patch. Accept the current version, freeze the dependency, and apply security patches manually for a defined period. This is appropriate when the breaking changes in the new version have high migration cost and the current version has no critical security exposure. It requires naming the period explicitly — "we're accepting the current version until Q4 2026" — and the conditions that would override it. A pin decision without a named end-condition is not a decision; it is a drift toward permanent dependency on an abandoned version.

Fork and patch. Accept the current version's behavior, apply only the specific breaking change from the new version that the team needs, and maintain the fork internally. This is appropriate when the new version contains exactly one feature the team needs and the remaining changes are costly to adopt. Forks have a maintenance burden that grows with each subsequent release — every upstream patch that the team wants requires a forward-port to the fork. The fork decision requires an honest accounting of this burden and a named condition for abandoning the fork in favor of full adoption.

Replace. Replace the dependency with an alternative that already has the desired API shape. This comparison becomes relevant when the migration cost to the new version exceeds the migration cost to a replacement — not an abstract "maybe we could use X instead" but a specific comparison of the two cost estimates. The replace alternative is frequently evaluated informally during AI chat deliberation and almost never recorded, because by the time the engineer has evaluated the comparison and decided to upgrade rather than replace, the evaluation feels like preliminary thinking rather than a recorded decision. It is both.

Wrap and decouple. Introduce an adapter interface that sits between the application code and the dependency, then migrate behind the interface incrementally. This approach converts a breaking migration from a large single PR into a series of smaller changes, but requires the up-front investment of writing and maintaining the adapter layer. The wrap-and-decouple alternative is most appropriate when the dependency is deeply integrated across a large codebase and the upgrade's behavioral changes are localized to the API surface the adapter would wrap. The decision record should name whether this alternative was evaluated and why it was or wasn't adopted.

Writing the alternatives section changes the decision. A team that writes out the pin-and-patch alternative with a specific condition ("acceptable until the CVE severity exceeds CVSS 7.0") may discover that the current migration urgency is lower than assumed. A team that writes out the replace alternative with a cost estimate may discover that replacement is the honest comparison for a dependency whose API has drifted substantially from the team's usage pattern. The blank alternatives section is the most useful thinking tool in the room — and teams that open the ADR template before committing to the upgrade path will make a different quality of decision than teams that open it to document a decision already made.

The hidden decision inside a migration

Every breaking migration forces at least one adjacent decision that is made implicitly during the migration work. These forced adjacent decisions deserve their own records because they are consequential, they are made under time pressure when the engineer is focused on making the tests pass rather than on documenting reasoning, and they are invisible to anyone who reads the codebase after the migration is complete.

Three categories of forced adjacent decision appear consistently across different dependency types:

Rendering or evaluation model decisions. A React 17 → 18 migration forces decisions about which rendering patterns are now expected. The concurrent rendering model changes what it means to read state during render-phase side effects. Teams migrating to React 18 make implicit decisions about whether they're adopting Suspense, whether they're using startTransition to wrap state updates that can be interrupted, and what their policy is on the rendering patterns now flagged as incorrect under strict mode. These decisions determine the architecture of UI code across the codebase. The migration PR reflects the outcome. The decision record — if it exists — reflects only the upgrade, not the rendering model choices that the upgrade forced.

Module system decisions. A Node.js 14 → 20 migration forces decisions about ESM and CommonJS. Under Node 14, the ESM/CJS boundary was a future problem most teams deferred. Under Node 20, the tooling expectations have shifted enough that many teams encounter it directly during migration. The decision about how to handle the boundary — hybrid modules, full ESM adoption, dual package support, or continuing with CJS and locking ESM out of the API surface — is made inline in the migration PR as the engineer encounters each case. The outcome is visible in the resulting module configuration. The reasoning for each choice is in the engineer's AI chat from the week of the migration.

Type system decisions. A TypeScript major version upgrade introduces stricter inference on patterns that were previously permissive. The team response to each stricter inference failure is a real decision: suppress with a type assertion, refactor to satisfy the stricter type, or add an escape hatch in tsconfig.json. An engineer who systematically adds // @ts-expect-error annotations during migration is making a different decision than one who refactors the offending patterns, and a different decision again from one who relaxes the compiler strictness to accept the old patterns. The tsconfig.json diff reflects the outcome. The reasoning — the specific patterns that were too costly to refactor, the assertions that were added as explicitly temporary vs. permanent — is missing.

The practical implication is that a migration PR should close with two items, not one: the PR itself and an ADR that names the forced adjacent decisions explicitly. The ADR does not need to be long. A three-sentence record per forced adjacent decision is sufficient: what the decision was, what the alternative was, and what made the chosen approach correct for the team's situation. The ADR should link to the migration PR as its primary evidence for the Consequences section.

The upgrade-vs-replace threshold

The most consequential moment in a dependency upgrade evaluation is the point where the comparison shifts from "upgrade now vs. upgrade later" to "upgrade vs. replace." This threshold is rarely obvious at the start of evaluation. Teams begin with the assumption that upgrading is the default path and discover during the investigation that the API surface has changed enough that the migration cost is approaching the replacement cost for the same capability.

The threshold becomes relevant when three conditions align: the migration guide is long enough that the cost estimate is significant, the new version's API shape has diverged substantially from the team's current usage pattern, and at least one alternative library already has the API shape the team would be migrating toward anyway. When all three are true, the question has changed. It is no longer "how do we upgrade?" but "why are we upgrading to this one specifically?"

The honest answer to the second question sometimes reveals that the team is on the upgrade path by default — because the current dependency is already installed, because the migration guide exists and is clear, because the engineering effort to evaluate alternatives feels speculative compared to the concrete migration path. These are legitimate reasons to continue on the upgrade path, but they should be named. A decision record that says "we evaluated replacing lodash with native array methods during this migration; the replacement would have eliminated the dependency entirely at the cost of N hours of refactoring; we chose to upgrade because the migration guide was clear and the replacement work was estimated at twice the migration work" is more useful than one that says "upgraded lodash to version 5."

The upgrade-vs-replace question is also the point where a new CTO inheriting the codebase most needs documentation. When they ask "why are we using this specific library rather than X?" and the answer is "we've been on it since 2019 and have stayed current" — that answer conceals the point where the comparison was made and closed. Whether it was made at all, whether the alternative was evaluated, and what constraint drove the upgrade-rather-than-replace decision are questions that have no answer in the commit history. They have an answer in the AI chat from the week the migration was evaluated.

Writing the upgrade ADR

The Nygard ADR format works as the base. The title should follow the decision-statement convention: "Upgraded React to 18.3.1 over deferring to Q3" or "Replaced lodash with native array methods over upgrading to lodash v5" — titles that make the comparison visible at the list level without opening the file. An upgrade ADR titled "React 18 migration" is a topic, not a decision statement. A new engineer scanning the decisions directory cannot tell from the title whether the decision was to upgrade, to defer, or to replace.

The upgrade ADR has a specific structure that extends the standard Nygard sections:

Context. The current version, the target version or alternative, and the trigger that made the timing decision necessary now. One of the five trigger categories named explicitly: security CVE with its CVSS score and ID, EOL notice with the date, blocking dependency with the chain named, gap cost calculation with the estimate, or team knowledge window with the specific reason. The context section should answer the question "what changed that made this a decision this quarter rather than last quarter or next quarter?"

Alternatives Considered. The four alternatives named above: pin-and-patch, fork-and-patch, replace, wrap-and-decouple. For each, a one-sentence rejection reason. The rejection reason should name the constraint that ruled it out — not "pin-and-patch was not chosen" but "pin-and-patch was rejected because the CVE has an active exploit in the wild and the security team's policy requires patching within 30 days of a CVSS ≥ 8.0 disclosure."

Decision. The chosen path with the constraint that drove the selection. The constraint should be specific enough to be verifiable — not "upgrading was the right call" but "upgrading now is the correct timing because the Q3 feature freeze means the next window for migration is six months out, during which two more minor releases will drop additional deprecations that increase the gap cost."

Consequences. What was accepted by making this choice. For an upgrade decision, the honest consequences include the migration cost (engineer-days estimated vs. actual), the adjacent decisions that were forced by the migration, and what was given up by not choosing each alternative. A Consequences section that lists only what the upgrade enables — "we can now use concurrent rendering features" — without naming the trade-offs is not honest. The team accepted something. That acceptance is the most useful content for the next engineer who reads the record.

Revisitation condition. For deferral decisions, the explicit trigger for re-evaluation. For upgrade decisions, the condition under which the upgrade decision itself would be reconsidered — typically when a subsequent major version introduces breaking changes that require another migration pass, or when the adjacent decisions forced by the migration prove incorrect and require reversal.

The record does not need to be long. A well-written upgrade ADR with all five sections is 400–600 words. The discipline is in the specificity of each section, not the length. An ADR that says "we chose to upgrade over deferral because the security team flagged CVE-2025-12345 (CVSS 9.1) and our SLA requires patching critical vulnerabilities within 14 days of disclosure" is more useful at 50 words than one that says "we upgraded to stay current and maintain security posture" at the same length.

The AI chat extraction advantage for upgrade decisions

Dependency upgrade deliberations in AI chat are some of the richest extraction targets in a typical export, for a specific reason: engineers frame migration evaluations as explicit comparison questions from the first message. "I'm trying to decide whether to upgrade React to 18 this sprint or wait until Q3 — here's our current setup" is a decision frame that names the comparison, the timing dimension, and the context all in the opening message. The AI chat session contains the alternatives considered (often including the fork and replace options that don't appear in the PR description), the constraint that drove the timing (often stated more candidly than in any formal documentation), and the adjacent decisions forced by the migration (named as the engineer encounters them during investigation).

The WhyChose extractor finds these sessions reliably because migration deliberations produce high-density signals: question shapes with named alternatives ("should we upgrade X or Y?"), trade-off markers ("the upgrade gives us A but costs us B"), and reversal markers ("actually, looking at the migration guide, the real issue is..."). The deliberation from the two weeks before a migration PR is almost always more complete than the PR description that documents the outcome.

The practical workflow is specific: use git log --diff-filter=M -- package.json to identify the date of each significant dependency change, then export AI chat history from the 14-day window before each change. The extractor's output for migration deliberation sessions typically includes the timing trigger, the alternatives evaluated, and the adjacent decisions that came up during research — all of which belong in the upgrade ADR but are absent from the commit. Running the extraction pass before writing the ADR converts what would otherwise be a reconstruction from memory into a transcription from the original reasoning session.

The migration deliberation session is also the highest-value recovery target for post-mortems that surface a deferred upgrade. When a production incident is traced to a dependency version that should have been upgraded six months earlier, the original deferral reasoning is almost never documented. The post-mortem team knows the current cost — the production impact — but cannot evaluate the original deferral decision without knowing the reasoning. Was the deferral deliberate with a named trigger condition that was never re-evaluated? Was it oversight? Was there a gap cost calculation that proved incorrect? The AI chat from the period of the original deferral evaluation is the most reliable path to this information.

The upgrade ADR in the decision log lifecycle

Upgrade decision records have a shorter useful life than foundational architectural records, and the ADR lifecycle applies differently to them. A React 18 upgrade ADR is superseded when the team upgrades to React 19 — the supersession creates a chain of custody showing the progression of timing decisions across major versions. The chain answers a question that is otherwise unanswerable: not just "which version are we on?" but "what was the reasoning at each step?" and "were the deferral windows shorter or longer over time, and why?"

The forced adjacent decisions from a migration deserve their own status tracking. If a migration forced a decision about rendering patterns that was explicitly marked as provisional — "we're using this approach temporarily until Suspense support is stable" — the review trigger should be explicit in the record. A provisional decision without a review trigger becomes a permanent decision by default, and the team will encounter the constraint it created without any record of its originally provisional status.

The dependency decision archive also provides a signal that is not available from any other source: the pattern of timing decisions across upgrades. A team that consistently upgrades within 30 days of EOL notices is making a different risk policy than a team that consistently upgrades six months after EOL. If those policies are explicit and consistent, they are defensible. If they are implicit and inconsistent — some upgrades prompt, some deferred indefinitely — the inconsistency is itself a signal that no deliberate upgrade policy exists. The decision archive makes the pattern visible. The engineering manager who reviews it in a quarterly retrospective can identify whether the team's upgrade cadence is intentional or accidental, and the difference between those two states is the difference between a risk posture and an unexamined default.

What a complete upgrade decision record looks like

A complete upgrade ADR for a React 17 → 18 migration would contain: a title that names the comparison ("Upgraded React to 18.3.1 over deferring to Q4 2026"), a Context section naming the gap-cost trigger and the specific EOL timeline for React 17 support, an Alternatives section naming pin-and-patch with the CVE date that ruled it out, replace with the evaluation of Preact as the alternative and the cost estimate comparison, wrap-and-decouple with the evaluation of adopting a strict boundaries approach and why it was rejected given the codebase's integration depth. A Decision section that names the constraint ("the gap cost calculation showed that waiting for Q4 adds two more minor releases with React 18 deprecations, making the migration more complex rather than simpler"). A Consequences section that names the migration cost actually incurred, the concurrent rendering patterns now expected across the codebase, the one rendering anti-pattern found in two components and how it was resolved, and what was given up by not adopting the wrap-and-decouple approach. A Revisitation section naming the trigger for the next upgrade evaluation.

This record is 500 words. It takes 20 minutes to write from the AI chat session that preceded the migration. It answers every question a new engineer joining the team will have about the dependency version, the migration history, and the adjacent decisions made during the upgrade. It also answers the question that surfaces in sprint planning when someone proposes revisiting the decision: "what was the original rationale, and has anything changed?" — a question that currently takes archaeology and produces uncertain answers.

The case for architecture decision records is that the decisions worth documenting are the ones where a new team member would behave differently knowing the reasoning. Every breaking dependency upgrade qualifies. The timing decision, the alternatives considered, and the adjacent decisions forced by the migration are all content that changes how a later engineer reads the codebase, evaluates subsequent upgrade decisions, and understands the gap between what the dependency version is and what it was when the decision was made. The commit message is not a substitute for this content. The migration PR description is not a substitute. The decision record is the record — and for dependency upgrades, almost nobody writes one.

Further reading