Learnixo
Back to blog
AI Systemsintermediate

Refactoring Under Test — Changing Code Without Changing Behaviour

Refactor safely in .NET using TDD: extract method, replace conditional with polymorphism, introduce value objects, and use the test suite as a safety net throughout — with clinical domain examples.

Asma Hafeez KhanMay 16, 20266 min read
TDDRefactoringTesting.NETClean Code
Share:𝕏

What Refactoring Is (and Isn't)

Refactoring: changing the internal structure of code without changing its observable behaviour.

It is NOT:
  → Changing what the code does
  → Adding new features
  → Fixing bugs (that's a behaviour change — test it first)

The tests are your safety net:
  → Before refactoring: all tests green
  → During refactoring: tests remain green at every small step
  → After refactoring: all tests still green
  If a test goes red during refactoring, you changed behaviour. Stop, understand why, revert.

Small steps matter:
  → Refactor one thing at a time
  → Run tests after every change
  → If you cannot run tests in under 30 seconds, your test suite needs work

Refactoring 1 — Extract Method

C#
// Before: complex method with inline logic
public Result Approve(decimal inrValue, DateTime checkedAt, Guid approvedBy)
{
    if (Status != PrescriptionStatus.Draft)
        return Result.Failure(Error.Validation("Status",
            "Only Draft prescriptions can be approved."));

    if (checkedAt < DateTime.UtcNow.AddHours(-24))
        return Result.Failure(Error.Validation("InrCheck",
            "INR check must be within the last 24 hours."));

    if (MedicationName.Value == "Warfarin" && (inrValue < 2.0m || inrValue > 3.0m))
        return Result.Failure(Error.Validation("InrValue",
            "Warfarin INR must be between 2.0 and 3.0 for approval."));

    InrValue     = inrValue;
    InrCheckedAt = checkedAt;
    Status       = PrescriptionStatus.Approved;
    return Result.Success();
}

// After: each guard extracted to a clearly named private method
public Result Approve(decimal inrValue, DateTime checkedAt, Guid approvedBy)
{
    if (IsNotInDraftStatus())
        return Result.Failure(Error.Validation("Status",
            "Only Draft prescriptions can be approved."));

    if (IsInrCheckExpired(checkedAt))
        return Result.Failure(Error.Validation("InrCheck",
            "INR check must be within the last 24 hours."));

    if (IsWarfarinInrOutOfRange(inrValue))
        return Result.Failure(Error.Validation("InrValue",
            "Warfarin INR must be between 2.0 and 3.0 for approval."));

    InrValue     = inrValue;
    InrCheckedAt = checkedAt;
    Status       = PrescriptionStatus.Approved;
    return Result.Success();
}

private bool IsNotInDraftStatus() =>
    Status != PrescriptionStatus.Draft;

private static bool IsInrCheckExpired(DateTime checkedAt) =>
    checkedAt < DateTime.UtcNow.AddHours(-24);

private bool IsWarfarinInrOutOfRange(decimal inrValue) =>
    MedicationName.Value == "Warfarin" && (inrValue < 2.0m || inrValue > 3.0m);
Tests: still green. Behaviour: identical. Readability: improved.

Refactoring 2 — Replace Magic Numbers with Named Constants

C#
// Before: magic numbers scattered through the class
if (checkedAt < DateTime.UtcNow.AddHours(-24)) ...
if (inrValue < 2.0m || inrValue > 3.0m) ...

// After: named constants express clinical intent
private const int    InrValidityHours    = 24;
private const decimal WarfarinInrMinimum = 2.0m;
private const decimal WarfarinInrMaximum = 3.0m;

private static bool IsInrCheckExpired(DateTime checkedAt) =>
    checkedAt < DateTime.UtcNow.AddHours(-InrValidityHours);

private bool IsWarfarinInrOutOfRange(decimal inrValue) =>
    MedicationName.Value == "Warfarin" &&
    (inrValue < WarfarinInrMinimum || inrValue > WarfarinInrMaximum);

Refactoring 3 — Introduce Value Object

C#
// Before: bare decimal for INR — no validation, no meaning
public Result Approve(decimal inrValue, DateTime checkedAt, Guid approvedBy)

// After: InrValue value object enforces range and carries clinical meaning

public sealed record InrValue
{
    public decimal Value { get; }

    private InrValue(decimal value) => Value = value;

    public static Result<InrValue> Create(decimal value)
    {
        if (value <= 0 || value > 20)
            return Result<InrValue>.Failure(Error.Validation("InrValue",
                "INR value must be between 0.1 and 20.0."));

        return Result<InrValue>.Success(new InrValue(value));
    }

    public bool IsTherapeuticForWarfarin() => Value >= 2.0m && Value <= 3.0m;
}

// Test for the value object:
[Theory]
[InlineData(2.0,  true)]
[InlineData(3.0,  true)]
[InlineData(1.99, false)]
[InlineData(3.01, false)]
public void IsTherapeuticForWarfarin_ReturnsExpected(decimal value, bool expected)
{
    var inr = InrValue.Create(value).Value;
    inr.IsTherapeuticForWarfarin().Should().Be(expected);
}

// Approve method now uses the value object:
public Result Approve(InrValue inrValue, DateTime checkedAt, Guid approvedBy)
{
    if (IsNotInDraftStatus())  return ...;
    if (IsInrCheckExpired(checkedAt)) return ...;
    if (MedicationName.Value == "Warfarin" && !inrValue.IsTherapeuticForWarfarin())
        return Result.Failure(Error.Validation("InrValue", ...));

    // ...
}

Refactoring 4 — Replace Conditional with Polymorphism

C#
// Before: switch on medication type for dosage rules
public Result ValidateDose(string medicationName, decimal dose)
{
    return medicationName switch
    {
        "Warfarin"  => dose >= 0.5m && dose <= 20m
            ? Result.Success()
            : Result.Failure(Error.Validation("Dose", "Warfarin dose must be 0.5-20mg.")),
        "Heparin"   => dose >= 100m && dose <= 40000m
            ? Result.Success()
            : Result.Failure(Error.Validation("Dose", "Heparin dose must be 100-40000 IU.")),
        _           => Result.Success()
    };
}

// After: polymorphism — each medication has its own validation rule
public interface IMedicationDosageRule
{
    Result Validate(decimal dose);
}

public sealed class WarfarinDosageRule : IMedicationDosageRule
{
    public Result Validate(decimal dose) =>
        dose >= 0.5m && dose <= 20m
            ? Result.Success()
            : Result.Failure(Error.Validation("Dose", "Warfarin dose must be 0.5–20mg."));
}

public sealed class HeparinDosageRule : IMedicationDosageRule
{
    public Result Validate(decimal dose) =>
        dose >= 100m && dose <= 40000m
            ? Result.Success()
            : Result.Failure(Error.Validation("Dose", "Heparin dose must be 100–40000 IU."));
}

// Adding a new medication requires no changes to existing code (OCP)
// Each rule is independently testable

When NOT to Refactor

Don't refactor when:
  → Tests are not green (fix the failure first)
  → You are about to add a feature (refactor first, then add the feature)
  → The code is not covered by tests (add tests first)
  → The deadline is in 2 hours (ship, schedule refactor for next sprint, leave a TODO)

Refactoring is separate from feature work:
  → Commit A: "refactor: extract InrValue value object" (no behaviour change)
  → Commit B: "feat: add INR range validation to prescription approval" (behaviour change)
  Mixing them makes code review harder and rollback riskier.

Production issue I've seen: A developer "refactored" a prescription approval handler while also fixing a bug and adding a new field. The commit was 400 lines. In code review, nobody could tell which changes were the bug fix (necessary), which were refactoring (safe), and which added the new field. The refactoring accidentally removed a null check. It merged, and two days later a NullReferenceException appeared in production when ApprovedBy was null in legacy data. If the refactoring had been a separate commit with passing tests, the null check removal would have been caught immediately — the test for null ApprovedBy would have turned red.


Key Takeaway

Refactoring means changing internal structure without changing observable behaviour — the test suite proves the behaviour is unchanged. Refactor in small, independent steps: run tests after every change. Extract methods, introduce value objects, replace conditionals with polymorphism — one at a time, all tests green throughout. Never mix refactoring with feature additions or bug fixes in the same commit. The tests are the safety net; you cannot refactor safely without them.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.