Deeply-immutable objects have the advantage that deep-cloning something simply requires copying a reference. They have the disadvantage that making even a small change to a deeply-nested object requires constructing a new instance of every object within which it is nested. Mutable objects have the advantage that changing an object is easy--just do it--but deep-cloning an object requires constructing a new object which contains a deep clone of every nested object. Worse, if one wants to clone an object and make a change, clone that object, make another change, etc. then no matter how big or small the changes are one has to keep a copy of the entire hierarchy for every saved version of the object's state. Nasty.
An approach that might be worth considering would be to define an abstract "maybeMutable" type with mutable and deeply-immutable derivative types. All such types would feature an
AsImmutable method; calling that method on a deeply-immutable instance of an object would simply return that instance. Calling it on a mutable instance would return a deeply-immutable instance whose properties were deeply-immutable snapshots of their equivalents in the original. Immutable types with mutable equivalents would sport an
AsMutable method, which would construct a mutable instance whose properties matched those of the original.
Changing a nested object in a deeply-immutable object would require first replacing the outer immutable object with a mutable one, then replacing the property containing the thing to be changed with a mutable one, etc. but making repeated changes to the same aspect of the overall object would not require making any additional objects until such time as an attempt was made to call
AsImmutable on a mutable object (which would leave the mutable objects mutable, but return immutable objects holding the same data).
As simple but significant optimizations, each mutable object could hold a cached reference to an object of its associated immutable type, and each immutable type should cache its
GetHashCode value. When calling
AsImmutable on a mutable object, before returning a new immutable object, check it would match the cached reference. If so, return the cached reference (abandoning the new immutable object). Otherwise update the cached reference to hold the new object and return that. If this is done, repeated calls to
AsImmutable without any intervening mutations will yield the same object references. Even if one doesn't save the cost of constructing the new instances, one will avoid the memory cost of keeping them. Further, equality comparisons between the immutable objects may be greatly expedited if in most cases the items being compared are reference-equal or have different hash codes.