Skip to content

Refactoring in .NET Systems

Safe, incremental improvement of existing code in real production systems

Refactoring is one of those topics that sounds simple in books but becomes very serious in production.

In a real system, especially a WPF desktop application controlling hardware, refactoring is not about making code look prettier. It is about reducing operational risk over time. Bad structure does not stay contained inside code files. It turns into slower feature delivery, fragile releases, harder debugging, more production incidents, and eventually loss of team confidence.

In large, long-lived systems, refactoring is not optional. It is part of maintenance.


PART 1 — BIG PICTURE

What refactoring really is

Refactoring means:

Changing the internal structure of code without changing its observable behavior.

That definition matters.

Refactoring is not:

  • adding a new feature
  • changing business rules
  • redesigning the product
  • migrating to a new framework just because the old one feels messy
  • rewriting the system from scratch

Refactoring is about improving the shape of the code so the next changes become safer and easier.

Simple example

Suppose you have machine start logic inside a WPF button click handler:

csharp
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
    if (_machine == null)
    {
        MessageBox.Show("Machine is not connected.");
        return;
    }

    if (!_machine.IsReady)
    {
        MessageBox.Show("Machine is not ready.");
        return;
    }

    StartButton.IsEnabled = false;
    StatusText.Text = "Starting...";

    await _machine.InitializeAsync();
    await _machine.LoadRecipeAsync(_selectedRecipe);
    await _machine.StartInspectionAsync();

    StatusText.Text = "Running";
    StartButton.IsEnabled = true;
}

If you move validation and workflow steps into better-named methods or services, but the system behaves the same to the user and machine, that is refactoring.

If you change how inspection startup works from a business perspective, that is no longer pure refactoring. That is a behavioral change.


Why rewriting systems is usually a bad idea

Senior engineers eventually learn this the hard way:

Most rewrites are underestimated.

A rewrite sounds attractive because the current code feels painful. People imagine a fresh architecture, cleaner abstractions, no legacy baggage. But real systems contain years of hidden knowledge:

  • edge cases
  • workarounds for vendor SDK issues
  • timing quirks
  • customer-specific behaviors
  • operational assumptions
  • recovery logic that nobody documented

The old system may look ugly but still encode critical survival knowledge.

Why rewrites fail

A rewrite often fails because:

  • the team does not fully understand current behavior
  • hidden dependencies are rediscovered too late
  • feature work pauses or slows down
  • the new system takes longer than expected
  • bugs reappear because subtle production behavior was lost
  • users do not care that the internals are cleaner if stability drops

In industrial or machine-control software, this risk is even higher. A rewrite can accidentally break:

  • machine sequencing
  • hardware timing assumptions
  • retry behavior
  • UI responsiveness
  • state recovery after communication failure

That is why experienced engineers usually prefer:

incremental modernization over heroic rewrites


Why continuous refactoring is necessary in long-lived systems

Long-lived systems do not decay because engineers are careless. They decay because reality keeps changing.

Every new requirement adds pressure:

  • one more machine mode
  • one more inspection result type
  • one more customer workflow
  • one more retry case
  • one more UI exception path
  • one more integration with hardware or storage

Even reasonable code becomes messy after enough changes.

If refactoring is postponed for too long:

  • features take longer to add
  • bugs become harder to isolate
  • the team becomes afraid to touch certain areas
  • code review quality drops because nobody can reason about the whole change
  • onboarding becomes painful
  • small issues become architectural bottlenecks

Real examples

Machine control logic

At first, machine commands may be simple: connect, home, start, stop. Later you add pause, emergency recovery, recalibration, reconnect, partial restart, maintenance mode. If the structure never improves, logic becomes a tangled chain of conditions.

Inspection workflows

What started as a linear sequence becomes a branching workflow with retries, image processing, result persistence, operator intervention, and error recovery. If workflow code remains in one huge method, every new change becomes dangerous.

UI + background processing

A ViewModel might begin as a simple presentation model. Over time it starts doing:

  • timer polling
  • state management
  • command orchestration
  • event subscription
  • file save logic
  • hardware calls
  • thread marshaling

Eventually it becomes the system’s accidental god object.

Refactoring is how you prevent that drift from becoming permanent.


PART 2 — IDENTIFYING CODE SMELLS

Code smells are not proof that code is wrong. They are warning signs that the structure may be making change harder than it should be.

The key is not to memorize smell names. The key is to ask:

Why is this code hard to understand, hard to test, or risky to change?

1. Large methods

Large methods are dangerous because they hide multiple responsibilities and make reasoning difficult.

Example signs

  • method is hundreds of lines long
  • local variables keep accumulating
  • many if/else branches
  • UI updates mixed with business logic
  • validation, orchestration, and persistence all in one place
csharp
public async Task RunInspectionAsync()
{
    // validate
    // update UI
    // log
    // start machine
    // acquire images
    // process results
    // save files
    // update database
    // publish events
    // handle retries
    // show errors
}

The problem is not just size. The problem is that nobody can safely change one part without worrying about the rest.


2. Duplicated logic

Duplication is one of the most expensive forms of technical debt because the same bug or rule change must be fixed in multiple places.

Example

You may have machine readiness checks copied into:

  • Start button command
  • Resume command
  • Auto-run workflow
  • Maintenance screen
  • Recipe preview window

Each one slowly drifts.

csharp
if (_machine != null && _machine.IsConnected && _machine.IsReady && !_machine.HasAlarm)
{
    ...
}

Later one location checks HasAlarm, another checks CurrentMode, another forgets to check recipe validity. Now behavior is inconsistent.


3. Unclear naming

If names are vague, people cannot build a correct mental model.

Bad names create false confidence. Code looks readable syntactically, but the reader does not actually know what it means.

Bad examples

csharp
var data = GetData();
var result = Process(data);
var manager = new Manager();
var handler = new Handler();
bool flag = true;

Better

csharp
var acquiredImage = AcquireInspectionImage();
var defectSummary = AnalyzeDefects(acquiredImage);
var inspectionWorkflowCoordinator = new InspectionWorkflowCoordinator();
bool isMachineReadyForStart = true;

Clear names reduce the need for comments because they expose intent.


4. Mixed responsibilities

One class or method is doing multiple unrelated jobs.

Example

A ViewModel that:

  • talks directly to camera SDK
  • decides workflow transitions
  • updates chart data
  • writes result files
  • logs performance metrics
  • shows error dialogs

That is too much.

When responsibilities are mixed, any change causes unintended coupling. A UI change may affect workflow behavior. A hardware change may force ViewModel edits. A persistence bug may be fixed in presentation code.


5. Tight coupling

Tight coupling means code is too dependent on specific implementations, timing, global state, or call order.

Example

csharp
public class InspectionViewModel
{
    private readonly CameraSdk _cameraSdk;
    private readonly PlcController _plc;
    private readonly SqlRepository _repository;
}

The ViewModel now depends directly on hardware and persistence details. That makes testing harder and forces changes to spread across layers.

Tight coupling often appears as:

  • direct construction of dependencies with new
  • direct SDK usage everywhere
  • shared mutable state
  • hidden dependencies via static classes or service locators
  • code that only works if called in a very specific sequence

6. Deeply nested logic

Nested logic makes it hard to see the real path of execution.

csharp
if (machine.IsConnected)
{
    if (machine.IsReady)
    {
        if (recipe != null)
        {
            if (!machine.HasAlarm)
            {
                if (user.HasPermission)
                {
                    StartInspection();
                }
            }
        }
    }
}

This code is not only ugly. It hides the actual business rule.

Usually this should be flattened into guard clauses or extracted into meaningful methods.


PART 3 — REAL PROBLEMS IN THIS SYSTEM

Let’s use the concrete case:

A WPF desktop app controlling a wafer inspection machine

This kind of system naturally accumulates refactoring pressure because it combines:

  • UI
  • long-running workflows
  • hardware integration
  • asynchronous execution
  • image/data processing
  • stateful behavior
  • operational recovery logic

Large ViewModels doing too much

This is one of the most common real-world problems.

A ViewModel starts clean, then gradually absorbs everything because it is the easiest place to put logic.

Typical symptoms

  • 2,000+ lines
  • dozens of commands
  • event subscriptions from machine, camera, workflow engine, timers
  • direct manipulation of UI state
  • async operations with error handling
  • persistence logic
  • logging
  • status calculations

At that point the ViewModel is no longer a presentation model. It becomes a hidden application service.

Why this is dangerous

  • hard to test without WPF runtime assumptions
  • high merge conflict rate
  • difficult to reason about threading
  • changes become broad and risky
  • bug fixes often create regressions elsewhere

Workflow logic mixed with UI code

This happens when workflow state transitions are triggered directly from UI commands and UI elements reflect workflow internals too closely.

Example

csharp
private async Task StartInspectionAsync()
{
    IsBusy = true;
    StatusMessage = "Checking machine";

    if (!_machine.IsConnected)
    {
        MessageBox.Show("Disconnected");
        IsBusy = false;
        return;
    }

    StatusMessage = "Loading recipe";
    await _machine.LoadRecipeAsync(SelectedRecipe);

    StatusMessage = "Starting scan";
    await _machine.StartScanAsync();

    StatusMessage = "Processing result";
    var result = await _processor.ProcessAsync();

    Results.Add(result);
    IsBusy = false;
}

This looks manageable at first, but over time:

  • pause/resume gets added
  • partial failure handling gets added
  • cancellation gets added
  • operator acknowledgment gets added
  • alternate workflows get added

Now UI code contains orchestration logic. That makes the system fragile.


Direct calls to hardware SDK everywhere

This is a classic legacy shape.

Instead of a controlled machine abstraction, direct SDK calls spread through:

  • ViewModels
  • services
  • utility classes
  • button handlers
  • event callbacks

Consequences

  • impossible to simulate behavior reliably
  • hard to standardize error handling
  • retry logic becomes inconsistent
  • vendor SDK changes cause widespread edits
  • machine behavior rules are duplicated

Even worse, different parts of the system may use the SDK differently, creating subtle inconsistencies.


Difficult-to-follow async workflows

In real systems, async logic often becomes hard to follow because it grows organically.

Symptoms

  • async void beyond UI event handlers
  • fire-and-forget tasks
  • missing cancellation
  • shared mutable state accessed across awaits
  • event-driven callbacks mixed with awaited flows
  • UI thread marshaling scattered everywhere

This makes behavior feel non-deterministic. People stop trusting the code because they cannot easily tell:

  • what runs where
  • what happens after cancellation
  • who owns workflow state
  • when errors are observed
  • whether multiple operations can overlap

Fragile code when adding new features

The strongest sign refactoring is needed is not that code looks ugly.

It is that small feature additions become disproportionately expensive or risky.

Example: Adding “Pause after current wafer completes” should be a normal extension. But in poor structure it requires changes to:

  • UI commands
  • machine polling loop
  • workflow state logic
  • result persistence
  • operator notification
  • hardware callback code

When a small requirement triggers broad edits across many unrelated files, the design is telling you something.


PART 4 — HOW TO REFACTOR SAFELY

This is where senior judgment matters most.

Good refactoring is not brave. Good refactoring is controlled.

1. Make small, incremental changes

Do not redesign the whole system in one go.

Instead:

  • identify one painful area
  • understand current behavior
  • make one small structural improvement
  • verify it
  • stop if risk increases

Good example

Instead of “replace all hardware calls with a new machine abstraction across the entire application,” do this:

  1. Identify one workflow, such as Start Inspection.
  2. Introduce a small interface for just the operations used there.
  3. Move only those calls behind the abstraction.
  4. Keep behavior identical.
  5. Verify.
  6. Expand gradually later.

This is how real systems get better safely.


2. Preserve behavior first

Refactoring should not mix structural cleanup with behavioral redesign unless absolutely necessary.

A useful question is:

Can I improve this code without changing what the user, machine, logs, and outputs observe?

If yes, do that first.

If behavior must change, separate the work mentally and preferably in source control:

  • commit or PR 1: structural refactoring
  • commit or PR 2: behavior change

This makes review and rollback much easier.


3. Use tests as a safety net, if available

Tests are excellent protection, but many legacy desktop/hardware systems do not have enough tests.

That does not mean you cannot refactor. It means you need alternative safety nets:

  • characterization tests
  • integration tests for critical flows
  • logging comparison
  • manual verification scripts
  • side-by-side output comparison
  • controlled release to field or staging hardware

Characterization tests

When behavior is unclear, write tests that capture current behavior, even if the behavior is ugly.

The goal is not to prove correctness. The goal is to prevent accidental change while restructuring.


4. Verify step by step

Safe refactoring is an engineering loop:

  1. Observe current behavior
  2. Make a small change
  3. Build and run
  4. Verify important scenarios
  5. Review logs / output / UI behavior
  6. Proceed or stop

In machine-integrated systems, verification should include:

  • startup/shutdown
  • reconnect behavior
  • cancellation
  • failure paths
  • timing-sensitive workflows
  • UI responsiveness
  • resource cleanup

PART 5 — COMMON REFACTORING TECHNIQUES

These are simple in concept, but powerful in production when used carefully.


1. Extract Method

Use when a method is too long or hides intent.

Before

csharp
public async Task StartInspectionAsync()
{
    if (_machine == null || !_machine.IsConnected)
        throw new InvalidOperationException("Machine not connected.");

    if (_selectedRecipe == null)
        throw new InvalidOperationException("No recipe selected.");

    _logger.LogInformation("Loading recipe {RecipeName}", _selectedRecipe.Name);
    await _machine.LoadRecipeAsync(_selectedRecipe);

    _logger.LogInformation("Starting inspection");
    await _machine.StartInspectionAsync();

    Status = "Inspection started";
}

After

csharp
public async Task StartInspectionAsync()
{
    EnsureMachineConnected();
    EnsureRecipeSelected();
    await LoadSelectedRecipeAsync();
    await StartMachineInspectionAsync();
    Status = "Inspection started";
}

private void EnsureMachineConnected()
{
    if (_machine == null || !_machine.IsConnected)
        throw new InvalidOperationException("Machine not connected.");
}

private void EnsureRecipeSelected()
{
    if (_selectedRecipe == null)
        throw new InvalidOperationException("No recipe selected.");
}

private async Task LoadSelectedRecipeAsync()
{
    _logger.LogInformation("Loading recipe {RecipeName}", _selectedRecipe.Name);
    await _machine.LoadRecipeAsync(_selectedRecipe);
}

private async Task StartMachineInspectionAsync()
{
    _logger.LogInformation("Starting inspection");
    await _machine.StartInspectionAsync();
}

Why it helps

  • exposes intent
  • makes review easier
  • creates refactoring stepping stones for later extraction

2. Extract Class / Service

Use when one class has too many responsibilities.

Before

csharp
public class InspectionViewModel
{
    public async Task StartAsync()
    {
        // validate
        // machine calls
        // save files
        // update result collection
        // logging
        // notification
    }
}

After

csharp
public class InspectionViewModel
{
    private readonly InspectionWorkflowService _workflowService;

    public InspectionViewModel(InspectionWorkflowService workflowService)
    {
        _workflowService = workflowService;
    }

    public async Task StartAsync()
    {
        await _workflowService.StartInspectionAsync();
    }
}
csharp
public class InspectionWorkflowService
{
    public async Task StartInspectionAsync()
    {
        // workflow orchestration moved here
    }
}

Why it helps

  • keeps UI layer thinner
  • enables testing outside WPF
  • separates orchestration from presentation

3. Introduce Interface

Use when code depends too directly on concrete implementations, especially vendor SDKs or system dependencies.

Before

csharp
public class InspectionWorkflowService
{
    private readonly VendorMachineSdk _sdk;

    public InspectionWorkflowService(VendorMachineSdk sdk)
    {
        _sdk = sdk;
    }
}

After

csharp
public interface IMachineController
{
    Task LoadRecipeAsync(Recipe recipe);
    Task StartInspectionAsync();
    bool IsConnected { get; }
}
csharp
public class VendorMachineController : IMachineController
{
    private readonly VendorMachineSdk _sdk;

    public VendorMachineController(VendorMachineSdk sdk)
    {
        _sdk = sdk;
    }

    public bool IsConnected => _sdk.IsConnected;

    public Task LoadRecipeAsync(Recipe recipe) => _sdk.LoadRecipeAsync(recipe);
    public Task StartInspectionAsync() => _sdk.StartInspectionAsync();
}

Why it helps

  • reduces coupling
  • centralizes SDK-specific behavior
  • improves testability
  • makes future vendor changes less painful

Important point: do not create interfaces everywhere by habit. Introduce them where they reduce real coupling or improve seams for change.


4. Replace conditional logic with polymorphism

Use when condition-heavy branching represents stable variants of behavior.

Before

csharp
public class ResultExporter
{
    public void Export(InspectionResult result, string format)
    {
        if (format == "csv")
        {
            // export CSV
        }
        else if (format == "json")
        {
            // export JSON
        }
        else if (format == "xml")
        {
            // export XML
        }
    }
}

After

csharp
public interface IResultExporter
{
    void Export(InspectionResult result);
}
csharp
public class CsvResultExporter : IResultExporter
{
    public void Export(InspectionResult result)
    {
        // export CSV
    }
}

public class JsonResultExporter : IResultExporter
{
    public void Export(InspectionResult result)
    {
        // export JSON
    }
}

Why it helps

  • isolates variation
  • avoids growing if/else chains
  • easier to extend safely

But this only helps when the branching reflects meaningful behavior variants. Do not over-engineer simple decisions.


5. Simplify complex conditions

Before

csharp
if (_machine != null &&
    _machine.IsConnected &&
    _machine.IsReady &&
    !_machine.HasAlarm &&
    _currentRecipe != null &&
    _currentRecipe.IsValidated &&
    !_isBusy)
{
    StartInspection();
}

After

csharp
if (CanStartInspection())
{
    StartInspection();
}

private bool CanStartInspection()
{
    return _machine != null &&
           _machine.IsConnected &&
           _machine.IsReady &&
           !_machine.HasAlarm &&
           _currentRecipe != null &&
           _currentRecipe.IsValidated &&
           !_isBusy;
}

Or better, push this into domain/application language:

csharp
private bool CanStartInspection()
{
    return _machineStateEvaluator.IsReadyForInspection(_machine, _currentRecipe, _isBusy);
}

Why it helps

  • improves readability
  • centralizes rules
  • reduces duplication

6. Rename for clarity

This is one of the cheapest and highest-value refactorings.

Before

csharp
public async Task DoWorkAsync()
{
    var data = await GetDataAsync();
    var result = await ProcessAsync(data);
    Save(result);
}

After

csharp
public async Task ExecuteInspectionResultProcessingAsync()
{
    var rawInspectionData = await AcquireInspectionDataAsync();
    var processedInspectionResult = await AnalyzeInspectionDataAsync(rawInspectionData);
    SaveInspectionResult(processedInspectionResult);
}

Good names reduce cognitive load immediately.


PART 6 — REFACTORING ASYNC & CONCURRENT CODE

This is where refactoring becomes much riskier.

With synchronous code, the structure is usually easier to follow. With async and concurrency, refactoring can accidentally change timing, sequencing, or thread interactions even when the code looks equivalent.

Risks when refactoring async workflows

1. Changing execution order

Moving awaits around can change behavior.

csharp
await SaveResultAsync();
await PublishEventAsync();

This is not the same as:

csharp
var saveTask = SaveResultAsync();
var publishTask = PublishEventAsync();
await Task.WhenAll(saveTask, publishTask);

The second version introduces concurrency and may break ordering assumptions.


2. Breaking UI thread assumptions

In WPF, some code must run on the UI thread. If you extract methods or move logic into services, you may accidentally update bound collections or properties from a background thread.

That can create:

  • dispatcher exceptions
  • intermittent crashes
  • subtle UI inconsistencies

3. Introducing race conditions

Suppose previously only one inspection could start at a time because code path sequencing accidentally enforced it. A refactor that “cleans up” the structure may remove that implicit protection.

Now two starts overlap under rare timing.

That is the kind of bug that only appears in production.


4. Losing cancellation behavior

Refactoring can accidentally drop CancellationToken flow or stop checking cancellation between steps. Now shutdown becomes slower, pause does not respond, or background tasks continue after the user exits the screen.


How to refactor async code safely

Preserve semantics before improving elegance

Do not introduce parallelism unless that is explicitly intended and verified.

Keep cancellation flowing

If a method accepted a token before, keep passing it through extracted methods.

Be careful with shared mutable state

State like _currentInspection, _isBusy, or ObservableCollection<T> should be treated carefully across awaits.

Add tracing/logging around critical workflow edges

This helps verify that sequencing remains intact after refactoring.

Test timing-sensitive cases

Especially:

  • rapid start/stop
  • cancellation during await
  • reconnect during workflow
  • double-click or repeated command execution
  • shutdown while background work is active

PART 7 — COMMON MISTAKES (VERY REALISTIC)

1. Large-scale rewrites instead of incremental refactoring

This is the most expensive mistake.

A team gets frustrated with legacy code and decides to rebuild a clean version. Months later:

  • feature delivery stalls
  • production bugs in old system still need fixes
  • knowledge splits across two codebases
  • the new design is still incomplete
  • confidence drops

Production consequence

You get maximum risk and delayed value.


2. Refactoring without understanding behavior

Engineers sometimes mistake “I understand the code structure” for “I understand the system behavior.”

In production systems, behavior includes:

  • timing
  • retries
  • state transitions
  • operational expectations
  • failure recovery

Production consequence

You remove something that looked redundant but was actually protecting against a vendor SDK quirk or sequence dependency.


3. Breaking production due to hidden dependencies

Legacy systems often have invisible couplings:

  • event ordering assumptions
  • static caches
  • global flags
  • indirect thread affinity
  • external scripts depending on filenames or log format

Production consequence

A clean-looking refactor breaks downstream tools, operator expectations, or machine recovery.


4. Over-refactoring with no real benefit

Not all ugly code deserves immediate cleanup.

Sometimes engineers spend time:

  • creating extra abstractions
  • splitting simple logic too aggressively
  • introducing patterns for theoretical future flexibility

Production consequence

You increase indirection and review cost without reducing real risk.

Good refactoring should make the code easier to change, not merely more architecturally fashionable.


5. Ignoring performance impact

Some refactorings improve structure but hurt runtime behavior.

Examples:

  • extra allocations in hot image-processing loops
  • excessive abstraction in performance-critical paths
  • more locking
  • added async overhead where simple synchronous code was better
  • breaking batching into many smaller operations

Production consequence

Cleaner code, worse throughput or UI responsiveness.

In industrial systems, that trade-off matters. Refactoring is not exempt from performance responsibility.


PART 8 — TRADE-OFFS

Refactoring is always a trade-off decision.

Speed vs safety

Fast refactoring often means larger change sets and more risk. Safe refactoring often means slower progress through smaller steps.

Senior engineers usually choose safety in critical areas:

  • machine control
  • workflow orchestration
  • concurrency
  • persistence of important inspection data

Code quality vs delivery deadlines

Sometimes a release deadline is real. You cannot refactor everything first.

The right question is:

What minimum structural improvement reduces risk enough for this change?

That is a much better question than:

  • “Should we clean this properly first?”
  • “Should we ignore the mess entirely?”

Often the answer is a small refactor at the seam of the new work.


Refactor now vs later

Refactor now when:

  • you are already touching the code
  • the change is likely to repeat
  • the current structure is causing bugs or confusion
  • future changes will be significantly easier afterward
  • the area is business-critical

Refactor later when:

  • you do not yet understand the behavior
  • the area is stable and rarely touched
  • the benefit is mostly cosmetic
  • delivery risk is too high right now

The best engineers are not the ones who always refactor. They are the ones who choose the right moments.


PART 9 — WORKING IN A TEAM

Refactoring in shared systems is as much a collaboration problem as a coding problem.

How to refactor in shared codebases

Keep changes small and reviewable.

A PR that mixes:

  • renaming
  • moving files
  • changing behavior
  • adding features
  • fixing bugs
  • reformatting everything

is hard to review and easy to break.

Better:

  • one focused refactor
  • clear intent
  • small blast radius
  • visible verification approach

Communicating changes to the team

Say clearly:

  • what problem you are addressing
  • what behavior is supposed to remain unchanged
  • what risks exist
  • how you verified it
  • what future improvements this enables

This helps reviewers evaluate the change correctly.


Avoiding merge conflicts and instability

Large refactors create pain for everyone else:

  • rebasing becomes difficult
  • ongoing features collide
  • blame history becomes noisy
  • release branches get harder to manage

That is why incremental refactoring is not just technically safer. It is socially safer in a team environment.

A good team habit is to refactor near the code you are already changing, not launch broad structural campaigns without coordination.


PART 10 — SENIOR ENGINEER MENTAL MODEL

This is the most important part.

How experienced engineers approach messy code

They do not react emotionally.

They do not say:

  • “This is terrible, we need to rewrite it”
  • “Who wrote this?”
  • “Let’s redesign everything properly”

Instead they ask:

  • What is the real risk here?
  • What behavior must not change?
  • Where is the change friction highest?
  • What is the smallest safe improvement?
  • What future changes does this unlock?

That is a much more mature way to work.


How to improve systems over time without risk

Senior engineers improve systems through controlled accumulation:

  • better naming
  • smaller methods
  • extracted responsibilities
  • clearer boundaries
  • fewer direct dependencies
  • safer async flows
  • targeted tests around critical seams

They know that a healthy codebase is not created in one grand effort. It is built through many good local decisions.


How to choose where to refactor

The best refactoring targets are usually:

  • high-change areas
  • bug-prone areas
  • code people avoid touching
  • code blocking important features
  • code with poor observability
  • complex workflow or concurrency boundaries
  • heavy coupling to hardware or infrastructure

Do not start with the most academically ugly code. Start with the code that creates the most operational pain.


How to balance pragmatism vs ideal design

This is the real senior-level skill.

Ideal design asks:

  • what would the cleanest architecture be?

Pragmatic design asks:

  • what can we improve safely today?
  • what is worth the cost?
  • what does this system actually need?
  • where does cleanliness materially reduce risk?

The best engineers are not careless pragmatists or perfectionist architects.

They are disciplined realists.

They know that:

  • some ugly code is acceptable
  • some elegant code is harmful
  • some cleanup is urgent
  • some cleanup is vanity
  • the right answer depends on risk, change frequency, and system criticality

Final practical takeaway

In real .NET systems, especially WPF + hardware + async workflows, refactoring should be treated as:

a continuous, risk-managed engineering practice that improves changeability without destabilizing production

The mindset is:

  • do not rewrite unless forced
  • improve code where change pressure is high
  • separate structure changes from behavior changes
  • refactor in small steps
  • verify constantly
  • be extra careful with async, state, and hardware boundaries
  • optimize for team understanding, not personal elegance

A good senior engineer does not try to make the codebase perfect.

They make it safer to evolve.

If you want, I can continue with the next topic in the same style: Legacy code in .NET systems or Code review for senior engineers in .NET teams.

Docs-first project memory for AI-assisted implementation.