Germinate allows you to modify C# v9 immutable records in a more convienient way. To obtain a new immutable record, you apply all your changes to a temporary draft. Once all the mutations are completed, Germinate applys the draft to generate a new immutable record containing all the mutations. Thus you can interact with your data by simply modifying it while keeping all the benifits of immutable data.
using System;
using Germinate;
[Draftable]
public record Weather
{
public int TemperatureC { get; init; }
public double PressureMB { get; init; }
public string Wind { get; init; }
}
[Draftable]
public record City
{
public string Location { get; init; }
public double Latitude { get; init; }
public double Longitude { get; init; }
public Weather CurWeather { get; init; }
}
public static class Program
{
public static void Main()
{
var chicago = new City()
{
Location = "Chicago",
Latitude = 41.9,
Longitude = -87.6,
CurWeather = new()
{
TemperatureC = 10,
PressureMB = 1023.4,
Wind = "High",
}
};
// The following Produce function is created by Germinate
City chicagoTomorrow = chicago.Produce(draft => {
draft.CurWeather.TemperatureC = 6;
draft.CurWeather.PressureMB += 10.2;
});
Console.WriteLine(chicago.ToString());
// => City { Location = Chicago, Latitude = 41.9, Longitude = -87.6,
// CurWeather = Weather { TemperatureC = 10, PressureMB = 1023.4, Wind = High } }
Console.WriteLine(chicagoTomorrow.ToString());
// => City { Location = Chicago, Latitude = 41.9, Longitude = -87.6,
// CurWeather = Weather { TemperatureC = 6, PressureMB = 1033.6, Wind = High } }
}
}Immutable data is great, but once you start nesting multiple immutable records inside each other it becomes difficult to adjust them.
Using with, the above example of updating Chicago's weather would look like:
var chicagoTomorrow = chicago with { CurWeather = chicago.CurWeather with {
TemperatureC = 6,
PressureMB = chicago.CurWeather.PressureMB + 10.2,
}};These with expressions get more and more complex once more of your data becomes immutable, especially once lists and dictionaries
become involved. There have been traditionally two approaches to this: lenses/optics and copy-on-write to draft objects.
Inspired by Immer, Germinate takes the latter approach. Germinate's copy-on-write
draft technique is to create the following:
public interface IWeatherDraft {
int TemperatureC { get; set; }
double PressureMB { get; set; }
string Wind { get; set; }
}
public interface ICityDraft {
string Location { get; set; }
double Latitude { get; set; }
double Longitude { get; set; }
IWeatherDraft CurWeather { get; }
IWeatherDraft SetCurWeather(Weather value);
}
public static class Producer {
public static Weather Produce(this Weather value, Action<IWeatherDraft> f) { ... }
public static City Produce(this City value, Action<ICityDraft> f) { ... }
}Note how the init properties have changed to set. The implementation is straightforward (and tedious). The Produce function
first creates drafts where the setter for each property tracks that the property has changed, Produce then
calls the user-supplied action f to update the draft, and finally converts the draft back to a new immutable record.
Germinate uses C# source code generation which as of .NET 5 is still in preview. Thus, your csproj file must include
<LangVersion>preview</LangVersion> and reference Germinate using the Analyzer output type.
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<!-- TODO: replace Germinate version with latest from Nuget -->
<PackageReference Include="Germinate" Version="*" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>For each record with the Germinate.DraftableAttribute, Germinate will generate an interface and an extension method Produce.
The interface will be named I<RecordName>Draft and will be placed into the same namespace as the record
marked with the Draftable attribute. For each public property on the record, Germinate will create properties
in the interface with the following rules.
-
If the record property's type is itself a draftable record (e.g. the
CurWeatherproperty of theCityrecord), the interface will contain two entries. First, a getter with the same property name which returns the draft interface for the property type. Second, a property calledSet<PropertyName>which takes the property type. Using the weather example above, theWeather CurWeather {get; init;}property in the record generatesIWeatherDraft CurWeather {get;}andIWeatherDraft SetCurWeather(Weather value)in the draft interface. -
If the record property's type is an Immutable Collection, Germinate adds to the draft interface the
Builderform of the collection. For example, if the record contains aImmutableList<int> MyIntList {get; init;}then Germinate addsImmutableList<int>.Builder MyIntList {get;}to the draft interface. The Builder allows you to use mutable methods to update the list. See the collections documentation for more details. -
If the record property's type is neither of the above (basic type such as
int, a struct, a class, or a record not marked as draftable), the property is copied directly into the draft interface with a setter. For example, a propertystring MyString {get; init;}in the record will translate tostring MyString {get; set;}in the draft interface.
Germinate will also create a static extension method
RecordName Produce(this RecordName v, Action<IRecordNameDraft> f) for each record marked with the
Draftable attribute. This will be in the Germinate namespace in a static class named after
the assembly, something like Producer_<assemblyName>. There is a single static class which contains
all the Produce extension methods.
For each draftable record used inside an immutable collection, Germinte will create a static extension method
AdjustAll which takes the immutable collection builder and an action to update the draft. A method is
generated for each immutable collection builder (ImmutableList<V>.Builder, ImmutableDictionary<K,V>.Builder, etc.)
for which the value is a draftable record. The AdjustAll extension method calls Produce on each
value in the immutable collection and sets the result back into the immutable collection builder.
See the collections documentation for more details.
Finally, Germinate generates the implementation of the draftable interfaces inside the
Germinate.Internal namespace. These classes are public so that if Germinte is used in multiple
assemblies, Germinate can generate references to the drafts of records in dependent assemblies. These
implementations are carefully implemented with copy-on-write, and delay creating a new draftable
instance until the getter is actually accessed. (That is, if the CurWeather property on the city
is never accessed in the action, no weather draft object will be created and the newly returned city
will use the original weather record object.) Despite these being public, they should never be used
directly.
For example, consider the following record:
namespace MyNameSpace {
[Germinate.Draftable]
public record MyRecord {
public Weather CurWeather { get; init; }
public ImmutableList<double> DoubleLst { get; init; }
public int SomeInt { get; init; }
public MyClass SomeClass { get; init; }
}
}Assuming Weather is a record marked with the draftable attribute, Germinate produces
namespace MyNameSpace {
public interface IMyRecordDraft {
public IWeatherDraft CurWeather { get; }
public IWeatherDraft SetWeather(Weather w);
public ImmutableList<double>.Builder DoubleLst { get; }
public int SomeInt { get; set; }
public MyClass SomeClass { get; set; }
}
}
namespace Germinate {
public static class Producer_AssemblyName {
public static MyRecord Produce(this MyRecord val, Action<IMyRecordDraft> f)
{
// make new instance which implements IMyRecordDraft with values initially coming from val
// call f
// convert result to new MyRecord
}
}
}When working with immutable records, they should only contain only immutable data consiting of base types, other
immutable records, or immutable collections. There are two possibilities:
System.Collections.Immutable
(NuGet) or just using IReadOnlyList/
IReadOnlyDictionary.
The prefered approach to collections is the System.Collections.Immutable (NuGet) library.
[Draftable]
public record SomeNumbers {
public ImmutableList<int> Numbers { get; init; }
}Germinate will translate immutable collections to their Builder version in the draft interface, allowing you to
make adjustments to the immutable list without requiring a full copy. See this
article
for more information, but essentially the operations applied to the Builder will be applied to the original
immutable list to produce a new immutable list, but these two immutable lists will share as much data and memory
as possible.
var evens = new SomeNumbers() { Numbers = ImmutableList.Create(2, 4, 6, 8) };
var moreEvens = evens.Produce(draft => {
draft.Numbers.Add(draft.Numbers[3] + 2);
draft.Numbers.Add(12);
});draft.Numbers has type
ImmutableList<int>.Builder and Germinate
will initialize the builder with the contents of evens.Numbers. At the end of the Produce function, Germinate calls the
ToImmutable
method to convert the builder to a new immutable list to be contained in moreEvens.
If the value inside an immutable collection is itself draftable, Germinate will generate extension methods AdjustAll
in the Germinate namespace.
public static void AdjustAll(this ImmutableArray<Weather>.Builder b, Action<IWeatherDraft> f);
public static void AdjustAll(this ImmutableArray<Weather>.Builder b, Action<IWeatherDraft, int> f);
public static void AdjustAll(this ImmutableList<Weather>.Builder b, Action<IWeatherDraft> f);
public static void AdjustAll(this ImmutableList<Weather>.Builder b, Action<IWeatherDraft, int> f);
public static void AdjustAll(this ImmutableHashSet<Weather>.Builder b, Action<IWeatherDraft> f);
public static void AdjustAll(this ImmutableSortedSet<Weather>.Builder b, Action<IWeatherDraft> f);
public static void AdjustAll(this ImmutableSortedSet<Weather>.Builder b, Action<IWeatherDraft, int> f);
public static void AdjustAll<Key>(this ImmutableDictionary<Key, Weather>.Builder b, Action<Key, IWeatherDraft> f);
public static void AdjustAll<Key>(this ImmutableSortedDictionary<Key, Weather>.Builder b, Action<Key, IWeatherDraft> f);From among the above list, Germinate only generates AdjustAll methods where the builder actually appears inside
a draftable record. The AdjustAll method calls Produce on each element in the collection, possibly also passing
the index as an integer or the key for the dictionaries to the adjustment function.
For example,
[Draftable]
public record Country {
public ImmutableDictionary<string, City> Cities {get; init;}
}would cause Germinate to emit (in the Germinate namespace)
public static void AdjustAll<Key>(this ImmutableDictionary<Key, City>.Builder b, Action<Key, ICityDraft> f);which could then be used as
var country = new Country() { .... };
var newCountry = country.Produce(countryDraft => {
countryDraft.Cities.AdjustAll((cityKey, cityDraft) => {
cityDraft.Weather.TemperatureC += 4;
});
});Note how countryDraft.Cities has type ImmutableDictionary<string, City>.Builder and so the AdjustAll extension
method can be called on it.
If you don't want to use the immutable collections package, you can consider using
IReadOnlyList and IReadOnlyDictionary directly in the record. Since these are normal non-draftable properties,
Germinate translates them to a getter and a setter in the draft interface. You can then create a new read only list or
dictionary using LINQ. For example,
[Draftable] public record SomeNumbers {
IReadOnlyList<int> Numbers { get; init; }
}can be used as
var evens = new SomeNumbers() { Numbers = new[] {2, 4, 6, 8}};
var odds = evens.Produce(d => d.Numbers = d.Numbers.Select(i => i + 1).ToArray());Care must be taken because while the record itself has type IReadOnlyList, there is nothing preventing the initialing code from
keeping a reference to the mutable collection. I initially used IReadOnlyList but had a hard to track down bug due to this.
In an existing codebase, we introduced some immutable records but still had some existing normal mutable classes. One of those classes
contained a property of a mutable System.Collections.Generic.List. This list was copied into a record with a property of type
IReadOnlyList so anything accessing the record couldn't change the list. But of course the list was also referenced from the
mutable class and was mutated there, causing a bug because code using the record expected the list to be unchanged. This could have
been mitigated by copying the list using the LINQ ToArray() or ToList() at the time the record is created so the record gets its
own copy, but there is nothing to enforce this restriction. Due to this, I converted all the immutable records in our project to use
immutable collections.
Germinate does not generate anything with operator overloading, but you might consider adding an overload
to the % operator in your record. For example,
using Germinate;
[Draftable]
public record City
{
public string Location { get; init; }
public double Latitude { get; init; }
public double Longitude { get; init; }
public Weather CurWeather { get; init; }
public static City operator %(City c, Action<ICityDraft> f)
=> c.Produce(f);
}This is most useful in the %= form as follows.
City chicago = new City() { ... }
chicago %= c => c.CurWeather.TemperatureC += 4;It is especially useful when working with collections of records.
[Draftable]
public record Country {
public ImmutableDictionary<string, City> Cities {get; init;}
}could be used as
var unitedStates = new Country() { ... }
unitedStates.Produce(draftUS => {
draftUS.Cities["Chicago"] %= chicago => {
chicago.CurWeather.TemperatureC += 4;
};
});Note how draftUS.Cities has type
ImmutableDictionary<string, City>.Builder
and so supports get and set with a new value. The %= operator on City allows adjusting that value directly with a draft action.
-
Each draftable record requires a public zero-argument constructor. The
Producefunction created by Germinate uses this constuctor to initalize a new copy of the record, setting all drafted properties. -
If a draftable record inherits from another record, the parent record must also be draftable all the way back to the inheritance root. Just make sure each record in the inheritance heirarchy is marked with the
[Draftable]attribute. -
Using the immutable collection
Builders, you can't distinguish between a null or empty collection. Germinate produces theBuilderlazily in the draft property getter. If the original collection was null the builder will be created as initially empty. -
When using Germinate in multiple dependent assemblies and you want to mix the records in the same draftable record, there must be a single common ancestor assembly that references Germinate. This is because if no depdenent assembly uses Germinate, Germinate generates a base class in the
Germinate.Internalnamespace. If two independent assemblies reference Germinate, there will be two separate definitions of this base class. In this situation, Germinate could still be used separately on the records in the two assemblies, but you couldn't combine them into a single draftable record.
Licensed under the MIT License