Non-destructive mutation

Mutating shared domain objects is a frequent source of subtle production bugs. This article explains an incident where an in-place mutator (CalculateMetrics) caused state resets across containers, why non-destructive calculations fix the problem, and how to apply Microsoft Learn–recommended patterns to avoid similar issues.

What this article covers:

βœ… Why shared mutable state leads to surprising bugs

βœ… How to implement non-destructive (copy-on-write) calculation patterns

βœ… Practical trade-offs, tests, and rollout guidance aligned with Microsoft Learn

πŸ› The Symptom: Unexpected "Resets" in Cached/Shared Objects

Calling BuildFullContainer on a linked container sometimes produced results that looked like other containers or cached objects had been reset: Value or Component.Value became 0. The root cause was an in-place mutation of objects that were shared across the application.

The system had two builder components (for example, ContainerBuilder and LinkedContainerBuilder) that invoke each other during batch/container construction.

Both builders were registered as Transient in the dependency injection container. Although Transient registration creates a new builder instance per resolve, it does not prevent domain objects returned from repositories or caches from being shared between resolves.

As a result, one builder's call to CalculateMetrics could mutate objects that the other builder (or a cached consumer) also referenced, producing the observed "reset" behavior.

Key Observations

  • CalculateMetrics modified an existing Specification instance and its nested Component objects in-place.
  • Those instances are sometimes shared or cached (for example, returned from a repository or stored by the ContainerBuilder).
  • In-place changes are observable from every reference, causing surprising downstream behavior.

πŸ” Root Cause: Shared Mutable State

When code writes to fields or properties on objects that multiple components reference, those writes become global side effects.

The CalculateMetrics implementation previously did exactly that:

  • It set specification.Value and c.Value directly on objects that could be referenced elsewhere.
  • Another flow that expected the original values later observed the changed (or zeroed) values, producing the appearance of a reset.

This is a classic shared mutable state problem.

Microsoft Learn and the .NET design guidelines recommend minimizing shared mutable state and preferring immutable or defensive-copy patterns for objects that may be reused or cached. See the .NET design guidelines for collections and the immutable collections documentation for details.

βœ… The Fix: Return Computed Copies (Non-Destructive APIs)

Rather than mutating the input Specification and its Components, we make CalculateMetrics return a new Specification instance with computed Value and new Component instances whose Value values reflect the computation.

The caller replaces the original specification with the computed copy in the container.

Benefits

  • The original object remains unchanged, so caches and other consumers are not affected.
  • The side effects are isolated to the new object, making reasoning and testing easier.

πŸ”„ Before and After (Simplified Examples)

Mutating (Problematic)

// MUTATING version (problematic)
public class Specification
{
    public string Name { get; set; }
    public double Value { get; set; }
    public List<Component> Components { get; set; }

    public void CalculateMetrics(double target)
    {
        Value = ComputeValue(target);
        foreach (var c in Components)
        {
            c.Value = ComputeComponentValue(c, Value);
        }
    }
}

public class Component
{
    public string Type { get; set; }
    public double Value { get; set; }
}
// NON-DESTRUCTIVE version (recommended)
public record Component(string Type, double Value);

// record gives 'with' expression support for free
public record Specification(string Name, double Value, IReadOnlyList<Component> Components)
{
    public Specification WithCalculatedMetrics(double target)
    {
        var computedValue = ComputeValue(target);

        var computedComponents = Components
            .Select(c => new Component(c.Type, ComputeComponentValue(c, computedValue)))
            .ToList();

        // 'with' creates a new record instance β€” the original is never touched
        return this with
        {
            Value = computedValue,
            Components = computedComponents
        };
    }
}

Caller Usage

var calculated = originalSpec.WithCalculatedMetrics(container.TargetValue);
container.ReplaceSpecification(originalSpec, calculated);

Notes

  • Declaring Specification as a record unlocks the with expression β€” the idiomatic C# feature for non-destructive mutation. Unspecified properties are copied automatically; you only name what changes.
  • with produces a shallow copy: reference-type properties (like IReadOnlyList<Component>) copy the reference, not the underlying data. Because Component is itself an immutable record, this is safe here.
  • IReadOnlyList<T> signals mutation intent to callers but does not enforce it β€” the underlying list can still be cast back to List<T> and mutated. For strict enforcement use ImmutableArray<T> or ImmutableList<T> from System.Collections.Immutable.

Impact

  • Safer caches: cached objects are no longer mutated by callers.
  • Easier reasoning: side effects are isolated to returned objects.
  • Improved testability: tests can assert that original instances remain unchanged.

⚑ Tradeoffs and Performance

Non-destructive approaches allocate new objects, which may increase allocations. In many business scenarios this cost is negligible compared to the correctness and maintainability gains. If you identify a hot path:

  • Benchmark the change (BenchmarkDotNet, realistic workloads).
  • Consider pooling or reusing buffer objects (only after profiling).
  • Use delta/diff objects to return only what changed.

Microsoft's guidance is pragmatic: prefer immutability for shared data; when copies are required, measure and optimize only after identifying real bottlenecks (see Collections and Data Structures and the Immutable Collections docs).

Key Takeaways

  • Prefer non-destructive APIs for domain objects that may be shared or cached.
  • Use immutable types or IReadOnlyList for public models and DTOs.
  • When performance is critical, benchmark and consider pooling or delta updates rather than premature optimization.

πŸ“‹ Additional Best Practices (Aligned with Microsoft Learn)

  1. Favor immutability for shared domain models. Use C# record types and init-only properties to express immutable data shapes: C# records and immutability
  2. Make side effects explicit. Name mutating methods clearly (for example, ApplyX for in-place mutation vs WithX for non-destructive variants) and document the behavior in API docs (see .NET design guidelines).
  3. Expose read-only collections (IReadOnlyList<T>, ReadOnlyCollection<T>) to signal intent, but prefer ImmutableArray<T> or ImmutableList<T> when you need to prevent casting back to a mutable type. See Guidelines for Collections and Immutable Collections.
  4. Prefer System.Collections.Immutable or concurrent collections when multiple threads/components share data: Collections and Data Structures
  5. Add unit tests that assert original instances remain unchanged after non-destructive operations; add integration tests for caching and builder scenarios.

These recommendations map to the official Microsoft Learn guidance on API design, collections, and immutability.

πŸ§ͺ Testing and Rollout Strategy

  • Add unit tests asserting that the original Specification and its Components are unchanged after the calculation.
  • Add integration tests for container building, caching, and linked-container scenarios.
  • Roll out the change behind feature flags if the system has many consumers, and monitor for regressions.

🏁 Conclusion

Converting mutators to non-destructive implementations prevents subtle bugs caused by shared mutation, simplifies reasoning about state, and aligns with Microsoft-recommended design practices.

πŸ“š Further Reading


See you next time on www.devskillsunlock.com