Unlock Non-Destructive Mutation: Stop Mutating Shared Domain Objects

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 (Recommended)
// 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
Specificationas arecordunlocks thewithexpression β the idiomatic C# feature for non-destructive mutation. Unspecified properties are copied automatically; you only name what changes. withproduces a shallow copy: reference-type properties (likeIReadOnlyList<Component>) copy the reference, not the underlying data. BecauseComponentis 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 toList<T>and mutated. For strict enforcement useImmutableArray<T>orImmutableList<T>fromSystem.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)
- Favor immutability for shared domain models. Use C# record types and init-only properties to express immutable data shapes: C# records and immutability
- 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).
- Expose read-only collections (
IReadOnlyList<T>,ReadOnlyCollection<T>) to signal intent, but preferImmutableArray<T>orImmutableList<T>when you need to prevent casting back to a mutable type. See Guidelines for Collections and Immutable Collections. - Prefer System.Collections.Immutable or concurrent collections when multiple threads/components share data: Collections and Data Structures
- 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
- C#
withExpression β Nondestructive Mutation - C# Records and Immutability
- .NET Design Guidelines for Collections
- Collections and Data Structures
- IReadOnlyList<T> and Read-Only Collection Types
- Immutable Collections Library (System.Collections.Immutable)
- Code Analysis Rule CA2227 β Collection Properties Should Be Read Only
See you next time on www.devskillsunlock.com
