Invariant records #5574
-
Invariant records - value-constrained, non-defaultable recordsA proposal of an idea that I made in another thread. SummaryAn public record struct CustomerId(invariant int Value)
{
// the argument name and type must match an argument name and type from the primary constructor
private static void Validate(int Value) => Invariant.Ensure(Value > 0);
} MotivationTo avoid Primitive Obsession. In my domain, I want to make 100% sure that the domain ideas that I use are valid instances, so I use strongly-typed Value Objects, such as I would like the language to allow me to express these constraints and I would like to prohibit any mechanism that could evade validation, e.g. through the explicit use of Proposal
Some examples: These are OK: a matching validate method with the same name and type of an invariant parameter public record struct Celsius(invariant float Value)
{
private static void Validate(float Value) => Invariant.Ensure(Value >= -273, "cannot be less than absolute zero");
} public record struct Celsius(invariant float Value)
{
private static void Validate(float Value) => Invariant.Ensure(Value >= -273, "cannot be less than absolute zero");
} public record struct WorkingAge(invariant int Value)
{
private static void Validate(int Value) => Invariant.Ensure(Value >= 14 && Value <= 66);
} These are not OK - resulting in compilation errors no corresponding public record struct Celsius(invariant float Value); validate method matches on name, but has a different type public record struct Celsius(invariant float Value)
{
private static void Validate(int Value) => Invariant.Ensure(Value >= -273, "cannot be less than absolute zero");
} validate method matches no invariant parameter public record struct Celsius(invariant float Value)
{
private static void Validate(int Age) => Invariant.Ensure(Age >0);
} I imagine invariant records to be very simple types containing just one parameter, and will primarily be used for wrapping primitives. compilation errors for methods with same signatures public record class Person(invariant string FirstName, string MiddleName, invariant string LastName)
{
private static void Validate(string FirstName) => Invariant.Ensure(!string.IsNullOrEmpty(FirstName), "must have a value");
private static void Validate(string LastName) => Invariant.Ensure(!string.IsNullOrEmpty(LastName), "must have a value");
} We could get around this by changing the Validate method to be an instance method which has access to the contents of the record: public record class Person(invariant string FirstName, string MiddleName, invariant string LastName)
{
private void Validate() =>
Invariant.Ensure(!string.IsNullOrEmpty(FirstName), "must have a value")
.Ensure(!string.IsNullOrEmpty(LastName), "must have a value");
} But I would prefer to restrict it to just one parameter; composite types could be made up from individual invariant records, e.g. public record Employee(Name firstName, Name lastName, WorkingAge age); I've used public readonly struct Invariant
{
public Invariant And(bool condition, [CallerArgumentExpression("condition")] string? expression = null) =>
Ensure(condition, expression);
private static Invariant Ensure(bool condition, [CallerArgumentExpression("condition")]string? expression = null)
{
if (!condition) throw new Exception($"Invariant failed, expected: {expression}");
return default;
}
} I envisage this to be used simply as a means to wrap primitives. I'm aware that using records might encourage or infer more complex usage, but I'm also aware of the complexities of introducing a new concept into the language, such as public primitive CustomerId(int Value) ... ... where a I think anything that can be added to the language to make Value Objects easier to use and to steer people away from Primitive Obsession would be a good thing. |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments 7 replies
-
I've recently been working on a source generator and analyzer that helps with non-default value objects. It's called Vogen (Value Object Generator). You decorate your [ValueObject(typeof(int))]
public partial struct CustomerId {
// optional
private static Validation Validate(int value) => value > 0
? Validation.Ok
: Validation.Invalid("Customer IDs must be a positive number.");
} Usage looks like: var id1 = CustomerId.From(123);
// error VOG009: Type 'CustomerId' cannot be constructed with default as it is prohibited.
CustomerId c = default;
// error VOG009: Type 'CustomerId' cannot be constructed with default as it is prohibited.
var c2 = default(CustomerId); ... and if we try to circumvent validation by adding other constructors, we get: [ValueObject(typeof(int))]
public partial struct CustomerId {
// Vogen already generates this as a private constructor:
// error CS0111: Type 'CustomerId' already defines a member called 'CustomerId' with the same parameter type
public CustomerId() { }
// error VOG008: Cannot have user defined constructors, please use the From method for creation.
public CustomerId(int value) { }
} |
Beta Was this translation helpful? Give feedback.
-
This falls under compile time execution/contract. It's just a subtype of contract. |
Beta Was this translation helpful? Give feedback.
-
What about arrays, collections etc? I do not really see how this addresses that problem. What about structs containing other structs? FirstOrDefault pattern, TryGetValue pattern? There is more than one way to make a default value. Struct wrappers overall have this problem and in my opinion classes in .Net give a better protection if I can name it like this. In case of not using unsafe or runtimehelpers or whatever magic things it's either valid or it is a null. So in a sense even a null is a safer value than default struct. |
Beta Was this translation helpful? Give feedback.
Support for validation is missing in records because the language team decided that such policy should not be built into the language, but provided through external means like source generators. This is why all of the members otherwise auto-generated by records can also be provided through explicit code in a
partial
class. I'd suggest that your source generator would remain the correct avenue to implement that logic.