andrewlock / StronglyTypedId

A Rosyln-powered generator for strongly-typed IDs
MIT License
1.49k stars 77 forks source link

[Proposal] Consider optional support for `class` vs. `struct` #46

Open prlcutting opened 2 years ago

prlcutting commented 2 years ago

Hi Andrew. In your excellent series of blog posts on Strongly Typed Id's, you make mention of some downsides to using a struct to model identity versus a class, due to implicit parameterless constructors. You also cite Vladamir Khorikov's article on the subject who makes further arguments for not choosing a struct for modelling identity and favoring a class. Classes have their downsides too of course, not least of which is having to null-check them all over the place, so they're not a panacea, but if you're trying to follow DDD best practices where protection of invariants is paramount, a class-based option would be desirable.

So, with all that said, have you considered enabling class-based strongly typed identifiers in your library in addition to struct-based ones?

andrewlock commented 2 years ago

Hi @prlcutting, yes, it's definitely something I've considered🙂 It's just not entirely clear to me that it's worth the hassle, when you can do something similar with record types these days, e.g.

public record OrderId(int Value);

This is something Thomas Levesque discusses on his blog, though things are even easier with c#10 as you can add sealed to the ToString() implementation, which means you don't even need the source generator part, you can do it all with a base-type.

I considered whether you can do the same thing with struct record and make this library obsolete, but the lack of inheritance means you're still stuck with a lot of boilerplate, so I think this still adds value.

If the record approach doesn't work for you, I'd be interested to know why. I'm not opposed to adding it as an option, but just want to make sure it makes sense!

prlcutting commented 2 years ago

Thanks for getting back to me @andrewlock, and apologies for the delayed response.

Vladamir has an excellent blog post, C# 9 Records as DDD Value Objects which compares and contrasts C#'s records with a more traditional DDD ValueObject base class approach. Many of the points he raises don't apply in the case of strong typed identifiers where there is a single, scalar value to be encapsulated, but a couple do.

The ability to compare identifiers through an IComparable<T> interface implementation would be helpful, e.g. to provide a stable sort when the data isn't sourced from a database and pre-ordered. But probably my chief concern with using records out of the box would be protecting invariants, e.g. disallowing negative values for numeric based backing values, Guid.Zero in the case of Guid's, etc., assuming they were requirements (which I suspect they often would be). This is what puts me off the struct-based approach, because such invariants cannot be enforced, which undermines the model for the identifier.

You can of course achieve such protection of invariants with records, but now you're back to writing somewhat verbose, repetitive code, which obviates the benefits over traditional classes at that point. Avoiding that verbose, repetitive code could be aided by refactoring into a base class as you suggest, or generated automatically with a source generator. It would of course assume that the code generation could be configured (perhaps with additional attribute "options") to configure min/max bounds etc.

Your challenge on my proposal made me think about this a little more deeply, and I'm grateful for that! Perhaps at this point, in the context of strongly typed identifiers, the question is more generalized to: which is better, a class hierarchy with most logic refactored into a shared base class, or source generation? I'd be curious if you have any opinions on that. Thanks again.

kyrvlasiuk commented 2 years ago

One reason to choose your library over the record class and record struct is all the converters and interfaces that are included and generated. If it would be possible to use them with records, it would be nice. Considering all the debugging and syntax support they are getting/ going to get. But this is definitely something I can live without. It would be nice to be able to specify any type (or have a composite id) and have converters generated around it for ef core and system.text.json especially. Then it's like

[StronglyTypedId( StronglyTypedIdConverter.SystemTextJson | StronglyTypedIdConverter.EfCoreValueConverter)]
public partial record struct MyId(Int16 Value);
[StronglyTypedId( StronglyTypedIdConverter.SystemTextJson | StronglyTypedIdConverter.EfCoreValueConverter)]
public partial record struct CompositId(string Part1, string Part2);

This is nice-to-haves, affects few categories. Looking good but not worth the effort.

dombrovsky commented 3 weeks ago

As for me, this is a significant issue with this otherwise just awesome library, that reference type ids can be generated.

Main issue is invariants protection: too easy to just pass or accidently have default(StringId) for [StronglyTypedId(Template.String)] struct StringId { }, which would not be same as StringId.Empty, eventually getting NRE later on, instead of getting ArgumentException early.

It would be much more preferable if it was class StringId with constructor that can do necessary precondition checks to ensure that if StringId instance exists, it is sane. And that may not only be null checks, but also that string is in certain format. Reference type ids can be useful for value type underlying ids for that reason too, e.g. to ensure Int32Id has only positive values for example.

C# Records are not an equivalent replacement to this library, because of all the nice features that are coming with code generation, e.g. type converters, static Empty member, implicit cast operators etc.

gorillapower commented 1 week ago

Ive also encountered this problem with the struct default constructor behaviour. I wonder if a simple [Obsolete] attribute could be used to provide a compiler error like so.

public readonly record struct ProductsId
{
    [Obsolete("Cannot be empty", true)]
    public ProductsId() { }

    public ProductsId(string value)
    {
        ArgumentNullException.ThrowIfNullOrEmpty(value);
        //other validation
        this.Value = value;
    }

    public string Value { get; }
}

Which then looks like this at compile time

image

And in saying this, if its possible to add this to the SourceGenerator code for this library? Maybe its already possible with the custom templates feature.

As for me, this is a significant issue with this otherwise just awesome library, that reference type ids can be generated.

Main issue is invariants protection: too easy to just pass or accidently have default(StringId) for [StronglyTypedId(Template.String)] struct StringId { }, which would not be same as StringId.Empty, eventually getting NRE later on, instead of getting ArgumentException early.

It would be much more preferable if it was class StringId with constructor that can do necessary precondition checks to ensure that if StringId instance exists, it is sane. And that may not only be null checks, but also that string is in certain format. Reference type ids can be useful for value type underlying ids for that reason too, e.g. to ensure Int32Id has only positive values for example.

C# Records are not an equivalent replacement to this library, because of all the nice features that are coming with code generation, e.g. type converters, static Empty member, implicit cast operators etc.