Changing Immutable Collections
As I've written before, I'm leaning heavily into immutability in the election site code. Until September 2025 (it's taken a long time to get round to writing this blog post) that meant a combination of records, ImmutableList<T> and ImmutableDictionary<TKey, TValue>.
In an ECMA C# standards meeting, however, Joseph Musser passed on some really valuable feedback - that those immutable types are significantly less efficient than ImmutableArray<T> and FrozenDictionary<TKey, TValue> for the way that I use them.
Usage patterns of immutable collectionsThere are two very distinct patterns for how you might use immutable collections.
The pattern that ImmutableList<T> and ImmutableDictionary<TKey, TValue> support really well is build over time, using the intermediate collections". Both of these have Add methods which returns a new immutable collection based on the previous one, but with an additional entry. There are certainly plenty of places where this usage pattern is appropriate - but my election site hardly ever does this.
Instead, my code tends to create a collection (often using LINQ, but not exclusively) and then never modify" it again. When something in the data changes, I create a new collection from scratch rather than basing a new collection on the existing one. This is the usage pattern which ImmutableArray<T> and FrozenDictionary<TKey, TValue> are designed for.
MigrationMigration from ImmutableDictionary to FrozenDictionary was really straightforward:
- Add imports for the System.Collections.Frozen namespace
- Rename my own extension methods (e.g. ToImmutableReferenceDictionary becomes ToFrozenReferenceDictionary)
- Call ToFrozenDictionary instead of ToImmutableDictionary
- Change declarations for a few properties and record parameters
That's it! Aside from anything else, the simplicity of that change was validation that I really wasn't using the ImmutableDictionary-specific methods for constructing new immutable dictionaries from existing ones.
Migration from ImmutableList to ImmutableArray was more fiddly, although never actually hard. There are two differences that are relevant:
- ImmutableArray is a value type (with a single field which is a reference to the backing array)
- ImmutableArray has a Length property (like arrays) as opposed to the Count property in ImmutableList
Again, looking over the commit, I can't see anywhere that I was using the ImmutableList-specific Add method and had to change how I was constructing any lists. I didn't have any helper methods to rename for ImmutableArray, but the value-type-ness did mean other helper method changes. For example, consider this declaration:
public static bool ElementsEqual<T>(ImmutableList<T>? left, ImmutableList<T>? right)
That's fine even if the caller has non-nullable references, and you couldn't overload it to the non-nullable equivalent parameters anyway - whereas with ImmutableArray you almost certainly don't want to wrap a non-nullable ImmutableArray value in an ImmutableArray? just for it to be checked and unwrapped again, so I've ended up with overloads:
public static bool ElementsEqual<T>(ImmutableArray<T>? left, ImmutableArray<T>? right)public static bool ElementsEqual<T>(ImmutableArray<T> left, ImmutableArray<T> right)
(The only-one-is-nullable" possibilities aren't worth adding in my codebase, although they'd be entirely valid.)
Likewise, in various places I would have code such as:
public void DoSomething(ImmutableList<string>? list){ if (list is null) { // Whatever needs to happen for the null case return; } // Now use list in a non-nullable way. The compiler knows it's not null, // so I don't get warnings. // For example: foreach (var element in list) { ... }}That doesn't work with the trivial migration to ImmutableArray because the parameter is a nullable value type. So there end up being two different options. We can unwrap" to the non-nullable value type using the .Value property, which is usually the simplest option when we only use it once:
public void DoSomething(ImmutableArray<string>? array){ if (array is null) { // Whatever needs to happen for the null case return; } // Use .Value to access the array foreach (var element in array.Value) { ... }}Or use the is operator to declare a new local variable:
public void DoSomething(ImmutableArray<string>? array){ if (array is not ImmutableArray<string> arrayValue) { // Whatever needs to happen for the null case return; } // Now we have a new non-nullable local variable, arrayValue foreach (var element in arrayValue) { ... }}The latter is the approach I've taken in most places where I'm using the array multiple times in a single member.
Performance benefitsI was initially slightly skeptical about the possible benefits of changing collection types. How bad could the existing types be, really? The site was already performing well enough" for me anyway.
At the time of migration, I only had three benchmarks:
- Constructing ConstituenciesVM
- Constructing CandidatesVM
- Validating ElectionContext
All of these three improved after migration, but the construction only improved a little bit... whereas ElectionContext.Validate improved from 5.5ms to 0.826ms - a huge improvement, due to it performing lots of read accesses.
Do I care that much about ElectionContext.Validate? Not really - on its own, that wouldn't justify the change, particularly if the code ended up being more complex or hard to read. But it does demonstrate that the benefits aren't purely theoretical. Given that my usage pattern is really, really closely correlated to the FrozenDictionary/ImmutableArray design, I figured I might as well take the plunge.
Realistically, even if I actually get a lot of traffic in the end (which is still a big if") almost all the traffic will end up being served from naturally-efficient paths without seeing much benefit from this. The relevant bits of work" to be done are:
- Loading the data from Firestore and converting it into the low-level models and the overall ElectionContext
- Building view-specific view-models from the ElectionContext
- Rendering Razor pages based on view-models
- Serving the pages (and API requests)
Of these, only steps 3 and 4 happen in the serving path. Steps 1 and 2 only happen when data has actually changed, and even then they happen during health checks, so aren't user-facing.
Even step 3 only happens once every 10 seconds per page, per instance - all pages (and most API responses) are behind a 10 second cache.
I should (and will) add more benchmarks for Razor page rendering and API serving. It may be possible to backport those benchmarks to a branch of the code before the migration. I'd expect to see modest gains from the migration in most cases, and potentially more significant gains in just a couple of cases. I don't expect to see any regressions, but obviously half the point of benchmarking is that expectations are often confounded.
ConclusionOverall, this was a simple change to make, and one that I'm glad I've done.
I'm left with two niggles though:
Firstly, having different name prefixes (and even namespaces) just feels a little untidy. It's not like I'm using the types in very different ways, so why is one Immutable and the other Frozen? This is a purely aesthetic niggle, and one that I suspect would bother some other people, and leave others completely baffled as to why I'd even mention it.
Secondly, the value-type-ness of ImmutableArray definitely gets in the way a bit. This is mostly in terms of nullability, but it also feels a little wrong... because really we are dealing with a reference, just wrapped in an ImmutableArray type. It's almost like we need a new kind of type: this is just wrapping a single reference, so pass the reference around directly under the hood, but then use it via the members declared in the wrapper type (and don't allow direct access to the real reference)." I'm sure I've written plenty of types like this in the past myself, and currently they always either need a pointless object involved (with the performance cost of constructing and garbage-collecting these wrappers) or you end up with the value type wrapper with accompanying weirdness. The additional language complexity probably isn't worth even proposing this, but it would be an interesting thought experiment.
I could address both niggles to some extent by introducing my own FrozenList class, but that would suffer from the performance cost mentioned above, and the possibility (or probability, even) of Doing It Badly. The niggles aren't severe enough to justify this.
For now, I'll live with the niggles and enjoy the improved performance. In other projects, I'm still using ImmutableList and ImmutableDictionary where I really don't care about performance, but it's good to know about the alternatives. It's possible that I should make FrozenDictionary and ImmutableArray my default" immutable types, but I'm not quite there yet...