.NET breaking changes: the contract is wider than the file
When you edit .NET code, the observable contract is usually
larger than the snippet in front of you.
A change that compiles and keeps tests green can still break a downstream consumer, another target
framework, a friend assembly, or regenerate away on the next build. This applies to a feature or a bug
fix as much as a refactor, and to
additions — a new public member, overload, or target framework — as
much as removals: added surface is a permanent contract obligation, and added behavior on a multi-targeted
or partial type must stay correct on every target and survive the next regeneration. (For the
behavior-preserving refactoring
process, use the
skill; this skill is the
compatibility knowledge it — and any other change — taps into.)
Inspect first — find which surfaces this repo actually has
The most valuable move is to look before you leap: find which hidden surfaces exist here, then check
only those. Don't recite these or assume — the markers present dictate the plan; markers absent tell you a
surface is not in play.
bash
# Public-API gate (which one? they are NOT interchangeable)
git ls-files "**/PublicAPI.Shipped.txt" "**/PublicAPI.Unshipped.txt" # PublicApiAnalyzers
grep -rl "EnablePackageValidation\|ApiCompat" --include=*.props --include=*.targets --include=*.csproj .
# Multi-targeting and conditional code
grep -rl "<TargetFrameworks>" --include=*.csproj --include=*.props . ; grep -rn "#if " --include=*.cs .
# Generated / partial code
git ls-files "*.g.cs" "*.generated.cs" ; grep -rln "partial class\|partial record\|partial struct" --include=*.cs .
# Friend assemblies
grep -rn "InternalsVisibleTo" --include=*.cs --include=*.csproj --include=*.props .
Then read only the reference(s) for the surfaces you actually found. For every
name, check whether that project is in the solution: a friend
not built here is an external
consumer of every internal it can see, exactly like a public caller, and a green solution build proves
nothing about it.
Before editing, confirm the request's premise is true in the code. For "consolidate", "deduplicate", or
"add X" requests, trace the actual calls: if one type already delegates to the other, or the member
already exists, the work is done — change nothing and report the delegating call (see Stop and escalate).
The hidden surfaces (depth in references/)
Public API (1) is the default surface to check on any library edit; the other three are
conditional — pursue them only when the inspect-first markers above show they are in play.
-
Public API. Renaming/moving/removing/re-signing a public member — or changing nullability, a
generic constraint, or a trimming/AOT attribute — is a breaking change a green test run will not catch.
Repos gate this
two non-interchangeable ways: source-level (
+
,
which you maintain) and binary/package-level (
/
<EnablePackageValidation>
). Respect the
gate that exists; if none exists, review the surface by hand and flag it — do
not bolt on analyzer
infrastructure as a side effect. Move a public type via a
forwarder; a
rename
needs an
shim, not a forwarder. See
.
-
Multi-targeting and . Code that multi-targets (
) or compiles conditionally
(
, platform/CoreCLR/Mono/NativeAOT branches) can build for the target your editor
shows and break one it never invoked. Inspect every branch — including inactive ones — preserve
intentional divergence, and re-gate each TFM/RID. See
references/multi-targeting.md
.
-
Source-generated and partial code. A type is often
across several files, and part may be
generated (source generators, Razor,
). Include every partial declaration; treat generated
files as derived and change the generator input/template unless the repo checks the output in as source.
See
references/source-generation.md
.
-
InternalsVisibleTo. is not private across a solution: test and friend assemblies bind to
internals (and, when strong-named, to a public key). An internal rename/move/removal can break a
consumer with no reference in the declaring project. A friend built outside this repo cannot be
updated, so keep the old internal name as an
forwarder. See
references/internals-visible-to.md
.
Decisions a green build will not make for you
Each row below compiles every caller and passes every test, yet breaks a consumer. Do not stop at a
green build or test run. Use the safe shape instead.
When the safe shape departs from the literal request — an overload instead of the requested optional
parameter, a forwarder kept instead of a deleted or renamed member, or no change at all — say so in your
final answer in one or two sentences: what you did instead, and which consumer it would have broken
(e.g. "already-compiled callers would throw
"). A silent substitution reads as
ignoring the request.
Treat that substitution as one atomic deliverable: implement the safe shape, preserve the old contract
and shipped baseline, test both old and new entry points, and explain the compatibility reason. After the
last edit, inspect the diff or reopen the implementation file before claiming success. A green test run
does not prove a failed patch applied when the unchanged fixture already passes those tests.
Do not use labels such as "for binary compatibility" as the whole explanation. Name the exact old
entry point that remains, the consumer that still binds to it, and the concrete failure avoided. For
example: "Kept
unchanged because already-compiled consumers still request that
exact method and would throw
if it disappeared." For source-only breaks, name
the affected syntax (
named arguments, derivation from a newly sealed class, or nullable callers)
instead. This causal sentence is part of the implementation, not optional release-note polish.
| Tempting change | Why it breaks | Safe shape |
|---|
| Add an optional parameter to a shipped method ( → ) | Binary break: the old signature is gone, so already-compiled consumers throw until they recompile | Keep unchanged and add a new overload with required on that overload; omission continues to select the old method. Record only the new overload |
| Fix RS0017 by editing or deleting a line in | Shipped lines are the released contract; rewriting one hides the break the gate exists to report | Leave shipped lines alone. Restore the shipped member and record new surface in |
| Add a member to a shipped public interface | Every existing implementer, including ones in other packages, stops compiling | Add a default interface implementation (requires runtime support: not on or .NET Framework targets), or add a new interface. A default member is callable only through the interface, so also add a public member on any concrete type that must expose it, and record both |
| Rename a shipped member | Source and binary break | Add the new name, keep the old one as an forwarder, and record only the new member |
| Rename or remove an member when names an assembly not in the solution | That friend was compiled against the old name and cannot be updated from this repo; it fails at bind time | Add the new name, migrate in-repo callers, keep the old internal name as an forwarder, keep the grant, and name the protected friend in the answer |
| Rename a parameter of a shipped member | Callers passing it by name () stop compiling and late-bound/ callers fail; positional callers in this repo hide it. PublicAPI analyzers report it as RS0016 + RS0017 | Keep the shipped name — no overload can carry a different name for the same signature. Leave it unchanged and edit nothing else; you may offer (not make) a clearer XML doc, or the rename at the next major version |
| Seal a class, make a member non-virtual, or narrow a parameter's nullability | Derived types, overrides, or nullable callers break | Treat as a breaking change; escalate rather than edit |
| Change a value or an optional parameter's default | The old value is compiled into callers and persists until they recompile | Preserve the shipped constant/default; introduce a separately named member (which may be ) or a new overload for new behavior. Replacing an existing with is itself source-breaking |
| Change a value under for one target | Only consumers of that target see it; tests edited to match will not catch it | Treat as a behavior change for that target and call it out |
Whenever you kept an old entry point or departed from the literal request, the final answer must
contain this line, filled in — terse bullets such as "preserved for binary consumers" do not satisfy it:
Compatibility: Kept
unchanged because
would
if it changed.
<What you did instead of the literal request, and why.>
Before finishing a compatibility-preserving edit, make the delta easy to audit:
- Name the new entry point or behavior and the exact old entry point that remains.
- Name the in-repo caller, generated template, or friend assembly you migrated, plus any external
consumer you could not migrate and therefore protected with a forwarder.
- State which baseline stayed unchanged and which new surface was recorded.
- Report the exact compatibility check that exercised the old entry point as well as the new one.
For a no-change outcome, lead with
and identify the decisive evidence before any
general explanation: the existing delegation, shipped signature, parameter name, or nullability line.
When asked "is this a breaking change?" or to review a diff, make no edits unless asked and answer
in this shape, scaled to the diff:
- One verdict line first (e.g. "Do not merge as-is: N changes break consumers").
- One numbered item per consumer-visible change: what changed, binary (already-compiled
consumers fail), source (consumers fail to recompile), or behavioral (same signature,
different result, possibly on only one target), who breaks, why CI stayed green (a shipped
API line rewritten, a test edited to match, or a change the analyzer does not track, such as
or a private ), and the safe shape. State each as fact or not at all: a
behavioral claim needs a concrete input whose old and new results differ; if unsure, probe both
versions with a one-line program — framework parsers already tolerate much (e.g.
ignores surrounding whitespace), and a no-op rewrite is not a break.
- One closing "Not breaking:" line covering every remaining hunk with its reason (internal-only
edits, probed no-ops), instead of side notes inside the numbered items.
Cover only surfaces the diff touches; do not add trimming, AOT, or tooling commentary it does not raise.
Verify before reporting
Build and test
every target framework (
for each), not only the default. If
the project sets
, run
— package validation runs only after
, never on build. Report exactly which commands ran and their result.
Stop and escalate (do not silently proceed)
- The requested change is already in place — e.g. the "duplicated" code already delegates to one
implementation, or the overload already exists. Say so and change nothing; do not invent a
refactor to have something to show.
- A public/shipped API would change and you cannot verify compatibility — no shim/forwarder path and
no PublicApiAnalyzers/ApiCompat/package-validation gate to catch a break.
- The edit changes an observable annotation (nullability,
[DynamicallyAccessedMembers]
,
[RequiresUnreferencedCode]
, generic constraints) on a public member.
- The change can be made consistent across some but not all target frameworks or platforms.
- The only way to apply it is editing generated output that the next build will overwrite.
When you change nothing (the request is already done or has no safe shape), a bare "no" looks like
ignoring the request. Make the final answer show the work, in this order: "No files changed." and why;
the evidence that decided it (the shipped
line, or the call that already
delegates, with its file); who would break, or where the single implementation already lives; what the
user
can do instead, if anything; and the validation you ran.
Reference files
Load a reference only for a surface the inspect-first step actually found:
- references/public-api.md — the two API gates and why they are not
interchangeable, nullable/trimming/AOT as observable API, shims and type-forwarders,
suppression baselines.
- references/multi-targeting.md — TFMs, and platform symbols,
RIDs, and how to inspect and re-gate every target.
- references/source-generation.md — partial types, generated output
vs checked-in source, editing the generator input. (See also
dotnet-msbuild/including-generated-files
.)
- references/internals-visible-to.md — friend/test assemblies
and strong-name keys.