Skip to content
Refactoring Legacy C# Codebases: From `async void` and `ArrayList` to Modern, Safe C#

Refactoring Legacy C# Codebases: From `async void` and `ArrayList` to Modern, Safe C#

1 The Strategic Modernization Roadmap: Philosophy Over Syntax

Refactoring legacy C# code is rarely about swapping old syntax for new syntax. Experienced teams know the real problem runs much deeper. The true enemy is structural fragility—systems that are hard to test, fail in unpredictable ways, and resist change because touching one area breaks something completely unrelated.

When we say “legacy,” we’re not talking about how old the code is. We’re talking about systems that lack:

  • Type safety
  • Clear boundaries between responsibilities
  • An architecture that supports change instead of fighting it

This is where modern C# helps the most. Replacing ArrayList with List<T> or removing async void methods is important, but those changes are just the doorway. The real goal is a codebase where behavior is explicit, dependencies are visible, and logic can be tested in isolation.

Modernization is not cosmetic. It’s about restoring trust in the system.

1.1 The “Big Bang” Fallacy vs. the Strangler Fig Pattern

At some point, every legacy system inspires the same thought: “Wouldn’t it be easier to just rewrite the whole thing?”

That idea usually survives until someone asks a dangerous but necessary question: “What if the undocumented logic in that 800-line method is the reason revenue reports still reconcile?”

Rewrite enthusiasm tends to collapse right there.

Industry data consistently shows rewrite failure rates hovering around 70%, and the reason is simple. Rewrites discard institutional knowledge—knowledge that lives in code, even when the code is messy. Legacy systems often behave the way they do for reasons no one remembers anymore. You usually discover those reasons only after something stops working.

So the real question isn’t whether to modernize, but how to do it without breaking production.

That’s where the Strangler Fig pattern comes in—applied surgically, not as a sweeping architectural revolution. Instead of replacing the entire system, you gradually grow modern code around the legacy logic, letting the old implementation shrink over time.

Importantly, this can happen inside a single assembly. You don’t need microservices, distributed systems, or massive rewrites to apply the pattern effectively.

1.1.1 Applying the Strangler Fig Pattern Inside a Single Assembly

Even small legacy libraries can be modernized safely by preserving public contracts while replacing internal behavior.

Consider a typical legacy surface area:

public class LegacyOrderProcessor
{
    public object Process(object order);
}

The worst thing you could do is rewrite this class and force every caller to change at once. Instead, keep the public API stable and redirect the implementation behind it:

public class LegacyOrderProcessor
{
    private readonly ModernOrderProcessor _modern = new();

    public object Process(object order)
    {
        // Over time, this collapses into a single, predictable call
        return _modern.Process((Order)order);
    }
}

Nothing outside this class needs to change. Callers continue working as before, while the real logic moves into a modern, testable implementation. Gradually, the legacy code becomes a thin façade—and eventually, something you can remove entirely.

This approach avoids the “Big Bang” rewrite and gives you a safe staging area to eliminate the most dangerous patterns one by one:

  • ArrayList and Hashtable
  • Static global state
  • Synchronous I/O in request paths
  • async void methods with unobserved failures

Each improvement is incremental, reversible, and low risk.

1.2 The Fictional Case Study: LegacyOrderProcessor

To keep these ideas grounded, we’ll use a fictional—but very realistic—example throughout this article. The system centers around a class called LegacyOrderProcessor, originally written during the .NET 2.0 era and extended over many years as requirements piled up.

If you’ve maintained enterprise C# software, you’ve almost certainly seen something like this.

1.2.1 What the Legacy Service Looks Like Today

The current implementation includes:

  • A single class with over 3,000 lines of logic
  • Order items stored in an ArrayList, requiring constant casting
  • A Hashtable acting as a loosely typed configuration cache
  • Synchronous ADO.NET calls embedded directly in business logic
  • Event handlers “modernized” in a 2018 async push, resulting in multiple async void methods that swallow exceptions and run concurrently without coordination

This kind of service becomes expensive in subtle ways. Bugs appear or disappear depending on timing and data shape. Changes feel risky. New developers hesitate to touch it. Teams compensate with excessive logging, manual testing, and long QA cycles.

The objective is not just cleaner code. The objective is to restore predictability, safety, and architectural clarity.

1.3 The Tooling Landscape (2024–2025)

The good news is that modernizing legacy C# code is easier today than it has ever been. The .NET ecosystem now provides strong tooling that removes much of the mechanical pain before you even touch the logic.

1.3.1 Using the .NET Upgrade Assistant

The .NET Upgrade Assistant handles the unglamorous but essential groundwork:

  • Converting old .csproj files to SDK-style projects
  • Adding modern target frameworks (for example, net8.0)
  • Migrating binding redirects and package references
  • Flagging APIs that no longer exist or behave differently

It won’t fix architectural problems—and it shouldn’t—but it gives you a clean, modern project foundation. This step is critical. Without it, you can’t reliably use modern analyzers, language features, or tooling.

Think of it as clearing the debris before structural work begins.

1.3.2 Enforcing Modern Rules with Roslyn Analyzers

Once the project builds on a modern framework, install analyzers immediately:

<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="8.*" PrivateAssets="all" />

These analyzers act as guardrails and will surface:

  • Usage of ArrayList, Hashtable, and other untyped collections
  • async void method signatures
  • Unawaited Task instances
  • Obsolete or dangerous APIs
  • Opportunities for safer, clearer modern patterns

Analyzers don’t refactor the code for you—but they turn uncertainty into visibility. Instead of guessing where the risks are, you get a concrete, prioritized list of problem areas.

Refactoring a large legacy system without analyzers is intuition-driven and error-prone. With analyzers, modernization becomes a deliberate, staged process—and that’s exactly what legacy systems need.


2 Establishing the Safety Net: Characterization Testing

Before you change a single line of legacy code, experienced teams do one thing first: they make sure they understand what “correct behavior” actually means.

In legacy systems, correctness is almost never written down. The original developers are gone. Requirements live in tribal knowledge. The code itself doesn’t explain intent—it only reveals behavior after the fact. That makes refactoring dangerous, because even small changes can have unintended consequences.

Something as harmless-looking as replacing an ArrayList with List<T> can change behavior in subtle ways:

  • Casting failures that used to be swallowed now throw
  • Enumeration order shifts
  • null handling behaves differently

Refactoring without protection is guesswork. Characterization tests provide that protection.

They don’t define what the system should do. They document what it does today. Once that behavior is captured, you can refactor with confidence.

2.1 The Golden Rule: Test First, Refactor Later

A common mistake when working with legacy code is writing tests based on assumptions:

“This method probably calculates shipping like this…” “Surely this discount logic was meant to work that way…”

Those assumptions are often wrong.

Characterization testing takes a safer approach. You don’t try to interpret intent. You observe inputs and capture outputs. If the system produces a result today, that result becomes the baseline—even if the logic behind it looks questionable.

You don’t need to understand the internals of LegacyOrderProcessor to write meaningful tests. You only need to capture how it behaves when given specific inputs.

2.1.1 Why This Rule Matters in Practice

Imagine LegacyOrderProcessor calculates shipping based on a value pulled from a loosely typed Hashtable. The logic is messy, undocumented, and arguably incorrect—but downstream systems rely on it.

If you “fix” the calculation while refactoring, you might:

  • Break accounting reports
  • Change invoice totals
  • Trigger reconciliation failures

Characterization tests make those changes immediately visible. If behavior changes, the tests fail. That forces an explicit decision instead of an accidental regression.

2.2 Snapshot Testing with Verify

One of the fastest ways to characterize legacy behavior is snapshot testing. Instead of asserting individual fields, you capture the entire output and compare it over time.

The open-source Verify library integrates cleanly with xUnit, NUnit, and MSTest. It can snapshot:

  • Objects
  • Strings
  • JSON
  • Logs
  • File output

For legacy refactoring, this is extremely powerful.

2.2.1 Capturing LegacyOrderProcessor Behavior

A simple characterization test might look like this:

[Fact]
public async Task LegacyOrderProcessor_OutputIsStable()
{
    var processor = new LegacyOrderProcessor();
    var order = TestDataFactory.CreateSampleOrder();

    var result = processor.ProcessOrder_Legacy(order);

    await Verify(result);
}

On the first run, Verify creates a .verified.txt (or JSON) snapshot. Every run after that compares the current output against the snapshot.

If a refactor changes behavior—even slightly—you get a clear, readable diff.

This is exactly what you want when modernizing internals behind a stable public API.

2.2.2 Dealing with Non-Deterministic Legacy Output

Legacy systems often produce values that change every run:

  • DateTime.Now
  • Random IDs
  • GUIDs
  • Hash-based ordering from Hashtable

If left alone, these make snapshot tests noisy and unreliable. Verify solves this with scrubbers, which normalize unstable values.

For example:

VerifierSettings.ScrubMembersWithRegex("Id|Timestamp|CreatedDate");

Or a custom scrubber:

VerifierSettings.AddScrubber(builder =>
{
    builder.Replace(Guid.NewGuid().ToString(), "GUID_REDACTED");
});

With scrubbers in place, snapshots reflect meaningful changes—not random noise.

2.2.3 Why Snapshot Testing Works So Well for Legacy Code

Snapshot tests give you two critical advantages:

  1. You lock in current behavior before refactoring begins
  2. You gain the freedom to replace large internal implementations safely

As you move logic from LegacyOrderProcessor into a modern, testable implementation (as described in Section 1), snapshots ensure the system behaves exactly the same—until you intentionally decide otherwise.

Modernization without behavior drift is the goal.

2.3 Exercising Edge Cases with Bogus

One snapshot test with a single static example is not enough. Legacy bugs tend to hide in edge cases—cases no one ever thought to test explicitly.

The Bogus library helps by generating large amounts of realistic, strongly typed test data.

2.3.1 Generating Orders with Bogus

Here’s an example of generating order data for LegacyOrderProcessor:

var orderFaker = new Faker<Order>()
    .RuleFor(o => o.Id, f => f.Random.Guid())
    .RuleFor(o => o.CustomerId, f => f.Random.Int(1, 1000))
    .RuleFor(o => o.Items, f => f.Make(5, () =>
        new OrderItem
        {
            Sku = f.Commerce.Product(),
            Quantity = f.Random.Int(1, 10),
            Price = f.Random.Decimal(1, 200)
        }));

Each generated order looks valid, but no two are exactly the same. Running snapshot tests across many generated inputs surfaces behavior you didn’t even know existed.

2.3.2 Why This Is Critical for Legacy Refactoring

When refactoring code that relies on ArrayList, Hashtable, or unchecked casting, failures tend to occur when:

  • Mixed types slip into collections
  • null values appear unexpectedly
  • Ordering assumptions break
  • Casting errors were previously swallowed

Bogus-generated data exposes these cases early—before they reach production.

Combined with snapshot testing, it gives you a robust safety net: wide input coverage with stable, observable output.


3 Phase I: Type Safety and Collection Modernization

With characterization tests in place, you finally have room to breathe. The system’s behavior is locked down, failures are visible, and refactoring no longer feels like defusing a bomb.

This is where modernization starts to pay off quickly.

The first phase focuses on type safety and collections. These changes tend to produce immediate, visible improvements:

  • Runtime casting errors disappear
  • Accidental null values surface early
  • Static analysis tools suddenly understand your code
  • Business intent becomes clearer

Most legacy C# codebases—our LegacyOrderProcessor included—still rely heavily on ArrayList and Hashtable. Removing them is one of the highest-return refactors you can make.

3.1 Exorcising ArrayList and Hashtable

In our running example, the legacy service contains fields like these:

private ArrayList _items = new ArrayList();
private Hashtable _config = new Hashtable();

These APIs date back to .NET 1.0, before generics existed. Everything is stored as object, which forces the rest of the codebase to guess—and hope—what type is really inside.

This design hides bugs instead of preventing them.

3.1.1 Refactoring ArrayList to List<T>

A typical legacy pattern looks like this:

foreach (object obj in _items)
{
    var item = (OrderItem)obj;
    total += item.Price;
}

This compiles, but it’s fragile. A single wrong insert elsewhere in the system turns into a runtime exception here.

The modern version is simpler and safer:

private readonly List<OrderItem> _items = new();

foreach (var item in _items)
{
    total += item.Price;
}

The code is shorter, clearer, and—most importantly—the compiler now enforces correctness.

The Ripple Effects (and Why They’re Good)

Changing the collection type will surface issues immediately:

  • Methods that add the wrong type
  • Code paths where null was quietly inserted
  • Sorting or comparison logic that relied on untyped behavior

At first, this feels noisy. In reality, these are bugs that were always there—just hidden behind casts. Your characterization tests ensure that fixing them doesn’t accidentally change external behavior.

This is modernization doing its job.

3.1.2 When a List<T> Isn’t the Right Answer

In many legacy systems, lists are used as “collections of unique things,” but nothing actually enforces uniqueness. The code just assumes it.

If characterization tests show that _items is treated as a set—no duplicates allowed, frequent membership checks—then HashSet<T> is often the better choice.

Use List<T> when you need:

  • Ordering
  • Duplicate entries
  • Index-based access

Use HashSet<T> when you need:

  • Uniqueness guarantees
  • Fast lookups
  • Deduplicated processing

Choosing the right collection clarifies intent and prevents entire classes of bugs.

3.1.3 Replacing Hashtable with FrozenDictionary<TKey, TValue>

Hashtable has three major problems:

  • It’s untyped
  • It’s not thread-safe
  • It’s inefficient for read-heavy workloads

In LegacyOrderProcessor, configuration values are often read but rarely changed. This makes them a perfect candidate for FrozenDictionary, introduced in .NET 8.

Legacy code:

private Hashtable _config = new Hashtable();
var rate = (decimal)_config["ShippingRate"];

Modern equivalent:

private readonly FrozenDictionary<string, decimal> _config;

Initialization:

_config = new Dictionary<string, decimal>
{
    ["ShippingRate"] = 4.95m,
    ["TaxRate"] = 0.08m
}.ToFrozenDictionary();

FrozenDictionary provides:

  • Faster reads than Dictionary
  • Lower memory usage
  • Guaranteed immutability and thread safety

For configuration and lookup tables, it’s an ideal replacement.

3.2 Embracing Modern Collection Syntax (C# 12)

Once your collections are typed, modern C# syntax starts to shine. These changes don’t just reduce lines of code—they make intent clearer and reduce opportunities for mistakes.

3.2.1 Collection Expressions

Before:

var statuses = new List<string> { "Pending", "Paid", "Shipped" };

After:

List<string> statuses = ["Pending", "Paid", "Shipped"];

Less noise. Same meaning. Easier to scan.

3.2.2 The Spread Operator

Legacy code often merges collections like this:

var all = new List<Order>();
all.AddRange(onlineOrders);
all.AddRange(inStoreOrders);

Modern syntax:

var all = [..onlineOrders, ..inStoreOrders];

The intent is immediately obvious: combine these two sequences.

3.2.3 Ranges and Indices

Legacy slicing tends to be verbose and allocation-heavy:

var last = items[items.Length - 1];
var subset = items.Skip(1).Take(3).ToArray();

Modern syntax is clearer and often more efficient:

var last = items[^1];
var subset = items[1..4];

When working with spans, slicing can avoid copying altogether—something older code never had the option to do.

3.3 Replacing Casting Logic with Pattern Matching

Legacy codebases are full of is checks, as casts, and nested conditionals. This style mixes type checking, null handling, and business rules into a single tangled block.

A common example:

if (obj is Order)
{
    var o = obj as Order;
    if (o.Status == "Pending") { ... }
    else if (o.Status == "Paid") { ... }
    else if (o.Status == "Shipped") { ... }
}

This is hard to read and easy to break.

3.3.1 A Modern Switch Expression

Pattern matching lets you express the same logic declaratively:

return obj switch
{
    Order { Status: "Pending" } => HandlePending(obj),
    Order { Status: "Paid" }    => HandlePaid(obj),
    Order { Status: "Shipped" } => HandleShipped(obj),
    null                        => throw new ArgumentNullException(nameof(obj)),
    _                           => throw new InvalidOperationException("Unknown type")
};

The code now reads like a set of business rules instead of defensive plumbing.

3.3.2 Why This Matters

Pattern matching:

  • Eliminates invalid casts
  • Forces you to handle all cases
  • Makes illegal states explicit
  • Aligns code structure with domain logic

In real legacy systems, it’s common to reduce 50+ lines of nested conditionals into a handful of clear expressions.

This is often the moment teams feel the payoff of modernization—because the code finally explains itself again.


4 Phase II: The Async Revolution (Concurrency)

By this point, LegacyOrderProcessor is safer and clearer. Typed collections replaced guesswork, and the code finally tells the truth about what it expects.

But there’s still one major source of instability left: concurrency.

In most legacy C# systems, async behavior was added late and under pressure. Synchronous database calls coexist with async void handlers. Tasks are blocked instead of awaited. Failures disappear into the thread pool. The result is a system that works—until load increases, timing changes, or something fails silently.

Modernizing concurrency isn’t about sprinkling async keywords around. It’s about reshaping the execution model so:

  • I/O doesn’t block threads
  • failures are observable
  • work can be cancelled
  • throughput degrades gracefully under load

This phase is where many legacy systems finally stop behaving unpredictably.

4.1 The async void Trap and Thread Pool Starvation

In LegacyOrderProcessor, async support was added years after the original design. Several event handlers were converted quickly, often by changing void to async void and moving on.

That’s a dangerous pattern.

An async void method:

  • Cannot be awaited
  • Cannot be composed
  • Propagates exceptions directly to the runtime
  • Detaches execution from the caller entirely

A real example from the legacy service looked like this:

public async void OnOrderProcessed(object sender, EventArgs e)
{
    await _emailService.SendConfirmationAsync();
}

If SendConfirmationAsync throws, there’s no caller to catch it. Depending on the hosting environment, this can crash the process or fail silently. Either way, you’ve lost control.

4.1.1 Converting async void into Observable Work

The fix is conceptually simple: move the async logic into an async Task method and make the event handler explicitly fire-and-forget.

public async Task OnOrderProcessedAsync()
{
    await _emailService.SendConfirmationAsync();
}

The event handler becomes:

public void OnOrderProcessed(object sender, EventArgs e)
{
    _ = OnOrderProcessedAsync();
}

That discarded task (_ =) is intentional. It documents the choice to not await, while still ensuring:

  • Exceptions stay within the task
  • Logging and monitoring can observe failures
  • The async method remains testable

This pattern shows up repeatedly when modernizing legacy event-driven code.

4.1.2 Identifying Sync-over-Async Bottlenecks

Another common issue in LegacyOrderProcessor was blocking on asynchronous calls:

var order = _repository.GetOrderAsync(id).Result;

or:

_repository.SaveAsync(order).Wait();

This pattern defeats the entire purpose of async. The thread blocks while waiting for I/O, and under load, enough blocked threads lead to thread pool starvation. When that happens, the system doesn’t fail fast—it just slows down until it appears frozen.

The only real fix is to let async flow naturally:

var order = await _repository.GetOrderAsync(id);

This change ripples upward. Once a method becomes async, its callers often must follow. That’s expected—and it’s healthy. When the top-level entry point is asynchronous, the entire pipeline becomes non-blocking and scalable.

4.2 Converting Blocking I/O to True Async/Await

The data access layer in LegacyOrderProcessor still used synchronous ADO.NET calls. These calls tie up threads while waiting on the database—exactly the opposite of what you want in a high-throughput system.

Legacy code:

using var reader = command.ExecuteReader();

Modernized:

await using var reader = await command.ExecuteReaderAsync(cancellationToken);

This single change has wide-reaching effects. Every method in the call chain must now support async, but the payoff is substantial:

  • Threads are released during I/O waits
  • The system handles spikes without spawning more threads
  • Latency becomes more predictable

4.2.1 Making Cancellation a First-Class Concern

Once async is in place, cancellation becomes practical—and extremely valuable.

Updated repository methods now accept a CancellationToken:

public async Task<Order?> GetOrderAsync(
    int id,
    CancellationToken cancellationToken)
{
    await using var connection = new SqlConnection(_connectionString);
    await connection.OpenAsync(cancellationToken);

    using var command = new SqlCommand("select ...", connection);
    return await ReadOrderAsync(command, cancellationToken);
}

This allows upstream callers—web requests, background workers, message handlers—to cancel work when:

  • a request times out
  • a client disconnects
  • the system is shutting down

Instead of wasting resources on doomed work, the pipeline becomes cooperative and responsive.

4.2.2 A Safe Migration Strategy

As with earlier phases, async modernization should be incremental:

  1. Introduce async versions alongside synchronous methods
  2. Route new code to async paths
  3. Gradually migrate callers
  4. Remove synchronous versions once stabilized

This mirrors the Strangler Fig approach used earlier—only now, the boundary is execution flow, not architecture.

4.3 Parallelism Done Right

Legacy systems often attempt parallelism manually, usually by spawning tasks in a loop:

var tasks = new List<Task>();
foreach (var item in items)
{
    tasks.Add(Task.Run(() => ProcessItem(item)));
}
await Task.WhenAll(tasks);

This looks harmless, but it scales poorly. There’s no throttling, no backpressure, and no awareness of system capacity. A large order batch can overwhelm the thread pool instantly.

Modern .NET provides a safer alternative: Parallel.ForEachAsync.

await Parallel.ForEachAsync(
    items,
    new ParallelOptions
    {
        MaxDegreeOfParallelism = Environment.ProcessorCount
    },
    async (item, ct) =>
    {
        await ProcessItemAsync(item, ct);
    });

4.3.1 Why This Matters for Legacy Systems

Parallel.ForEachAsync:

  • Prevents unbounded task creation
  • Integrates cancellation naturally
  • Adapts to available system resources
  • Produces stable throughput under load

For workloads like processing order lines, applying pricing rules, or validating large batches, this change alone can eliminate entire classes of production incidents.


5 Phase III: Null Safety and Defensive Coding

By now, LegacyOrderProcessor is faster, safer, and more predictable under load. Typed collections replaced guesswork, async flows are observable, and concurrency behaves like you expect.

The next source of instability is subtler: null.

In the legacy system, null values were tolerated almost everywhere. That wasn’t always intentional—it was often a side effect of untyped collections, unchecked casts, and APIs that never stated their expectations. Over time, the code evolved around defensive checks and “just in case” logic, making behavior harder to reason about.

Modernizing null safety requires a mindset shift. Instead of reacting to null at runtime, you make explicit decisions about where null is allowed—and let the compiler enforce those decisions.

5.1 Enabling Nullable Reference Types (NRT)

The foundation of null safety in modern C# is Nullable Reference Types. Enable it at the project level:

<PropertyGroup>
    <Nullable>enable</Nullable>
</PropertyGroup>

Turning this on in a legacy codebase will almost certainly produce a flood of warnings. That’s normal—and expected.

A practical strategy is to adopt NRT incrementally:

  1. Enable NRT globally
  2. Add #nullable disable at the top of untouched legacy files
  3. Remove the directive as each file is modernized

This keeps the build green while ensuring that new and refactored code is held to a higher standard.

5.1.1 What Actually Changes When NRT Is Enabled

Once NRT is active, reference types default to non-nullable. That forces clarity at API boundaries.

For example:

  • Returning Order? tells callers, “This may be missing—handle it.”
  • Returning Order promises, “You will always get a valid instance.”

The compiler now enforces those promises. Warnings aren’t failures—they’re questions:

Is this value really allowed to be null? If so, how should callers respond?

The goal isn’t to eliminate warnings immediately. The goal is to turn ambiguity into explicit contracts.

5.2 Managing the Initial Warning Explosion

The first NRT pass through LegacyOrderProcessor usually exposes years of implicit assumptions:

  • Fields that were never initialized
  • Methods that sometimes return null
  • Code paths that don’t guarantee a value

Some warnings indicate real bugs. Others exist because the compiler can’t infer what humans already know.

5.2.1 When the Compiler Is Unsure

Consider this method:

public Order GetOrder(int id)
{
    var order = TryLoad(id);
    if (order == null)
        throw new NotFoundException();

    return order;
}

From a human perspective, this is safe. From the compiler’s perspective, order might still be null.

When you truly know more than the compiler, use the null-forgiving operator:

return order!;

This is a conscious declaration:

“We guarantee this value is non-null at this point.”

Use it sparingly. Each ! is a contract that future maintainers must respect.

5.2.2 Fixing the Real Design Issues

Many warnings point to deeper problems.

For example, if LoadCustomerAsync returns Customer?, but the rest of the system cannot function without a customer, propagating null just spreads uncertainty. In those cases, a better design is to:

  • Throw a domain-specific exception
  • Fail fast
  • Keep the rest of the pipeline null-free

The same applies to configuration and deserialization. Legacy code often allows missing values and hopes for the best:

public string? Region { get; init; }

If Region is required for pricing logic, the type should reflect that:

public string Region { get; init; } = default!;

Then enforce validation at startup. Fail early. Fail loudly.

5.3 Modern Object Construction and Explicit Contracts

Null issues are often symptoms of unclear initialization. The legacy processor contains DTOs and services with optional fields, mutable state, and public setters—patterns that invite invalid states.

Modern C# gives you better tools.

5.3.1 Using required to Enforce Initialization

The required keyword makes object contracts explicit:

public class OrderDto
{
    public required int Id { get; init; }
    public required string Customer { get; init; }
    public decimal Total { get; init; }
}

Now, forgetting to initialize a required property is a compile-time error, not a production bug.

Correct usage is obvious:

var dto = new OrderDto
{
    Id = 42,
    Customer = "Contoso",
    Total = 289.50m
};

This single change eliminates entire classes of null checks downstream.

5.3.2 Simplifying Dependency Injection with Primary Constructors

Legacy DI-heavy classes tend to be verbose and repetitive:

public class OrderService
{
    private readonly IRepository _repository;
    private readonly ILogger _logger;

    public OrderService(IRepository repository, ILogger logger)
    {
        _repository = repository;
        _logger = logger;
    }
}

C# 12 introduces primary constructors, which reduce noise and make dependencies impossible to miss:

public class OrderService(IRepository repository, ILogger logger)
{
    private readonly IRepository _repository = repository;
    private readonly ILogger _logger = logger;
}

Less boilerplate means fewer places for null assumptions to hide—and clearer intent for readers and tools alike.

5.3.3 Making Invalid States Unrepresentable

When you combine:

  • Nullable reference types
  • required properties
  • Primary constructors

You end up with objects that are always valid by construction.

For example:

public class PricingConfig(string region)
{
    public required decimal TaxRate { get; init; }
    public string Region { get; } = region;
}

This configuration object cannot exist without the data it needs. Downstream code no longer has to guess—or defensively check for nulls that should never occur.


6 Phase IV: Architecture and Resilience

Up to this point, modernization has focused on safety at the code level: type correctness, async behavior, nullability, and predictable execution. Those changes stabilize the internals of LegacyOrderProcessor.

What remains are the structural issues that make legacy systems hard to evolve over time:

  • Hidden dependencies
  • Global state
  • Implicit behavior at system boundaries
  • Fragile assumptions about external systems

This phase addresses architecture and resilience—not by rewriting everything, but by isolating risk and making dependencies explicit. The result is a system that is easier to test, easier to reason about, and far more tolerant of real-world failures.

6.1 Breaking the Grip of Static Dependencies

Static utilities were common when LegacyOrderProcessor was first written. They avoided constructor plumbing and made code feel simple. But that simplicity came at a cost.

Static dependencies:

  • Hide what a class actually depends on
  • Prevent mocking
  • Force tests to touch shared or global resources
  • Make failures harder to observe and control

A concrete example from the legacy code was a static logger:

Logger.Write("Order processed");

Every service relied on it. Testing anything that logged meant writing to production-style logs—or not testing logging behavior at all.

6.1.1 Introducing an Adapter (Without Breaking Anything)

The goal isn’t to rip out static code immediately. The goal is to contain it.

Start by introducing a small abstraction:

public interface ILegacyLogger
{
    void Write(string message);
}

public class LegacyLoggerAdapter : ILegacyLogger
{
    public void Write(string message) => Logger.Write(message);
}

Now, instead of calling the static logger directly, services depend on the interface:

public class AuditService(ILegacyLogger logger)
{
    private readonly ILegacyLogger _logger = logger;

    public void Audit(string record)
    {
        _logger.Write(record);
    }
}

Nothing breaks. The static logger still exists. But new code no longer depends on it directly.

6.1.2 Gradually Moving to Modern Logging

Once the dependency is abstracted, swapping implementations becomes trivial. Eventually, the adapter can be replaced with native structured logging:

public class AuditService(ILogger<AuditService> logger)
{
    private readonly ILogger<AuditService> _logger = logger;

    public void Audit(string record)
    {
        _logger.LogInformation("Audit entry: {Record}", record);
    }
}

This transition:

  • Removes global state
  • Enables structured, queryable logs
  • Makes logging testable
  • Aligns with modern .NET diagnostics

And it happens incrementally—without a risky, system-wide rewrite.

6.1.3 Testing Without Static Side Effects

Once static calls are hidden behind interfaces, testing becomes straightforward:

public class TestLogger : ILegacyLogger
{
    public List<string> Messages { get; } = [];

    public void Write(string message) => Messages.Add(message);
}

Tests can now assert behavior instead of ignoring it. Logging becomes observable instead of incidental.

6.2 Adding Resilience with Polly

Legacy systems often assume that databases, APIs, and networks are always available. In reality, failures are common—and usually transient.

Now that LegacyOrderProcessor is fully async, it’s finally ready for resilience patterns:

  • Retries
  • Timeouts
  • Circuit breakers

These patterns prevent small failures from cascading into system-wide outages.

6.2.1 Adding Simple Retry Logic

Using Polly, a basic retry policy looks like this:

var policy = Policy
    .Handle<SqlException>()
    .WaitAndRetryAsync(
        3,
        attempt => TimeSpan.FromMilliseconds(200 * attempt)
    );

var order = await policy.ExecuteAsync(() => LoadOrderAsync(id, ct));

This alone significantly improves reliability during brief database hiccups or network glitches.

6.2.2 Using .NET 8 Resilience Pipelines for HTTP

.NET 8 integrates Polly directly into HTTP client configuration through Microsoft.Extensions.Http.Resilience.

A resilient HTTP client can be configured declaratively:

services.AddHttpClient("OrdersApi")
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.MaxRetryAttempts = 3;
        options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
    });

Now, any service that consumes this client automatically benefits from retries and circuit breaking:

var client = _clientFactory.CreateClient("OrdersApi");
var response = await client.GetAsync($"/orders/{orderId}", ct);

Resilience is enforced at the boundary—not reimplemented ad hoc throughout the codebase.

6.2.3 Protecting the Database with Circuit Breakers

For database access, Polly still plays an important role:

private static readonly IAsyncPolicy RetryPolicy =
    Policy.Handle<SqlException>()
          .CircuitBreakerAsync(5, TimeSpan.FromSeconds(10));

public async Task<Order> SafeLoadAsync(int id, CancellationToken ct)
{
    return await RetryPolicy.ExecuteAsync(() => LoadAsync(id, ct));
}

When failures spike, the circuit opens. The system stops hammering a failing dependency and recovers faster once it becomes healthy again.

This is how modern systems fail responsibly.

6.3 Making Data Immutable with Records

Legacy DTOs in LegacyOrderProcessor often had public setters and mutable collections. Over time, this led to unpredictable state changes as objects flowed through the system.

A typical legacy DTO looked like this:

public class OrderDto
{
    public int Id { get; set; }
    public string Customer { get; set; }
    public decimal Total { get; set; }
}

This object could be partially initialized, modified mid-flight, or reused incorrectly.

6.3.1 Refactoring DTOs into Records

Modern C# records make immutability the default:

public record OrderDto(int Id, string Customer, decimal Total);

Records provide:

  • Immutable state
  • Value-based equality
  • Clear construction semantics

Tests become simpler and more expressive:

Assert.Equal(expectedOrder, actualOrder);

Concurrency issues caused by accidental mutation disappear entirely from this layer.

6.3.2 Handling Optional Fields Explicitly

When optional data is truly optional, model it directly:

public record OrderDto(
    int Id,
    string Customer,
    decimal Total,
    string? Notes = null
);

This aligns perfectly with nullable reference types, JSON serialization, and the null-safety guarantees introduced earlier.


7 Phase V: High-Performance Optimization (The Modern Edge)

At this point, LegacyOrderProcessor is stable, testable, and resilient. The architecture is clear, async behavior is predictable, and data flows through well-defined boundaries.

Only now does performance optimization make sense.

This phase is not about chasing micro-benchmarks or rewriting everything for speed. It’s about identifying real hot paths—through profiling—and applying modern .NET features where they deliver meaningful gains without sacrificing readability or safety.

Think of this phase as sharpening the edge, not rebuilding the blade.

7.1 Reducing Allocations with Span<T>

One of the most common performance issues in legacy code is unnecessary memory allocation—especially around string manipulation. Methods like Split, Substring, and repeated concatenation create new objects every time they’re called. In high-volume workflows, those small allocations add up quickly and increase GC pressure.

In LegacyOrderProcessor, this showed up when parsing inbound order records.

Legacy code looked like this:

var parts = line.Split(',');
var sku = parts[0];
var qty = int.Parse(parts[1]);

This allocates:

  • A string array
  • Multiple substrings

That’s fine at low volume—but expensive at scale.

7.1.1 Parsing Without Allocations Using Span<T>

With Span<char>, the same logic can work directly on slices of the original string:

ReadOnlySpan<char> span = line.AsSpan();

var firstComma = span.IndexOf(',');
var sku = span[..firstComma];

var secondComma = span[(firstComma + 1)..].IndexOf(',');
var qtySpan = span[(firstComma + 1)..(firstComma + 1 + secondComma)];

var qty = int.Parse(qtySpan);

Nothing new is allocated. Each slice is just a view over existing memory.

For workloads like processing thousands of order rows or log entries, this change alone can dramatically reduce GC pressure and smooth out latency spikes.

7.1.2 When to Reach for Span<T>

Span<T> is most effective when:

  • You’re in a hot path
  • Data is already in memory
  • You want to avoid temporary allocations

It’s not something you sprinkle everywhere—but when used intentionally, it’s one of the most powerful performance tools in modern .NET.

7.2 Async Pipelines and Memory<T>

Span<T> works only within synchronous code. When data flows through async pipelines—streams, sockets, file I/O—you need something similar that supports awaiting.

That’s where Memory<T> fits.

In LegacyOrderProcessor, asynchronous stream processing benefited from this pattern:

public async Task<int> ReadAsync(Memory<byte> buffer, CancellationToken ct)
{
    return await _stream.ReadAsync(buffer, ct);
}

Compared to raw byte arrays, Memory<T>:

  • Avoids unnecessary copying
  • Works naturally with async APIs
  • Integrates with modern pipelines and pooling strategies

For high-throughput scenarios, this reduces allocation churn and improves overall system stability.

7.3 High-Performance JSON with System.Text.Json

Legacy systems often rely on Newtonsoft.Json or even XML serializers. While flexible, these serializers depend heavily on reflection and runtime type inspection. That adds overhead on every call and makes trimming or AOT scenarios difficult.

Modern .NET favors System.Text.Json, especially when paired with source generation.

7.3.1 Introducing Source-Generated Serialization

For DTOs like those used by LegacyOrderProcessor, start by defining a serializer context:

[JsonSerializable(typeof(OrderDto))]
[JsonSourceGenerationOptions(WriteIndented = false)]
public partial class OrderJsonContext : JsonSerializerContext;

Serialization then becomes:

var json = JsonSerializer.Serialize(
    order,
    OrderJsonContext.Default.OrderDto
);

The serializer now uses pre-generated metadata instead of reflection.

7.3.2 Why This Matters

Source-generated serialization:

  • Eliminates runtime reflection
  • Improves startup time
  • Reduces CPU usage
  • Works with trimming and AOT

For systems that emit or consume JSON at scale—APIs, background processors, event publishers—this creates a noticeable and reliable performance improvement.

In the legacy processor, order summary generation became both faster and more predictable under load.

7.3.3 Nested DTOs Work Naturally

Source generators handle nested records without additional configuration:

public record OrderSummaryDto(
    int Id,
    decimal Total,
    IReadOnlyList<OrderLineDto> Lines
);

The generator understands the full object graph. No custom converters. No manual plumbing. Just efficient serialization using strongly typed metadata.

Below is a refined and aligned rewrite of Section 8, written as a natural closing to the LegacyOrderProcessor journey. The tone is reflective but practical, the metaphor is grounded in engineering reality, and the conclusion reinforces why this approach works—not just what was done.


8 Conclusion: The Ship of Theseus, Rebuilt in Production

By this point, LegacyOrderProcessor has been transformed across every dimension that matters: type safety, async correctness, architectural boundaries, resilience, and performance.

No single change rewrote the system. No “big bang” moment ever occurred. Instead, each phase replaced a small, well-understood piece while the system continued to run, ship, and generate value. Over time, nearly every original component was swapped out—but never all at once.

This is the Ship of Theseus in practice. The system is undeniably modern, yet it retains the knowledge, behavior, and guarantees that were earned over years of production use.

And crucially: every step was observable, testable, and reversible.

8.1 A Practical Refactoring Checklist

One of the most effective ways teams sustain progress is by treating modernization as a habit rather than a project. Whenever a legacy file is touched, the same questions are asked.

A simple checklist keeps that discipline:

  1. Add or update characterization tests (preferably snapshot-based).
  2. Enable nullable reference types for the file.
  3. Replace ArrayList, Hashtable, and untyped collections.
  4. Remove async void and fix sync-over-async calls.
  5. Introduce cancellation tokens where work can be abandoned.
  6. Isolate static dependencies behind interfaces or adapters.
  7. Convert DTOs to immutable records when possible.
  8. Replace casting and branching with pattern matching.
  9. Add resilience policies around external calls.
  10. Look for targeted performance wins (spans, source generators, collection expressions).

Not every item applies every time—and that’s fine. The value comes from asking the questions consistently. Over months, the codebase trends steadily toward clarity instead of entropy.

8.2 Guardrails That Prevent Backsliding

Modernization only sticks if the system enforces it.

Continuous integration should act as a set of guardrails, not as a punishment mechanism. Tools like Roslyn analyzers, Roslynator, and SonarQube help catch regressions early:

  • New async void methods
  • Untyped collections
  • Nullable violations
  • Dangerous APIs creeping back in

A typical baseline configuration looks like this:

<AnalysisLevel>latest</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>

Combined with targeted suppressions where justified, this ensures that new code doesn’t undo months of careful refactoring.

Some teams also add checks for:

  • Missing resilience configuration
  • Unsafe async usage
  • Performance regressions in hot paths

The goal isn’t to slow developers down—it’s to make the safe path the easy path.

8.3 What You Gain in the End

After modernization, the biggest win is not prettier code.

It’s confidence.

  • Changes are easier to make because behavior is covered by tests.
  • Failures are visible, logged, and recoverable.
  • Performance is predictable under load.
  • Dependencies are explicit and mockable.
  • New developers understand the system faster.
  • Features ship faster because fear is gone.

Teams that complete this journey often notice something unexpected: work that once took weeks now takes days. Sometimes hours.

The technical debt that quietly taxed every change is no longer there. What remains is a system aligned with modern .NET practices—robust, maintainable, and ready to evolve.

Advertisement