Dotnet Breaking Changes
.NET (Microsoft)OfficialPublished by .NET (Microsoft)
- Skill
- Guidelines
- Software development
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a rigorous process for evaluating code changes in .NET projects where the consumer base is external or hidden. It emphasizes that a successful build and passing tests are insufficient to guarantee compatibility. The workflow guides users through inspecting hidden surfaces—such as Public API gates, multi-targeting, source generators, and friend assemblies—to ensure that modifications do not silently break downstream consumers or different target frameworks.
What it does
- Identifies hidden API surfaces like PublicAPI.Shipped.txt and ApiCompat gates.
- Analyzes multi-targeted projects and conditional #if branches for cross-target compatibility.
- Detects dependencies on internal members caused by InternalsVisibleTo attributes.
- Provides safe patterns for evolving public APIs without breaking existing binary or source consumers.
- Guides the use of forwarders and obsolete shims to maintain backward compatibility.
- Verifies changes across all target frameworks and package validation gates.
When to use it
- Before modifying public methods, overloads, or interface members in a shipped library.
- When renaming or moving code that is consumed by friend assemblies or external projects.
- Before changing nullability, constants, or default parameter values in public APIs.
- When adding new target frameworks or changing conditional compilation logic.
- When investigating potential breaking changes reported by RS0016/RS0017 or ApiCompat.
Advantages
- Prevents silent binary and source-level breaks that standard CI builds often miss.
- Ensures compatibility across diverse target frameworks and runtime environments.
- Maintains the integrity of public contracts while allowing for library evolution.
- Provides a clear, audit-ready process for documenting compatibility decisions.
- Reduces the risk of runtime exceptions like MissingMethodException for downstream users.
Usage examples
- “I need to add an optional parameter to a public method in my NuGet package. How do I do this without breaking existing users?”
- The agent will advise against changing the existing signature and instead recommend adding a new overload, ensuring that existing binary consumers continue to bind to the original method.
- “Is renaming this internal method a breaking change?”
- The agent will check for InternalsVisibleTo attributes and guide you to keep the old name as an [Obsolete] forwarder if the method is consumed by friend assemblies.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/dotnet/dotnet-breaking-changes/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip dotnet-breaking-changes.zip -d .agents/skills
Documentation by .NET (Microsoft)
.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 csharp-refactoring 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.
# 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 InternalsVisibleTo
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 (
PublicApiAnalyzers+PublicAPI.*.txt, which you maintain) and binary/package-level (ApiCompat/<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[TypeForwardedTo]forwarder; a rename needs an[Obsolete]shim, not a forwarder. Seereferences/public-api.md.Multi-targeting and
#if. Code that multi-targets (<TargetFrameworks>) or compiles conditionally (#if NET8_0_OR_GREATER, 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. Seereferences/multi-targeting.md.Source-generated and partial code. A type is often
partialacross several files, and part may be generated (source generators, Razor,*.g.cs). Include every partial declaration; treat generated files as derived and change the generator input/template unless the repo checks the output in as source. Seereferences/source-generation.md.InternalsVisibleTo.
internalis 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[Obsolete]forwarder. Seereferences/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 MissingMethodException"). 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 Parse(string?, int) unchanged because already-compiled consumers still request that
exact method and would throw MissingMethodException if it disappeared." For source-only breaks, name
the affected syntax (raw: 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 (Foo(a) → Foo(a, b = null)) | Binary break: the old signature is gone, so already-compiled consumers throw MissingMethodException until they recompile | Keep Foo(a) unchanged and add a new overload Foo(a, b) with b 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 PublicAPI.Shipped.txt | 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 PublicAPI.Unshipped.txt |
| 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 netstandard2.0 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 [Obsolete] forwarder, and record only the new member |
Rename or remove an internal member when InternalsVisibleTo 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 [Obsolete] forwarder, keep the grant, and name the protected friend in the answer |
| Rename a parameter of a shipped member | Callers passing it by name (raw: x) stop compiling and late-bound/dynamic 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 <param> 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 const 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 static readonly) or a new overload for new behavior. Replacing an existing const with static readonly is itself source-breaking |
Change a value under #if 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
<exact old signature>unchanged because<who still binds to it>would<concrete failure>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 No files changed. 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
sealedor a privateconst), 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.bool.TryParseignores 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 (dotnet test -f <tfm> for each), not only the default. If
the project sets EnablePackageValidation, run dotnet pack — package validation runs only after
Pack, 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 PublicAPI.Shipped.txt 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,
[Obsolete]shims and type-forwarders, suppression baselines.references/multi-targeting.md — TFMs,
#ifand 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.
Files
- ATTRIBUTION.mdAdded by AutoGineer852 B
- LICENSELicence file added by AutoGineer1.1 KiB
- SKILL.md14.7 KiB
- references/internals-visible-to.md2.3 KiB
- references/multi-targeting.md2.8 KiB
- references/public-api.md5.5 KiB
- references/source-generation.md2.0 KiB
references/public-api.md
Public API surface — the two gates and how not to break it
Contents
Two gates, not interchangeable
What counts as a breaking change (including annotations)
Deciding and validating a public change
Preserving identity:
[Obsolete]shims and type-forwardersSuppression baselines
Two gates, not interchangeable
.NET repos protect the public surface in two fundamentally different ways. Do not treat one as a substitute for the other — a repo may use either, both, or neither, and each catches things the other does not.
| Gate | What it compares | Where it lives | You maintain |
|---|---|---|---|
| PublicApiAnalyzers (RS0016/RS0017/…) | Declared source API of the current compilation | PublicAPI.Shipped.txt + PublicAPI.Unshipped.txt per project | Yes — you edit the .txt files; the analyzer only enforces they match the code |
| ApiCompat / package validation | Built assemblies / NuGet package against a baseline (previous version, or a contract/ref assembly) | <EnablePackageValidation>, Microsoft.DotNet.ApiCompat.* in props/targets | Baseline version + suppression file |
Practical rule: run/respect whichever gate the repo already has and update its files or baseline as the change legitimately requires. If neither exists, review the public surface by hand and flag the risk to the user — do not add analyzer or package-validation infrastructure as a side effect of an unrelated change (that is scope creep and its own kind of breaking change to the build).
What counts as a breaking change (including annotations)
Beyond the obvious rename/remove/move of a public type or member, these are also observable and can break consumers or the API gate:
Signature changes: parameter type/order, return type, adding a required parameter,
params, default values, generic arity or constraints.Parameter names, including case: callers that pass arguments by name (
Parse(raw: s)) and late-bound callers (dynamic, VB late binding) break. Positional callers hide this.Adding an optional parameter to an existing public method is binary-breaking, even when ordinary source calls still compile. Already-compiled call sites bind to the exact old signature and fail with
MissingMethodException. It can also be source-breaking: method-group conversions to the old delegate type no longer compile. Keep the original method and add an overload instead. Make the new parameter required on the new overload: callers that omit it continue selecting the original method, while callers that need the capability select the new signature explicitly.Adding a member to a public interface breaks every implementer. A default interface member avoids this only where the runtime supports it (.NET Core 3.0+ and .NET 5+, not
netstandard2.0or .NET Framework).constvalues and optional-parameter defaults are copied into the caller at compile time. Changing them does not reach existing callers until they recompile, so callers silently keep the old value.Nullability annotations (
string→string?,[NotNullWhen],[MaybeNull]) — these are part of the public contract under#nullable enable; changing them shifts consumer warnings and the API gate.Trimming/AOT attributes (
[DynamicallyAccessedMembers],[RequiresUnreferencedCode],[RequiresDynamicCode],[UnconditionalSuppressMessage]) — observable API for trim/AOT consumers.Accessibility widening/narrowing,
sealed/abstract/virtual/staticchanges,readonly/ref.Moving a public type to another assembly (identity changes even if the name does not).
Preserve these unless the task is explicitly to change them.
Deciding and validating a public change
Determine the project's role: shipped library/package (public API matters) vs app/service (external contract = HTTP/config/schema, internal surface can move) vs private/single-target (behavior + tests only).
If a gate exists, build/pack and let it run; update
PublicAPI.Unshipped.txtor the ApiCompat baseline intentionally, never to silence a break you did not mean to make.PublicAPI.Shipped.txtmoves only at release time. An RS0017 that points at a shipped line means a released member has disappeared; restore the member and do not edit the line.If no gate exists in a library, diff the public surface manually (compare declarations, or a generated ref/
.txt) and surface the delta to the user.In the final response, do not stop at "binary compatible." Name the preserved signature and the failure it prevents for already-compiled consumers; name source-only hazards such as named arguments or nullable callers just as concretely.
Preserving identity: [Obsolete] shims and type-forwarders
[Obsolete]shim: keep the old member alongside the new one, forwarding to it, so source consumers keep compiling. Use for renames/relocations within an assembly.[TypeForwardedTo]type-forwarder: preserves binary identity when a public type moves to another assembly. Forwarders solve cross-assembly moves; they do not help a rename (the name changed) and are unnecessary for moves within the same assembly.
Suppression baselines
ApiCompatSuppressions / GlobalSuppressions and PublicApiAnalyzers baselines exist so intentional,
reviewed changes pass. Update them deliberately with the change; do not blanket-add suppressions to make
an edit "pass" — that hides the very break the gate exists to catch.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs a command-line tool
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- .NET (Microsoft)
- Publisher
- .NET (Microsoft) (Official)
- Source
- dotnet/skills · plugins/dotnet/skills/dotnet-breaking-changes · commit 96c3d68
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by .NET (Microsoft) or .NET (Microsoft).
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download dotnet-breaking-changes.zip