BrandGhost
Safe Refactoring in C#: A Workflow for Behavior-Preserving Changes
All articles

Safe Refactoring in C#: A Workflow for Behavior-Preserving Changes

Learn safe refactoring in C# with a worked workflow for recording behavior, making small changes, reviewing semantic differences, and preserving API contracts.

Safe refactoring starts with a specific promise: change the structure of this code without changing the behavior that its callers observe. That promise needs more than a green build. We need to identify the boundary, record the behavior there, make a bounded transformation, and examine the differences before calling the work complete.

This walkthrough follows a small C# report formatter through those checkpoints. It is not another catalogue of techniques or a guide to deciding which technical debt deserves attention. The decision to improve the structure has already been made. The question now is what evidence lets us distinguish safe refactoring from an accidental behavior change.

The documentation baseline checked on October 12, 2026 is .NET runtime 10.0.12 and SDK 10.0.401, and C# 14. The examples use ordinary console applications and standard-library APIs. They are documentation-reviewed illustrations, not code that was compiled or executed for this article.

Safe Refactoring Begins With a Bounded Change

Our imaginary report accepts an array of nullable names and returns one labeled entry for each accepted name. It ignores null, empty, and whitespace-only entries. It preserves the original spelling, leading spaces, trailing spaces, duplicates, and input order. Every accepted name receives the literal label item:. Joining adds separators only between entries, not after the final entry. Newlines already inside a name pass through, so an entry can span lines and the result can end in a newline supplied by the final name.

There is an obvious temptation to trim the names while improving the implementation. Resist it. Trimming may be a worthwhile product change, but it changes output. This distinction is the operational meaning of Martin Fowler's behavior-preserving transformations: a transformation is not a refactoring merely because its result looks more sensible.

Write the intended structural change separately: move the acceptance decision into a named helper while retaining the public method signature and output rules. That sentence is narrow enough to review. “Clean up reporting” is not. It leaves filtering, formatting, normalization, and API design open to simultaneous changes.

For the broader definition and motivation, the existing guide to understanding refactoring remains the starting point. Here, we are turning the preservation promise into a concrete working record.

Record the Observation Boundary

The caller sees a returned string or an exception. The method does not write to a file, mutate the array, or send network requests. Our observation boundary therefore includes the exact returned text, the null-array exception type and parameter name, and the absence of changes to array elements.

It does not include stack-trace frame names. Extracting a helper may change those names, and stack traces can matter to diagnostics. If callers actually depend on them, this proposed boundary is insufficient. Safe refactoring requires stating that limitation rather than quietly treating all diagnostics as invisible.

The same discipline applies in a larger system. A returned identifier may not be the only relevant output when the operation also writes a database row and emits an event. The boundary must describe those observations before tests can tell us anything useful about preservation.

Capture the Original Behavior Before Editing

The first example is a complete console program containing the original implementation and explicit assertions. Each fenced example in this article stands alone. Replace the console project's Program.cs with one example at a time; do not combine their top-level statements.

using System;
using System.Collections.Generic;

var names = new string?[] { "Ada", null, "", " ", " Bob ", "Ada" };
var actual = BuildReport(names);
if (actual != "item:Ada\nitem: Bob \nitem:Ada")
{
    throw new InvalidOperationException("Report baseline changed.");
}

if (names[4] != " Bob ")
{
    throw new InvalidOperationException("Input was mutated.");
}

try
{
    BuildReport(null);
    throw new InvalidOperationException("Expected null rejection.");
}
catch (ArgumentNullException exception)
{
    if (exception.ParamName != "names")
    {
        throw new InvalidOperationException("Parameter name changed.");
    }
}

static string BuildReport(string?[]? names)
{
    if (names is null)
    {
        throw new ArgumentNullException(nameof(names));
    }

    var lines = new List<string>();
    foreach (var name in names)
    {
        if (!string.IsNullOrWhiteSpace(name))
        {
            lines.Add("item:" + name);
        }
    }

    return string.Join("\n", lines);
}

The assertions make preservation concrete. An implementation that trims names would fail the text comparison. An implementation that deduplicates entries would also fail. Checking only the number of lines would miss both changes, which is why the observation needs to match the contract rather than an easy-to-measure proxy.

This is a baseline record, not proof that these business rules are desirable. We deliberately keep the spaces around Bob because they exist in the behavior being preserved. If the requirement says names must be normalized, record that as a separate discrepancy. Safe refactoring should not conceal an intentional fix inside a structural patch.

Add cases for an empty array, an all-rejected array, one accepted entry, and a name containing an embedded newline. The last case exposes an uncomfortable fact: this formatter is not an escaping protocol. It passes embedded newlines through. Preserve that behavior within this change or explicitly classify escaping as separate work.

The existing treatment of refactoring legacy code explains the preparation problem more broadly. Our narrower concern is how a selected observation becomes a discriminating assertion, including observations that are awkward rather than ideal.

Make One Transformation and Keep the Rules Still

Now extract only the acceptance predicate. Do not replace the array with an arbitrary enumerable, change separators, or introduce new formatting conventions. Those changes would expand the behavior surface beyond the question this patch is meant to answer.

using System;
using System.Collections.Generic;

var input = new string?[] { "Ada", null, " ", " Bob ", "Ada" };
if (BuildReport(input) != "item:Ada\nitem: Bob \nitem:Ada")
{
    throw new InvalidOperationException("Extracted version changed output.");
}

if (BuildReport(Array.Empty<string?>()) != "")
{
    throw new InvalidOperationException("Empty result changed.");
}

static string BuildReport(string?[]? names)
{
    if (names is null)
    {
        throw new ArgumentNullException(nameof(names));
    }

    var lines = new List<string>();
    foreach (var name in names)
    {
        if (IsAccepted(name))
        {
            lines.Add("item:" + name);
        }
    }

    return string.Join("\n", lines);
}

static bool IsAccepted(string? name)
{
    return !string.IsNullOrWhiteSpace(name);
}

The helper contains the same predicate, evaluates it once per element, and does not mutate its argument. The label creation remains in the same branch. Enumeration order and final joining remain unchanged. Those are the semantic reasons for expecting preservation; the assertions support that reasoning for their selected cases.

The helper is not inherently better because it is shorter. Its value is that the acceptance decision now has a name, which can clarify a later independent change. If the name obscures a straightforward condition, leaving the original code is a reasonable alternative. Safe refactoring is not a competition to maximize helper count.

For a fuller technique explanation, reuse the existing Extract Method example. This walkthrough focuses on the evidence around extraction, not another explanation of how to move statements into methods.

Keep a Checkpoint That Can Be Reviewed

The checkpoint should contain the original baseline cases, the single transformation, and a sentence describing what did not change. A reviewer should be able to match the old predicate to the helper and see that the label, joining, and array traversal are untouched.

In real work, record the exact validation commands and their actual results. “Tests added” is different from “tests executed successfully.” An interrupted run is not a pass. A test that was never observed failing against a deliberate mismatch offers less evidence about whether its assertions can detect the intended regression.

You can inspect that sensitivity without changing production behavior. In a disposable test exercise, change the expected label or introduce trimming and confirm that the relevant assertion detects it. The important point is the observed failure, not an invented claim that a harness must be effective because it contains comparisons.

Compare the Final Behavior Against the Original

A second checkpoint compares both implementations using independent copies of the same inputs. The comparison is useful when several cases need to travel through both paths, but it still requires knowing what observations matter.

using System;
using System.Collections.Generic;

var cases = new string?[][]
{
    Array.Empty<string?>(),
    new string?[] { null, "", " " },
    new string?[] { "Ada", " Bob ", "Ada" },
    new string?[] { "first\nsecond" }
};

foreach (var input in cases)
{
    var beforeInput = (string?[])input.Clone();
    var afterInput = (string?[])input.Clone();
    if (Before(beforeInput) != After(afterInput))
    {
        throw new InvalidOperationException("Differential output mismatch.");
    }
}

static string Before(string?[] names)
{
    var lines = new List<string>();
    foreach (var name in names)
    {
        if (!string.IsNullOrWhiteSpace(name))
        {
            lines.Add("item:" + name);
        }
    }

    return string.Join("\n", lines);
}

static string After(string?[] names)
{
    var lines = new List<string>();
    foreach (var name in names)
    {
        if (Accepted(name))
        {
            lines.Add("item:" + name);
        }
    }

    return string.Join("\n", lines);
}

static bool Accepted(string? name)
{
    return !string.IsNullOrWhiteSpace(name);
}

This comparison intentionally covers non-null arrays only. The null guard was checked separately and remains part of the review. Removing it from this smaller example does not mean the public contract no longer includes null rejection. A scoped harness must say what it excludes.

Independent arrays prevent one path from modifying the other's starting input. For mutable objects inside an array, a shallow clone would not isolate object state. You would need independent object graphs or a reset procedure. This example avoids that complication because its elements are strings, not mutable customer records.

Differential agreement can also hide shared mistakes. If both methods contain the same incorrect label, their comparison passes. That is why the explicit expected-output assertion remains valuable. Safe refactoring combines a recorded baseline, targeted examples, and semantic review instead of treating one technique as universal proof.

Review What the Diff Could Have Changed

Read the diff as a sequence of operations rather than a set of attractive lines. Was a null check moved after a dereference? Did a helper acquire a second call? Did formatting move outside a condition? Did continue become return, ending the whole method rather than skipping an element?

The existing control-flag refactoring discussion is useful when loop exits are involved. In this walkthrough, the operational lesson is to compare the exit boundary itself. Similar-looking indentation is not evidence that two paths terminate in the same place.

An automated preview deserves the same inspection as a handwritten patch. Tool assistance can reduce mechanical work, but it does not establish that a changed private name is absent from reflection strings or configuration. A project-wide symbol update answers a different question from “every relevant consumer still works.”

Keep performance outside this preservation claim unless you have measurements and an agreed performance contract. This article does not assert that a helper is faster, allocation-free, or production-proven. If an optimization is necessary, make its behavioral and measurement requirements explicit instead of assuming that less code means less work.

Check the Public Contract Before Calling It Internal

Our local functions are illustration details, but a real formatter might be a public library method. If consumers call it by name, retaining the output while renaming the method is not enough. Existing source may fail to compile, and already-built consumers may reference the old member.

The same issue applies to parameter names used by named arguments. A rename from names to items can leave positional calls looking fine while named calls break. Microsoft's library compatibility rules treat public member and parameter renames as compatibility concerns.

Keep the public entry point unchanged while moving implementation behind it when that satisfies the structural goal. A wrapper is not a universal guarantee: stack traces, reflection over private members, serialization configuration, and initialization behavior may need separate investigation. Record the consumer boundary rather than promising compatibility for unspecified use.

Existing work on essential C# refactoring techniques already warns about inaccessible consumers. The operational checkpoint is to find those consumers or state the missing evidence, not simply repeat that public APIs are important.

Decide What the Evidence Actually Supports

The formatter checkpoint supports a bounded conclusion: the acceptance predicate was extracted without an identified change to the selected outputs, null-array rejection, ordering, or input contents. It does not prove all possible integrations, runtime environments, diagnostics, or future callers.

Stop when a mismatch cannot be explained. Do not update expected output merely to make the suite green. First decide whether the mismatch is a test mistake, an unintended regression, or a desired behavior change. Only the first allows correcting the test without changing the preservation promise.

Stop also when establishing the boundary requires more production changes than the proposed transformation. A difficult dependency may call for a separate preparatory change. Reuse the existing material on interfaces and dependency isolation rather than turning every safe refactoring task into a new dependency-injection framework.

The final record should distinguish verified observations from assumptions. Tests that ran, cases inspected, consumer artifacts checked, and known omissions belong in different sentences. This precision lets another developer understand where confidence comes from and what further work would change the decision.

Retain the baseline input and expected output with that record. A statement that a report stayed unchanged is harder to investigate when nobody can reconstruct which names were supplied. Keeping the concrete fixture also lets a later formatting change explain exactly which previous observations it intentionally replaces.

Frequently Asked Questions About Safe Refactoring

Does a successful build prove safe refactoring?

No. A build can show that the selected source is acceptable to its compiler, but output, exceptions, side effects, and external consumers can still change. Safe refactoring needs evidence at the observation boundary as well as a review of the operations that the diff moves.

Should I fix a bug discovered while recording behavior?

Record the bug and decide on it separately. A baseline can describe undesirable behavior without endorsing it. If the bug must be fixed immediately, make the intentional change explicit and use requirements-based assertions for the new behavior rather than presenting the combined patch as behavior-preserving.

How small should each transformation be?

Small enough that you can explain its structural purpose, match its operations to the original, and identify the observations that could discriminate a mistake. There is no universal line-count threshold. A three-line edit can change exception handling, while a larger mechanical extraction may have a narrow semantic effect.

Must every refactoring introduce interfaces?

No. Choose the narrowest existing boundary that exposes the behavior you need to observe. Interfaces help when they separate a hard dependency, but adding them solely to satisfy a testing habit can introduce unnecessary design work. A returned string can often be asserted directly without a new abstraction.

Should examples always use the newest C# syntax?

No. Current language awareness matters, but syntax changes should serve the selected transformation. C# 14 adds field, extension members, and null-conditional assignment; none is needed to extract this predicate. Introducing them here would widen the review without strengthening the evidence for safe refactoring.

What is the final acceptance statement?

Describe the actual boundary and observations, then disclose the limits. “Selected outputs and null rejection are preserved in the reviewed formatter” is more informative than “everything is safe.” Safe refactoring becomes understandable when its scope is explicit and its confidence is attached to evidence rather than a label.

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