Skip to content

Instantly share code, notes, and snippets.

@cobysy
Last active March 17, 2026 17:53
Show Gist options
  • Select an option

  • Save cobysy/fde9da758261af410774f4cc4d2dc5d9 to your computer and use it in GitHub Desktop.

Select an option

Save cobysy/fde9da758261af410774f4cc4d2dc5d9 to your computer and use it in GitHub Desktop.
DeepFreeze.cs
public interface IFreezable
{
bool IsFrozen { get; }
void Freeze();
void Unfreeze();
void DeepFreeze();
void DeepUnfreeze();
}
public abstract class FreezableBase : IFreezable
{
// Must be virtual so Castle DynamicProxy can override the getter on the generated proxy
// subclass. Without virtual, the proxy reads its own never-updated backing field while
// Freeze() sets the state on the target object, causing IsFrozen to always return false.
[JsonIgnore]
[Newtonsoft.Json.JsonIgnore]
public virtual bool IsFrozen { get; private set; }
// Must be virtual so Castle DynamicProxy intercepts the call and routes it through
// FreezeInterceptor before forwarding to this implementation on the target object.
public virtual void Freeze()
{
this.IsFrozen = true;
}
public virtual void Unfreeze()
{
this.IsFrozen = false;
}
// Must be virtual so Castle DynamicProxy intercepts the call on the proxy and forwards
// it to this implementation on the target object.
public virtual void DeepFreeze()
{
// ReferenceEqualityComparer ensures that identity (not equality) is used when
// tracking visited objects, which prevents infinite loops in object graphs that
// contain circular references or shared child instances.
var visited = new HashSet<object>(comparer: ReferenceEqualityComparer.Instance);
this.DeepFreeze(visited: visited);
}
// Must be virtual so Castle DynamicProxy intercepts the call on the proxy and forwards
// it to this implementation on the target object.
public virtual void DeepUnfreeze()
{
// ReferenceEqualityComparer ensures that identity (not equality) is used when
// tracking visited objects, which prevents infinite loops in object graphs that
// contain circular references or shared child instances.
var visited = new HashSet<object>(comparer: ReferenceEqualityComparer.Instance);
this.DeepUnfreeze(visited: visited);
}
// Internal overload that accepts a visited set for circular reference tracking
internal virtual void DeepFreeze(
HashSet<object> visited)
{
// If this object has already been processed, skip it to prevent infinite recursion
if (!visited.Add(this))
{
return;
}
this.Freeze();
// Reflection is used so subclasses do not need to override DeepFreeze to register
// their own properties — any IFreezable property is discovered and frozen automatically.
PropertyInfo[] freezableProps = this.GetType().GetProperties()
.Where(p => p.CanRead && p.GetValue(this) is IFreezable)
.ToArray();
foreach (PropertyInfo prop in freezableProps)
{
if (prop.GetValue(this) is IFreezable freezable)
{
// If the freezable is a FreezableBase, use the internal overload to maintain visited tracking
if (freezable is FreezableBase freezableBase)
{
freezableBase.DeepFreeze(visited: visited);
}
else
{
// For non-FreezableBase implementations, just call the public method
freezable.DeepFreeze();
}
}
}
// IList and IEnumerable are used to find collections that may contain IFreezable items.
// Both generic and non-generic collections need to be processed.
PropertyInfo[] collectionProps = this.GetType().GetProperties()
.Where(p =>
{
if (!p.CanRead)
{
return false;
}
object? value = p.GetValue(this);
// Check for both generic IEnumerable<T> and non-generic IList
return value is IList || value is IEnumerable enumerable && value.GetType().IsGenericType;
}
)
.ToArray();
foreach (PropertyInfo prop in collectionProps)
{
object? value = prop.GetValue(this);
if (value is IEnumerable enumerable)
{
// Convert to list for processing
List<object> items = enumerable.Cast<object>().ToList();
foreach (object? item in items)
{
if (item is IFreezable freezableItem)
{
// If the freezable is a FreezableBase, use the internal overload to maintain visited tracking
if (freezableItem is FreezableBase freezableBase)
{
freezableBase.DeepFreeze(visited: visited);
}
else
{
// For non-FreezableBase implementations, just call the public method
freezableItem.DeepFreeze();
}
}
}
}
}
}
// Internal overload that accepts a visited set for circular reference tracking
internal virtual void DeepUnfreeze(
HashSet<object> visited)
{
// If this object has already been processed, skip it to prevent infinite recursion
if (!visited.Add(this))
{
return;
}
this.Unfreeze();
// Reflection is used so subclasses do not need to override DeepUnfreeze to register
// their own properties — any IFreezable property is discovered and unfrozen automatically.
PropertyInfo[] freezableProps = this.GetType().GetProperties()
.Where(p => p.CanRead && p.GetValue(this) is IFreezable)
.ToArray();
foreach (PropertyInfo prop in freezableProps)
{
if (prop.GetValue(this) is IFreezable freezable)
{
// If the freezable is a FreezableBase, use the internal overload to maintain visited tracking
if (freezable is FreezableBase freezableBase)
{
freezableBase.DeepUnfreeze(visited: visited);
}
else
{
// For non-FreezableBase implementations, just call the public method
freezable.DeepUnfreeze();
}
}
}
// IList and IEnumerable are used to find collections that may contain IFreezable items.
// Both generic and non-generic collections need to be processed.
PropertyInfo[] collectionProps = this.GetType().GetProperties()
.Where(p =>
{
if (!p.CanRead)
{
return false;
}
object? value = p.GetValue(this);
// Check for both generic IEnumerable<T> and non-generic IList
return value is IList || value is IEnumerable enumerable && value.GetType().IsGenericType;
}
)
.ToArray();
foreach (PropertyInfo prop in collectionProps)
{
object? value = prop.GetValue(this);
if (value is IEnumerable enumerable)
{
// Convert to list for processing
List<object> items = enumerable.Cast<object>().ToList();
foreach (object? item in items)
{
if (item is IFreezable freezableItem)
{
// If the freezable is a FreezableBase, use the internal overload to maintain visited tracking
if (freezableItem is FreezableBase freezableBase)
{
freezableBase.DeepUnfreeze(visited: visited);
}
else
{
// For non-FreezableBase implementations, just call the public method
freezableItem.DeepUnfreeze();
}
}
}
}
}
}
}
// Creates Castle DynamicProxy wrappers around FreezableBase objects so that property
// setters can be intercepted at runtime without modifying the original class hierarchy.
// The proxy subclasses the concrete type and routes every virtual member through
// FreezeInterceptor, which throws when a setter is called on a frozen object.
public static class FreezableFactory
{
// ProxyGenerator is expensive to create and caches generated proxy types internally,
// so a single shared instance avoids repeated type-generation overhead.
private static readonly ProxyGenerator _proxyGenerator = new();
// FreezeInterceptor holds no state — one instance is safe to share across all proxies.
private static readonly FreezeInterceptor _interceptor = new();
public static T? Wrap<T>(
T? target) where T : class, IFreezable
{
if (target == null)
{
return null;
}
// target.GetType() is used instead of typeof(T) because T may be inferred as the
// IFreezable interface when called from a recursive context. Passing an interface
// to CreateClassProxyWithTarget throws ArgumentException — it requires a class.
// The cast back to T is safe because the generated proxy subclasses the concrete type.
return (T)_proxyGenerator.CreateClassProxyWithTarget(
target.GetType(),
Array.Empty<Type>(),
target: target,
options: ProxyGenerationOptions.Default,
Array.Empty<object>(),
_interceptor
);
}
public static T? WrapAndFreeze<T>(
T? target) where T : class, IFreezable
{
T? wrapped = Wrap(target: target);
if (wrapped == null)
{
return null;
}
wrapped.Freeze();
return wrapped;
}
public static T DeepFreeze<T>(
T target) where T : class, IFreezable
{
// ReferenceEqualityComparer ensures that identity (not equality) is used when
// tracking visited objects, which prevents infinite loops in object graphs that
// contain circular references or shared child instances.
var visited = new HashSet<object>(comparer: ReferenceEqualityComparer.Instance);
// Dictionary to maintain the mapping between original objects and their wrapped proxies
// This is needed to return the correct wrapped proxy when encountering circular references
var originalToWrapped = new Dictionary<object, IFreezable>(comparer: ReferenceEqualityComparer.Instance);
var wrapped = (T)DeepWrap(target: target, visited: visited, originalToWrapped: originalToWrapped);
wrapped.DeepFreeze();
return wrapped;
}
private static IFreezable DeepWrap(
IFreezable target,
HashSet<object> visited,
Dictionary<object, IFreezable> originalToWrapped)
{
if (target is null)
{
throw new ArgumentNullException(nameof(target));
}
// If we've already processed this object, return its wrapped proxy to preserve circular references
if (originalToWrapped.TryGetValue(key: target, out IFreezable? existingWrapped))
{
return existingWrapped;
}
// If this target is already visited but not in our mapping, it means we're encountering
// a wrapped proxy during traversal - return it as-is to avoid re-wrapping
if (visited.Contains(item: target))
{
return target;
}
// Use the runtime type of the target when creating the proxy so that we always
// subclass the concrete implementation rather than the IFreezable interface.
// The dynamic dispatch ensures the generic constraint (class, IFreezable) is
// satisfied using the concrete runtime type.
IFreezable wrapped = Wrap((dynamic)target)
?? throw new InvalidOperationException("Wrap returned null for a non-null target.");
// Store the mapping from original to wrapped BEFORE processing child properties
// This ensures circular references can find the wrapped version
originalToWrapped[key: target] = wrapped;
// Both the original target and its proxy are tracked so that encountering either
// one during recursive traversal is recognised as already-visited. Without tracking
// the proxy, a second traversal path to the same object would wrap it again.
visited.Add(item: target);
visited.Add(item: wrapped);
// Wrap each IFreezable property value so that freeze/unfreeze operations propagate
// to nested objects. CanWrite is required because the wrapped proxy is set back
// onto the property so the parent always holds the proxy, not the raw object.
Type runtimeType = target.GetType();
IList<PropertyInfo> freezableProps = runtimeType
.GetProperties()
.Where(p =>
{
if (!p.CanRead)
{
return false;
}
return p.CanWrite && p.GetValue(obj: target) is IFreezable;
}
).ToList();
foreach (PropertyInfo prop in freezableProps)
{
object? value = prop.GetValue(obj: target);
if (value is IFreezable freezable)
{
IFreezable wrappedValue = DeepWrap(target: freezable, visited: visited, originalToWrapped: originalToWrapped);
prop.SetValue(obj: wrapped, value: wrappedValue);
}
}
// Collections are not proxied as a whole — only their IFreezable elements are
// individually wrapped and replaced in-place. The list itself (e.g. List<Person>)
// is left as-is because Castle cannot meaningfully proxy a sealed generic type.
PropertyInfo[] listProps = runtimeType.GetProperties()
.Where(p => p.CanRead && p.GetValue(obj: target) is IList)
.ToArray();
foreach (PropertyInfo prop in listProps)
{
object? value = prop.GetValue(obj: target);
if (value is IList list)
{
// First, ensure that all IFreezable elements in the collection are themselves
// wrapped, so subsequent access to those elements respects freezing semantics.
for (var i = 0; i < list.Count; i++)
{
if (list[index: i] is IFreezable freezableItem && !visited.Contains(list[index: i]))
{
list[index: i] = DeepWrap(target: freezableItem, visited: visited, originalToWrapped: originalToWrapped);
}
}
// If the property is declared as a writable list and the underlying instance is
// mutable, wrap it in a freezable-aware collection so that callers cannot mutate
// the collection via Add/Remove/Clear *while the owner is frozen*, but can do so
// again after Unfreeze()/DeepUnfreeze().
if (prop.CanWrite && !list.IsReadOnly && target is IFreezable owner)
{
Type propertyType = prop.PropertyType;
// Non-generic IList properties are wrapped in a non-generic, freezable-aware
// IList implementation that checks the owner's IsFrozen flag on mutation.
if (propertyType == typeof(IList))
{
IList wrappedList = new FreezableNonGenericListWrapper(inner: list, owner: owner);
prop.SetValue(obj: wrapped, value: wrappedList);
}
// For generic list/collection-typed properties (e.g. IList<T>, ICollection<T>,
// IReadOnlyList<T>), wrap the list in a FreezableListWrapper<T> so callers
// cannot mutate it while the owner is frozen.
else if (propertyType.IsGenericType)
{
Type genericDefinition = propertyType.GetGenericTypeDefinition();
if (genericDefinition == typeof(IList<>) ||
genericDefinition == typeof(ICollection<>) ||
genericDefinition == typeof(IReadOnlyList<>))
{
Type elementType = propertyType.GetGenericArguments()[0];
Type wrapperType = typeof(FreezableListWrapper<>).MakeGenericType(elementType);
// Construct FreezableListWrapper<T>(IList<T>, IFreezable).
object? wrappedList = Activator.CreateInstance(
type: wrapperType,
list,
owner
);
// Ensure the constructed wrapper can be assigned to the declared
// property type (e.g. IList<T> / ICollection<T> / IReadOnlyList<T>).
if (wrappedList != null && propertyType.IsInstanceOfType(o: wrappedList))
{
prop.SetValue(obj: wrapped, value: wrappedList);
}
}
}
}
}
}
return wrapped;
}
/// <summary>
/// Non-generic IList wrapper that gates all mutating operations on the owning IFreezable's
/// IsFrozen state. When the owner is frozen, mutations throw; when unfrozen, mutations
/// delegate to the underlying list.
/// </summary>
private sealed class FreezableNonGenericListWrapper(IList inner, IFreezable owner) : IList
{
private readonly IList _inner = inner ?? throw new ArgumentNullException(nameof(inner));
private readonly IFreezable _owner = owner ?? throw new ArgumentNullException(nameof(owner));
public int Add(
object? value)
{
this.EnsureNotFrozen();
return this._inner.Add(value: value);
}
public void Clear()
{
this.EnsureNotFrozen();
this._inner.Clear();
}
public bool Contains(
object? value)
{
return this._inner.Contains(value: value);
}
public int IndexOf(
object? value)
{
return this._inner.IndexOf(value: value);
}
public void Insert(
int index,
object? value)
{
this.EnsureNotFrozen();
this._inner.Insert(index: index, value: value);
}
public bool IsFixedSize => this._inner.IsFixedSize;
public bool IsReadOnly => this._inner.IsReadOnly;
public void Remove(
object? value)
{
this.EnsureNotFrozen();
this._inner.Remove(value: value);
}
public void RemoveAt(
int index)
{
this.EnsureNotFrozen();
this._inner.RemoveAt(index: index);
}
public object? this[
int index]
{
get => this._inner[index: index];
set
{
this.EnsureNotFrozen();
this._inner[index: index] = value;
}
}
public void CopyTo(
Array array,
int index)
{
this._inner.CopyTo(array: array, index: index);
}
public int Count => this._inner.Count;
public bool IsSynchronized => this._inner.IsSynchronized;
public object SyncRoot => this._inner.SyncRoot;
public IEnumerator GetEnumerator()
{
return this._inner.GetEnumerator();
}
private void EnsureNotFrozen()
{
if (this._owner.IsFrozen)
{
throw new InvalidOperationException("Cannot modify a collection owned by a frozen object.");
}
}
}
/// <summary>
/// Generic list wrapper that gates mutating operations on the owning IFreezable's IsFrozen
/// state, while exposing both IList&lt;T&gt; and IReadOnlyList&lt;T&gt; semantics.
/// </summary>
private sealed class FreezableListWrapper<T> : IList<T>, IReadOnlyList<T>
{
private readonly IList<T> _inner;
private readonly IFreezable _owner;
public FreezableListWrapper(
IList<T> inner,
IFreezable owner)
{
this._inner = inner ?? throw new ArgumentNullException(nameof(inner));
this._owner = owner ?? throw new ArgumentNullException(nameof(owner));
}
public T this[
int index]
{
get => this._inner[index: index];
set
{
this.EnsureNotFrozen();
this._inner[index: index] = value;
}
}
public int Count => this._inner.Count;
public bool IsReadOnly => this._inner.IsReadOnly;
public void Add(
T item)
{
this.EnsureNotFrozen();
this._inner.Add(item: item);
}
public void Clear()
{
this.EnsureNotFrozen();
this._inner.Clear();
}
public bool Contains(
T item)
{
return this._inner.Contains(item: item);
}
public void CopyTo(
T[] array,
int arrayIndex)
{
this._inner.CopyTo(array: array, arrayIndex: arrayIndex);
}
public IEnumerator<T> GetEnumerator()
{
return this._inner.GetEnumerator();
}
public int IndexOf(
T item)
{
return this._inner.IndexOf(item: item);
}
public void Insert(
int index,
T item)
{
this.EnsureNotFrozen();
this._inner.Insert(index: index, item: item);
}
public bool Remove(
T item)
{
this.EnsureNotFrozen();
return this._inner.Remove(item: item);
}
public void RemoveAt(
int index)
{
this.EnsureNotFrozen();
this._inner.RemoveAt(index: index);
}
IEnumerator IEnumerable.GetEnumerator()
{
return this._inner.GetEnumerator();
}
private void EnsureNotFrozen()
{
if (this._owner.IsFrozen)
{
throw new InvalidOperationException("Cannot modify a collection owned by a frozen object.");
}
}
}
}
public class FreezeInterceptor : IInterceptor
{
public void Intercept(
IInvocation invocation)
{
if (invocation.Method.Name.StartsWith("set_") &&
invocation.InvocationTarget is IFreezable freezable &&
freezable.IsFrozen)
{
string propertyName = invocation.Method.Name.Substring(startIndex: 4);
throw new InvalidOperationException(
$"Cannot set property '{propertyName}': object is frozen"
);
}
invocation.Proceed();
}
}
public class FreezableTests
{
private static Person CreateTestPerson()
{
var person = new Person
{
Name = "John",
Age = 30,
HomeAddress = new Address
{
Street = "123 Main St",
City = "Springfield"
}
};
person.Children.Add(new Person { Name = "Alice", Age = 8 });
person.Hobbies.Add("Reading");
person.Hobbies.Add("Gaming");
return FreezableFactory.DeepFreeze(target: person);
}
[Fact]
public void FreezableBase_Freeze_SetsFrozenState()
{
Person person = CreateTestPerson();
person.Freeze();
person.IsFrozen.Should().BeTrue();
}
[Fact]
public void FreezableBase_Freeze_ThrowsOnPropertySet()
{
Person person = CreateTestPerson();
person.Freeze();
Action act = () => { person.Age = 31; };
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void FreezableBase_Unfreeze_ClearsFrozenState()
{
Person person = CreateTestPerson();
person.Freeze();
person.Unfreeze();
person.IsFrozen.Should().BeFalse();
}
[Fact]
public void FreezableBase_Unfreeze_AllowsPropertySet()
{
Person person = CreateTestPerson();
person.Freeze();
person.Unfreeze();
person.Age = 31;
person.Age.Should().Be(expected: 31);
}
[Fact]
public void FreezableBase_DeepFreeze_FreezesNestedObjects()
{
Person person = CreateTestPerson();
person.DeepFreeze();
person.IsFrozen.Should().BeTrue();
person.HomeAddress.IsFrozen.Should().BeTrue();
((Person)person.Children[index: 0])?.IsFrozen.Should().BeTrue();
}
[Fact]
public void FreezableBase_DeepFreeze_ThrowsOnNestedPropertySet()
{
Person person = CreateTestPerson();
person.DeepFreeze();
Action act = () => { person.HomeAddress.City = "New City"; };
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void FreezableBase_DeepUnfreeze_UnfreezesNestedObjects()
{
Person person = CreateTestPerson();
person.DeepFreeze();
person.DeepUnfreeze();
person.IsFrozen.Should().BeFalse();
person.HomeAddress.IsFrozen.Should().BeFalse();
((Person)person.Children[index: 0])?.IsFrozen.Should().BeFalse();
}
[Fact]
public void FreezableBase_DeepUnfreeze_AllowsNestedPropertySet()
{
Person person = CreateTestPerson();
person.DeepFreeze();
person.DeepUnfreeze();
person.HomeAddress.City = "New City";
person.HomeAddress.City.Should().Be("New City");
}
[Fact]
public void FreezableBase_JsonSerialization_SerializesFrozenObject()
{
Person person = CreateTestPerson();
person.Freeze();
string json = JsonSerializer.Serialize(value: person, new JsonSerializerOptions { WriteIndented = true });
json.Should().NotBeNullOrEmpty();
}
[Fact]
public void FreezableBase_JsonSerialization_DeserializesAsUnfrozen()
{
Person person = CreateTestPerson();
person.Freeze();
string json = JsonSerializer.Serialize(value: person);
var deserialized = JsonSerializer.Deserialize<Person>(json: json);
deserialized?.IsFrozen.Should().BeFalse();
}
[Fact]
public void FreezableBase_JsonSerialization_DeserializedObjectAllowsPropertySet()
{
Person person = CreateTestPerson();
person.Freeze();
string json = JsonSerializer.Serialize(value: person);
var deserialized = JsonSerializer.Deserialize<Person>(json: json);
deserialized!.Name = "Modified After Deserialize";
deserialized.Name.Should().Be("Modified After Deserialize");
}
[Fact]
public void FreezableBase_FreezeCycles_MaintainCorrectState()
{
Person person = CreateTestPerson();
for (var i = 0; i < 3; i++)
{
person.Freeze();
person.IsFrozen.Should().BeTrue();
person.Unfreeze();
person.IsFrozen.Should().BeFalse();
person.Name = $"Cycle {i + 1}";
person.Name.Should().Be($"Cycle {i + 1}");
}
}
[Fact]
public void FreezableBase_DeepFreezeUnfreezeCycle_AllowsPropertySetAfterUnfreeze()
{
Person person = CreateTestPerson();
person.DeepFreeze();
person.DeepUnfreeze();
person.Name = "Final Name";
person.HomeAddress.City = "Final City";
person.Name.Should().Be("Final Name");
person.HomeAddress.City.Should().Be("Final City");
}
[Fact]
public void FreezableBase_Freeze_ThrowsOnCollectionMutation()
{
// Arrange
Person person = CreateTestPerson();
person.Freeze();
// Act
Action addChild = () => person.Children.Add(new Person { Name = "Bob", Age = 5 });
Action removeChild = () => person.Children.RemoveAt(index: 0);
Action addHobby = () => person.Hobbies.Add("Cooking");
Action removeHobby = () => person.Hobbies.RemoveAt(index: 0);
// Assert
addChild.Should().Throw<InvalidOperationException>();
removeChild.Should().Throw<InvalidOperationException>();
addHobby.Should().Throw<InvalidOperationException>();
removeHobby.Should().Throw<InvalidOperationException>();
}
[Fact]
public void FreezableFactory_DeepWrap_ReusesProxies_ForSharedAndCircularReferences()
{
// Arrange: create a graph with shared and circular references
var shared = new TestNode();
var root = new TestNode
{
Left = shared,
Right = shared
};
// Introduce a circular reference from the shared node back to the root
shared.Left = root;
// Act
TestNode wrapped = FreezableFactory.DeepFreeze(target: root);
// Assert: shared references should still be shared in the wrapped graph
wrapped.Left.Should().BeSameAs(expected: wrapped.Right);
// Circular reference should be preserved and should use the wrapped instance
wrapped.Left!.Left.Should().BeSameAs(expected: wrapped);
// Wrapped graph should not contain the original raw instances
wrapped.Left.Should().NotBeSameAs(unexpected: shared);
wrapped.Should().NotBeSameAs(unexpected: root);
}
public class Person : FreezableBase
{
public virtual string Name { get; set; }
public virtual int Age { get; set; }
public virtual Address HomeAddress { get; set; }
public virtual IList Children { get; set; } = new List<Person>();
public virtual IList Hobbies { get; set; } = new List<string>();
}
public class Address : FreezableBase
{
public virtual string Street { get; set; }
public virtual string City { get; set; }
public virtual string ZipCode { get; set; }
}
public class TestNode : FreezableBase
{
public TestNode? Left { get; set; }
public TestNode? Right { get; set; }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment