Vogen icon indicating copy to clipboard operation
Vogen copied to clipboard

Vogen doesn't always play nice with nullable ValueObject properties

Open Steve-OH opened this issue 7 months ago • 2 comments

I know that we've gone through all of this nullability stuff before, but I just ran into another related issue that includes something I don't quite understand.

Let's say I have a value object based on string:

[ValueObject<string>]
public partial record Foo
{
    ...
}

Elsewhere in the code, there is a class with a property of type Foo?:

public class Bar
{
    public Foo? Foo { get; set; }
}

Let's also say that I have a global directive that (for now) is set to default:

[assembly: VogenDefaults(deserializationStrictness: DeserializationStrictness.Default)]

Using the System.Text.Json serializer/deserializer, this works fine. I can serialize with Foo = null, and deserialize with null in the JSON.

I change my global defaults to disallow nulls:

[assembly: VogenDefaults(deserializationStrictness: DeserializationStrictness.DisallowNulls)]

Now, as expected, I get errors both on deserialization and serialization, System.Text.Json.JsonException and System.NullReferenceException, respectively. The null reference exception seems odd, but whatever, we'll just accept it for what it is.

So what that global directive is saying is that I want nulls to be disallowed in general. However, because this particular property is explicitly declared as being nullable, I obviously do want to allow nulls here. Ideally, declaring the property as nullable ought to be enough, but it clearly isn't, and I suspect that because of the way that serialization and deserialization work, it's not possible to make it work that simply.

And thus my first attempt at a workaround is to override the strictness at the Foo level:

[ValueObject<string>(deserializationStrictness: DeserializationStrictness.Default]
public partial record Foo

And this is where I'm surprised: This doesn't work. Even though Default worked perfectly fine globally, using it at the class/record level doesn't, and I don't have an explanation for that.

This does work, however (although again I don't really understand why):

[ValueObject<string>(deserializationStrictness: DeserializationStrictness.AllowKnownInstances]
public partial record Foo

Anyway, getting back to the original reason I went down this rabbit hole: when I declare the property as type Foo?, what I'm saying (or, at least, I want to be saying) is that I want to restrict the property's value to either (a) null or (b) a non-null value of type Foo. But it doesn't look like that's possible. The end result is that my two choices are either to go full strictness and fully disallow nulls, or else allow nulls at the value object level by overriding the global strictness. When I do the latter, then declaring the property to be of type Foo or type Foo? doesn't matter; the lack of strictness allows nulls in either case. That mythical happy place where Foo? allows nulls and Foo does not remains out of reach.

Steve-OH avatar May 05 '25 20:05 Steve-OH

Thanks for the feedback. I'll initially take a look at the difference in behaviour between local and global configuration. I tend to avoid this by treating values in data transfer objects as primitives and leaving it to the domain layer to decide what to do.

SteveDunn avatar May 27 '25 20:05 SteveDunn

+1 to this issue. Even if we declare DisallowNulls globally, it should respect the nullable operator on the type.

kcadduk avatar Oct 01 '25 14:10 kcadduk