BrandGhost
Public Contract Compatibility When Refactoring C# Libraries
All articles

Public Contract Compatibility When Refactoring C# Libraries

Understand public contract compatibility in C# libraries: preserve source calls, existing binaries, observable behavior, and consumer-sensitive API details.

Public contract compatibility matters when a C# library is consumed by code you cannot edit alongside it. Your solution can compile after a rename while an existing application still expects the old member. Your unit tests can pass while a named argument no longer compiles. Your output can match while callers receive a different exception.

Those are different compatibility questions. Treating them as one “no breaking changes” checkbox makes the evidence hard to interpret. A useful library refactoring identifies the consumer boundary, preserves the public entry points where possible, and validates source, binary, and behavioral expectations separately.

The documentation baseline checked October 12, 2026 is .NET runtime 10.0.12 and SDK 10.0.401, and C# 14. The console examples use standard-library APIs and ordinary syntax. They are documentation-reviewed illustrations, not executed compatibility tests or evidence that a particular package release is safe.

Public Contract Compatibility Has Several Dimensions

Microsoft defines source and binary compatibility separately. Source compatibility asks whether existing consumer source can be recompiled against the new API without edits. Binary compatibility asks whether an already-built consumer can use the newer API without recompilation.

Behavioral compatibility asks what the caller experiences when the operation runs. A method can retain its name and parameters while changing output, exceptions, ordering, or mutation. A successful binary load does not establish that its behavior is unchanged.

These categories overlap, but none replaces the others. A parameter rename can break named source calls without changing the ordinary positional call signature. Removing a member can break both source and binaries. Returning a different value can preserve the shape of the API while violating behavior.

The existing definition of refactoring explains the preservation goal. Public contract compatibility extends the observation boundary to consumers that do not participate in your local editing session.

Identify Which Consumers You Actually Know

List the supported target frameworks, separately built applications, plugin implementations, and language consumers that belong to the contract. A C# source caller is one kind of consumer; a reflection-based loader is another. Do not claim they have all been checked because your own project references were updated.

Also identify contract evidence: public documentation, released assemblies, consumer examples, tests, configuration, and representative data. Internal implementation details should not become supported API accidentally, but real dependencies on them still deserve investigation before a change surprises users.

Where the consumer inventory is incomplete, say so. Public contract compatibility is not established by assuming missing consumers do not exist. A bounded statement about known consumers is more useful than an unrestricted promise that cannot be supported.

Preserve the Public Entry Point While Moving Internals

Suppose a library exposes a name formatter. The structural goal is to move formatting into an internal implementation type, not to rename the public method. The following standalone console example keeps the consumer-visible method and its parameter name.

using System;

namespace ContractExample;

internal static class Program
{
    private static void Main()
    {
        var formatter = new NameFormatter();
        if (formatter.Format(name: " Ada ") != "name: Ada ")
        {
            throw new InvalidOperationException("Output changed.");
        }

        try
        {
            formatter.Format(name: null);
        }
        catch (ArgumentNullException exception)
        {
            if (exception.ParamName == "name")
            {
                return;
            }

            throw new InvalidOperationException("Parameter name changed.");
        }

        throw new InvalidOperationException("Expected null rejection.");
    }
}

public sealed class NameFormatter
{
    public string Format(string? name)
    {
        if (name is null)
        {
            throw new ArgumentNullException(nameof(name));
        }

        return FormattingCore.Format(name);
    }
}

internal static class FormattingCore
{
    internal static string Format(string name)
    {
        return "name:" + name;
    }
}

The public guard remains at the entry point and the internal helper receives a non-null string. The label and spaces remain exact. The named call documents the parameter name that the consumer expects. This is an after-shape illustrating a narrow structural move, not a claim that every wrapper is universally compatible.

To establish preservation in a real library, compare this shape against the released original entry point and run the relevant consumer checks. Retaining a method name is necessary for this scope but not sufficient for assembly identity, loading, reflection, initialization, or behavioral compatibility.

The existing article on essential refactoring techniques already discusses inaccessible consumers. The additional practical step is preserving their entry point while making internal movement explicit.

A Wrapper Preserves Only What It Retains

An unchanged wrapper can preserve a public signature and delegate behavior, but it cannot automatically preserve everything behind the method. Moving validation into the helper might alter diagnostic stacks. Adding a lazy initializer could change when a failure occurs. Returning a different object could change identity even when its properties match.

Reflection that inspects private structure is a separate question. Do not promise that internal movement preserves private member names, and do not dismiss a known consumer that uses those names. Determine whether the dependency is supported, accidental, or in need of migration.

Avoid overengineering a forwarding layer when a private helper inside the existing type satisfies the goal. Keeping the public type intact often reduces the compatibility surface. The tradeoff is that some internal responsibilities remain together, which may be acceptable for a bounded refactoring.

Parameter Names Can Be a Source Contract

A parameter rename looks innocuous when every local call is positional. A consumer using named arguments sees a different situation. Microsoft's library-change rules specifically identify parameter renaming as a source compatibility concern.

using System;

namespace NamedCallExample;

internal static class Program
{
    private static void Main()
    {
        var before = new OriginalCalculator();
        var after = new RefactoredCalculator();

        if (before.Add(amount: 3, increment: 2) != 5 ||
            after.Add(amount: 3, increment: 2) != 5)
        {
            throw new InvalidOperationException("Named consumer changed.");
        }
    }
}

public sealed class OriginalCalculator
{
    public int Add(int amount, int increment)
    {
        return checked(amount + increment);
    }
}

public sealed class RefactoredCalculator
{
    public int Add(int amount, int increment)
    {
        return AddCore(amount, increment);
    }

    private static int AddCore(int left, int right)
    {
        return checked(left + right);
    }
}

The internal parameter names can change to left and right without changing the public names amount and increment. Checked addition remains inside the actual arithmetic operation. The example demonstrates a source-call shape and a selected behavioral assertion, not binary compatibility between its two differently named example types.

If the public parameter amount were renamed to value, the named consumer source would need an edit. Do not include that intentionally uncompilable call in an example advertised as runnable. Instead, retain the complete valid example and explain the breaking proposal separately.

A positional caller may not reveal the problem. That is why consumer fixtures should include supported calling styles rather than only the shortest invocation. Public contract compatibility needs representative calls at the boundary, not just direct tests of the new internal helper.

Defaults, Return Types, and Added Members Need Separate Checks

Microsoft's compatibility rules for defaults and signatures distinguish changing an optional parameter's default from changing the parameter list. Changing the default alone is not a binary break, but recompiled callers that omit the argument can behave differently. Removing the default can break source calls that omit it. Adding an optional parameter to replace an existing signature still changes that signature; source convenience does not preserve an old binary's member reference.

Changing a declared return type is also a contract change. Returning a more-derived object through an unchanged declared return type is a different operation and still deserves behavioral review. Adding an overload can change which member a recompiled or late-bound caller selects, or introduce ambiguity. An additive API diff therefore needs source-call checks, not just a search for removed members.

The same library-change rules distinguish adding params to an existing parameter from changing its collection type. Adding the modifier is allowed; removing it can break expanded source calls. Replacing params T[] with params ReadOnlySpan<T> changes the IL signature and breaks already-compiled callers even when a recompiled call looks unchanged. Non-array params collections arrived in C# 13, not C# 14.

Adding an interface member needs separate downstream-implementer review. Default interface implementations date to C# 8 and .NET Core 3.0, not C# 14. They can avoid some implementation failures, but language support and default-resolution conflicts still matter. C# 13 permits ref struct interface implementations; these types must implement instance members explicitly and cannot rely on a new default instance implementation. This is why Microsoft's rules classify interface-member additions as requiring judgment rather than universally safe.

Binary Compatibility Needs an Already-Built Consumer

Recompiling every project against the new library answers a source question. It does not demonstrate that an old consumer binary can still bind to the released API. For the binary question, preserve a consumer artifact built against the previous supported library and validate it against the candidate replacement in an appropriate environment.

Record exactly what artifact and library versions were used. A test that builds the consumer and library together can inadvertently update references and conceal a missing public member. The separation between old consumer and new library is the important property of this validation.

Assembly loading can involve framework targets, dependencies, identity, packaging, and deployment configuration. This article does not prescribe one universal command for all those environments. The validation plan must match the library's supported consumers rather than copying a console-only procedure into every product.

Microsoft's library rules allow moving a type between assemblies with type forwarding, subject to the documented forwarding requirement. That is a specific compatibility mechanism, not permission to move types freely. An internal helper extraction is usually a narrower transformation than an assembly split.

API Shape Checks Are Useful but Incomplete

A public API comparison can reveal removed types, changed member signatures, or altered accessibility. It is useful evidence about surface shape. It cannot establish every behavioral consequence of a method body, nor can it inventory every string-based consumer in a deployment.

Microsoft's visibility rules disallow reducing a public type's or member's visibility. Expanding type visibility or nonvirtual member visibility is allowed under those rules, but it does not prove unchanged behavior. Preserve the accessible boundary rather than treating all access-modifier edits as equivalent.

Keep API shape evidence alongside consumer execution evidence and behavioral assertions. Each answers a different question. A mismatch in one dimension should not be hidden by a pass in another.

If execution is prohibited or unavailable, report the binary check as unrun. Documentation can identify a likely breaking change, but documentation review cannot manufacture a successful consumer load. Public contract compatibility should remain qualified until the required artifact has actually been checked.

Behavioral Compatibility Includes Errors and Mutation

Consider the name formatter again. Changing null rejection to an empty string preserves the signature but changes the behavior. Trimming names preserves the return type but changes the result. Updating an input object instead of returning a value changes mutation and can affect callers beyond the current operation.

The next complete example demonstrates an observable mutation boundary without introducing a mutable domain class. The public operation accepts an array and returns a sum while leaving the array contents untouched.

using System;

namespace MutationExample;

internal static class Program
{
    private static void Main()
    {
        var input = new[] { 3, 1, 2 };
        var service = new Totals();
        if (service.Sum(input) != 6 ||
            string.Join(",", input) != "3,1,2")
        {
            throw new InvalidOperationException("Consumer observation changed.");
        }

        try
        {
            service.Sum(new[] { int.MaxValue, 1 });
        }
        catch (OverflowException)
        {
            return;
        }

        throw new InvalidOperationException("Overflow policy changed.");
    }
}

public sealed class Totals
{
    public int Sum(int[] values)
    {
        if (values is null)
        {
            throw new ArgumentNullException(nameof(values));
        }

        var total = 0;
        foreach (var value in values)
        {
            total = Add(total, value);
        }

        return total;
    }

    private static int Add(int left, int right)
    {
        return checked(left + right);
    }
}

Sorting the array before summing would violate the selected mutation contract, even though the ordinary sum remains six. It can also change intermediate checked-overflow behavior for some inputs, so a claim that reordering is universally harmless would be unjustified.

The example includes an overflow assertion and retains the explicit null guard. It does not test every array, concurrent mutation, or resource limit. The preservation boundary is ordered checked addition with no writes to the input array, under ordinary single-threaded use.

Use the existing Extract Method material for the structural technique. Public contract compatibility adds the consumer-visible mutation and error expectations that an internal helper test alone can miss.

Find String-Based and Data-Based Consumers

Symbol-aware refactoring tools can update known references without finding every configuration string. A plugin loader may use a type name. A serializer may have naming conventions or explicit mappings. A script may look up a member through reflection. These are investigation targets, not proof that every library has such dependencies.

Search the actual supported configuration and consumer data before renaming. Distinguish type names in logging from names used to instantiate a type. A harmless textual occurrence and a runtime lookup require different decisions.

Serialization compatibility depends on the serializer, options, data schema, and supported versions. Do not claim that keeping a C# property name guarantees serialized compatibility or that changing a private field always breaks it. Verify the concrete framework and configuration when the library actually exposes serialized data.

The existing legacy refactoring guide is useful for discovering dependencies. The compatibility job here is to determine which discovered dependencies belong to separately consuming clients and how they will be validated.

Preserve Behavior Without Promising Unsupported Internals

Documenting a dependency does not automatically make it a permanent supported contract. You may decide that private reflection usage is unsupported and offer migration guidance. That is an API-policy decision, not a reason to call a known break invisible.

If a supported consumer relies on a public name, retain it or make the breaking evolution explicit. Obsolete forwarding members can be one transition mechanism, but their suitability depends on overloads, interfaces, loading, and behavior. This article does not promise that a wrapper resolves every compatibility problem.

Public contract compatibility is easiest to explain when the intended contract and known accidental dependencies are recorded separately. Future work can then improve the latter without silently weakening the former.

Modern Syntax Does Not Remove Compatibility Work

C# 14 introduces extension members, field-backed properties, null-conditional assignment, and user-defined compound assignment. These features can support internal changes, but newer syntax is not evidence that existing consumers remain compatible.

Extension API modernization requires checking the actual public member surface and calling styles. Replacing a backing field with field requires reviewing initialization and any known reflection dependencies. A null-conditional assignment can skip work for a null receiver, which is important if the old code rejected null instead.

The C# 14 feature documentation describes availability and semantics, not universal compatibility of rewrites. There is a useful narrower guarantee: Microsoft's library-change rules describe converting an existing extension method from this-parameter syntax to equivalent extension-block syntax as source and binary compatible because both generate identical IL. That does not cover changing its signature or behavior, or a project's ability to build the new syntax with an older compiler. The examples here exclude these transformations so the consumer boundary stays clear. Older ordinary syntax is appropriate when it isolates the contract being taught.

Keep the supported consumer targets separate from the current research baseline. A library supporting older targets cannot infer that every C# 14 feature is appropriate merely because the author uses a new SDK. Microsoft also warns against an unrestricted LangVersion of latest because installed compilers can differ between environments.

Check static invocation as well as extension-call syntax. Microsoft's extension-member guide shows that MyExtensions.WordCount(s) applies to both equivalent extension-block and this-parameter declarations. Keeping only the apparent receiver syntax in a consumer fixture would omit that supported calling style; changing the containing class or namespace is not covered by the syntax-conversion guarantee.

Frequently Asked Questions About Public Contract Compatibility

Can I rename a public method if my solution still builds?

Not while claiming compatibility for callers that still use the old name. Your solution is only the editable consumer set. Existing source or binaries outside it may depend on the released member. Retain the entry point or treat the rename as intentional API evolution with a consumer migration plan.

Is a public parameter name really part of the contract?

It can be. Named arguments depend on that name, and Microsoft's compatibility rules explicitly flag parameter renames. Positional calls alone do not reveal the break. Consumer fixtures should retain relevant named-call patterns rather than assuming the parameter list's types are the only source-visible details.

Does a wrapper guarantee public contract compatibility?

No. A wrapper can retain a member shape and selected behavior, but loading, initialization, reflection, diagnostics, and other observations may still differ. Describe what the wrapper preserves and validate the remaining dimensions instead of treating forwarding as a universal certificate.

What proves binary compatibility?

Evidence from an appropriate already-built consumer using the candidate library supports that dimension for the checked configuration. Rebuilding the consumer answers a different source question. Neither check alone proves all behavior or every supported deployment, so retain exact artifact identities and disclose the tested scope.

Do internal changes ever affect consumers?

Yes, through observable behavior or discovered dependencies such as reflection and configuration. Not every internal detail is a supported contract, but known dependencies require an explicit decision. Public contract compatibility concerns what supported consumers rely on, not only what members carry the public keyword.

How should compatibility uncertainty be reported?

Separate verified API-shape observations, executed consumer checks, reviewed behavioral cases, and missing evidence. A precise qualified conclusion is more useful than “no breaking changes” without scope. Public contract compatibility is an evidence-backed statement about defined consumers, not a guarantee about every possible caller.

Get the Dev Leader Newsletter

C#, .NET, and software engineering content every Saturday.

Subscribe to the newsletter
An error has occurred. This application may no longer respond until reloaded. Reload