I've made an object builder which can create all sorts of classes, it can also create immutable objects.
There are 2 requirements that must be met in order for this pattern to work with fully immutable objects.
- The properties that you want to be modified must have a backing readonly field.
- The backing field must follow some convention in my case it must start with small letter and optionally it can start with _ too, of course this can be extended to match your preferences.
Here is the actual builder class :
public class ImmutableObjectBuilder<T>
where T : class
{
private class PropertyWrapper
{
public PropertyInfo Property { get; }
public object Value { get; set; }
internal PropertyWrapper(PropertyInfo property, object value)
{
Property = property;
Value = value;
}
}
private readonly IDictionary<string, PropertyWrapper> propertiesInfo = new Dictionary<string, PropertyWrapper>();
public ImmutableObjectBuilder()
{
PropertyInfo[] properties = typeof(T).GetProperties();
foreach (PropertyInfo p in properties)
{
propertiesInfo.Add(p.Name, new PropertyWrapper(p, null));
}
}
public ImmutableObjectBuilder<T> WithValue<TValue>(string propertyName, TValue value)
{
if (!propertiesInfo.ContainsKey(propertyName))
{
throw new KeyNotFoundException("The type of TValue is different than the type of T");
}
propertiesInfo[propertyName].Value = value;
return this;
}
public TObject Build<TObject>(Func<TObject> instance)
where TObject : T
{
TObject localInstance = instance();
PropertyInfo[] instanceProperties = localInstance.GetType().GetProperties();
PropertyInfo[] matchingProperties =
(from tProperty in instanceProperties
from localProperty in propertiesInfo
where tProperty.Name == localProperty.Key &&
tProperty.PropertyType == localProperty.Value.Property.PropertyType
select localProperty.Value.Property).ToArray();
foreach (PropertyInfo p in matchingProperties)
{
if (p.CanWrite)
{
p.SetValue(localInstance, propertiesInfo[p.Name].Value);
}
else
{
p.SetReadonlyValue(localInstance, propertiesInfo[p.Name].Value);
}
}
return localInstance;
}
public TObject Build<TObject>()
where TObject : T, new() => Build(() => (TObject) Activator.CreateInstance(typeof(TObject)));
}
It works primarily with reflection in order to obtain all the properties from the generic type that is given to the class upon creation. The only function that sets the values of the class here is WithValue<TValue>(string propertyName, TValue value)
.
I was originally planning to create method dynamically using System.Reflection.Emit.DynamicMethod
class which would have respective names and wouldn't requrie the string name
parameter but I'm not fluent in IL and the are quie some downsides in this approach such as performance loss, I decided to work with what I can and I made that single method.
The other important function here is of course TObject Build<TObject>(Func<TObject> instance) where TObject : T
. It matches all the properties from the type T
and the type TValue
in order to assign values properly.
Next we check if the property is makred as readonly if that's the case it's using an extension method that I will show you in a moment, but if not just use the default .SetValue(obj, value)
method.
I have a single extension SetReadonlyValue
public static class Extensions
{
public static void SetReadonlyValue(this PropertyInfo property, object obj, object value)
{
FieldInfo[] allFields = property.DeclaringType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
FieldInfo propertyBackingField =
allFields.FirstOrDefault(f =>
f.Name == ConventionConverter.PublicToPrivate(property.Name) ||
f.Name == ConventionConverter.PublicToPrivate(property.Name, true));
propertyBackingField.SetValue(obj, value);
}
}
Which utilizes the ConventionConverter
class which is implemented as follows :
public static class ConventionConverter
{
public static string PublicToPrivate(string source, bool withUnderscore = false) => withUnderscore
? "_" + source[0].ToString().ToLower() + string.Join("", source.Skip(1))
: source.First().ToString().ToLower() + string.Join("", source.Skip(1));
public static string PrivateToPublic(string source, bool withUnderscore = false) => withUnderscore
? source.Remove(0, 1).First().ToString().ToUpper() + string.Join("", source.Skip(1))
: source.First().ToString().ToUpper() + string.Join("", source.Skip(1));
}
If more options would be added a [Flags] enum
would be more suitable.
Here are some test objects to work with :
public interface IAnimal
{
Gender Gender { get; set; }
string TellMeYourGender();
}
public class Dog : IAnimal
{
public Gender Gender { get; set; }
public string Breed { get; set; }
public string TellMeYourGender()
{
return $"My gender is {Gender}";
}
}
public class Cat : IAnimal
{
public Gender Gender { get; set; }
public string TellMeYourGender()
{
return $"My gender is {Gender}";
}
}
public class Person
{
private readonly int _age;
public int Age => _age;
private readonly string _name;
public string Name => _name;
}
public class Employee : Person
{
private readonly string workPlaceName;
public string WorkPlaceName => workPlaceName;
private int workHours;
public int WorkHours => workHours;
}
And here is the example usage of the shown test objects :
static void Main(string[] args)
{
//creating a builder that contains all properties from the class Person.
ImmutableObjectBuilder<Person> humanBuilder = new ImmutableObjectBuilder<Person>();
//instantiating a new Employee object using the builder.
Employee John = humanBuilder
.WithValue("Name", "John")
.WithValue("Age", 32)
.Build<Employee>();
//instantiating a normal Person object.
Person denis = new Person();
//using the builder to edit the reference of the object.
humanBuilder
.WithValue("Name", "Denis")
.WithValue("Age", 17)
.Build(() => denis);
//trying to edit a Employee specific property will crash
//because the type of the builder is Person thus
//only the properties of Person have been loaded.
Employee employee = humanBuilder
.WithValue("Name", "Denis")
.WithValue("Age", 17)
.WithValue("WorkPlaceName", "SomePlace")
.Build(() => new Employee()); //<- error
//this will work because the builder is of type Employee
//thus it will load all the properties from Employee.
Employee workingEmployee = new ImmutableObjectBuilder<Employee>()
.WithValue("Name", "John")
.WithValue("Age", 32)
.WithValue("WorkPlaceName", "SomePlace")
.Build<Employee>();
//creating a builder that contains all properties from the interface IAnimal.
ImmutableObjectBuilder<IAnimal> animalBuilder = new ImmutableObjectBuilder<IAnimal>();
//instantiating a new Dog object using the builder.
Dog dog = animalBuilder
.WithValue("Gender", Gender.Female)
.Build<Dog>();
//instantiating a normal cat object.
IAnimal cat = new Cat();
//using the builder to edit the reference of the object.
animalBuilder
.WithValue("Gender", Gender.Female)
.Build(() => cat);
//trying to edit a Employee specific property will crash
//because the type of the builder is Person thus
//only the properties of Person have been loaded.
Dog dogo = animalBuilder
.WithValue("Gender", Gender.Male)
.WithValue("Breed", "German Shepard")
.Build(() => new Dog()); //<- error
}
WithValue(t -> t.Name = "Denis")
? \$\endgroup\$WithValue(t -> t.Name, "Denis")
- and inspect the Expression to extract the property name. \$\endgroup\$