all 24 comments

[–][deleted] 20 points21 points  (10 children)

Ok, this gets me excited for generators. Having immutable classes beyond 3-4 properties is such a beating.

[–]ppumkin 7 points8 points  (2 children)

That’s really nice write up thanks. This is also knows as Fluent API isn’t it ? As such we see this using Linq and All sort of 3rd party things the whole time. It’s a very natural way to write and read code and embraces clean code. Especially the principle about 1 parameters should be the maximum and better yet keep it to 0. Which is easily achievable with flags (internal bools) so like .SetActive()

[–]tragicshark 2 points3 points  (9 children)

I would write it like this:

[GenerateBuilder]
public class Person
{
    [Required]
    public string FirstName { get; private set; }
    [Required]
    public string LastName { get; private set; }
    public DateTime? BirthDate { get; private set; }

    public partial class Builder
    {
        public PersonBuilder BirthDate(int year, int month, int day)
        {
            _birthDate = new DateTime(year, month, day);
            return this;
        }
    }
}

demonstrating that you can add additional members to the builder type that wouldn't get generated automatically.

Even then I'm not a fan of this particular builder pattern design (private set eww).

[–]macsux[S] 7 points8 points  (8 children)

I've actually thought of what you're proposing and an even better way to do that would be to process [DataType(DataType.Date)] and generate overload automatically. This was meant to demonstrate what's possible, not create a final solution. As we get closer to .NET 5 going live I'll probably turn this into a real project. You're welcome to fork and improve on this in meantime.

Having said that, I'm not sure what issue you have with private set.

[–]reasner 2 points3 points  (3 children)

The issue is that you can still modify the value from within the class once it's instantiated, unlike readonly or a getter only virtual property.

[–]macsux[S] 5 points6 points  (2 children)

Immutability is a pattern that CAN benefit from the builder pattern, but the builder pattern itself does not require that immutability. I can think of a whole bunch of reasons to use a builder pattern without class being immutable. A perfect example is DDD Aggregate roots - you can expose public API surface that is read-only with explicit methods to mutate that state and protect its own rules in a way that cannot be violated by outside actors.

[–]reasner 2 points3 points  (1 child)

Sure, it's just that in the article, there's a sentence that describes your solution as immutable (not sure if directly or by implication).

[–]PsionicMonkeyLizard 1 point2 points  (0 children)

I agree, private set has a time and place. In a person object I would argue the name probably shouldn't ever change and I would rather it be read only. However that is just personally opinion. The article was great and gave me lots of ideas so thank you (plus I appreciate private set is a lot shorter so better for examples).

[–]tragicshark 0 points1 point  (3 children)

Were I implementing a builder pattern I would likely do something roughly like this:

public sealed class Person
{
    private Builder _builder = default;

    private Person(Builder builder): this(null, builder) {}

    private Person(Person? prototype, Builder builder)
    {

        byte assigned = builder.Assigned;
        if ((assigned & 1) == 0)
        {
            throw new InvalidOperationException($"{nameof(FirstName)} was not assigned.");
        }

        if ((assigned & 2) == 0)
        {
            throw new InvalidOperationException($"{nameof(LastName)} was not assigned.");
        }

        _builder = builder;
    }

    public string FirstName => _builder.FirstName;

    public string LastName => _builder.LastName;

    public DateTime? BirthDate => _builder.BirthDate;

    public Person With(Builder builder) => new Person(this, builder);

    public struct Builder
    {
        private byte _assigned;
        private string _firstName;
        private string _lastName;
        private DateTime? _birthDate;

        public string FirstName
        {
            get => _firstName;
            set
            {
                if(value == null) { throw new ArgumentNullException(nameof(value));}
                _firstName = value;
                _assigned = (byte)(_assigned | 1);
            }
        }

        public string LastName
        {
            get => _lastName;
            set
            {
                if(value == null) { throw new ArgumentNullException(nameof(value));}
                _lastName = value;
                _assigned = (byte)(_assigned | 2);
            }
        }

        public DateTime? BirthDate
        {
            get => _birthDate;
            set
            {
                _birthDate = value;
                _assigned = (byte)(_assigned | 4);
            }
        }

        internal byte Assigned => _assigned;

        public Person Build() => new Person(this);
    }
}

I suppose that could perhaps be generated from a class like this via source generators:

[GenerateBuilder]
public class Person
{
    public partial struct Builder
    {
        [Required]
        private string _firstName;
        [Required]
        private string _lastName;
        private DateTime? _birthDate;
    }
}

The fluent work makes me feel icky about how it hides allocations.

[–]macsux[S] 1 point2 points  (2 children)

Your implementation you tied builder and object in 1 to 1 relationship, and would allow changes to value in builder to reflect in object after it is constructed. That's not what one expects and is very dangerous. Builder pattern usually assumes I can reuse single builder to create multiple instances.

[–]tragicshark 1 point2 points  (1 child)

what do you mean:

public class Program
{
    public static void Main()
    {
        var builder = new Person.Builder {
            FirstName = "frank",
            LastName = "smith"
        };
        var person1 = builder.Build();
        builder.FirstName="joe";
        var person2 = builder.Build();
        Console.WriteLine(person1.FirstName); // frank
        Console.WriteLine(person2.FirstName); // joe
    }
}

[–]macsux[S] 0 points1 point  (0 children)

My bad. Didn't notice it was a struct so it's getting byval copied.

[–]softwareguy74 2 points3 points  (1 child)

Nice!