-
Notifications
You must be signed in to change notification settings - Fork 1
Version 6.x.x Value Objects
- Requirements
- Getting started
- Validation
-
Customizing
- Custom equality comparer
- Custom comparer (simple value objects only)
- Skip factory methods generation
- Null in factory methods yields null
- Empty-String in factory methods yields null
- Skip implementation of
IComparable
/IComparable<T>
- Implementation of addition operators
- Implementation of subtraction operators
- Implementation of multiply operators
- Implementation of division operators
- Implementation of comparison operators
- Skip implementation of
IParsable<T>
- Skip implementation of
IFormattable
- Changing the name of static property
Empty
- JSON serialization
- MessagePack serialization
- Support for Minimal Web Api Parameter Binding and ASP.NET Core Model Binding
- Support for Entity Framework Core
- Logging
- Real-world use cases and ideas
This library provides some interfaces, classes, Roslyn Source Generators, Roslyn Analyzers and Roslyn CodeFixes for implementation of Value Objects. The library comes with some Roslyn Analyzers and Code Fixes to guide the software developer through the implementation. Furthermore, additional Nuget packages add support for System.Text.Json
, Newtonsoft.Json
, MessagePack
, Entity Framework Core
and ASP.NET Core Model Binding
.
- C# 11 (or higher) for generated code
- SDK 7.0.102 (or higher) for building projects
The value objects described here are divided in 2 categories. Each category is for a specific use case and has different features:
- Simple or keyed value objects are types with 1 field/property, which share a lot of features with a Smart Enum)
- Complex value objects are types with 2 or more fields/properties
When it comes to the number of members then the "assignable" members are taken into consideration only. Read-only properties like
int Value => 42
are ignored.
A simple value object has 1 field/property only, i.e., it is kind of wrapper for another (primitive) type. The main use case is to prevent creation of values/instances which are considered invalid according to some business rules.
A value object can be either a class
or a readonly struct
flagged with ValueObjectAttribute
. Let's take a look at a value object without validation first.
The only property or field of a simple value object (like
Value
in example below) will be called the key member from now on.
[ValueObject]
public sealed partial class ProductName
{
public string Value { get; }
// The member can be a private readoly field as well
//private readonly string _value;
}
After the implementation of the ProductName
, a Roslyn source generator kicks in and implements the rest. Following API is available from now on.
// Factory method for creation of new instances.
// Throws ValidationException if the validation fails
ProductName bread = ProductName.Create("Bread");
// Alternatively, using an explicit cast (behaves the same as with Create)
ProductName bread = (ProductName)"Bread"; // is the same as calling "ProductName.Create"
-----------
// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = ProductName.TryCreate("Milk", out ProductName milk);
-----------
// similar to TryCreate but returns a ValidationResult instead of a boolean.
ValidationResult? validationResult = ProductName.Validate("Milk", out var milk);
if (validationResult == ValidationResult.Success)
{
logger.Information("Product name {Name} created", milk);
}
else
{
logger.Warning("Failed to create product name. Validation result: {ValidationResult}", validationResult.ErrorMessage);
}
-----------
// implicit conversion to the type of the key member
string valueOfTheProductName = bread; // "Bread"
-----------
// Equality comparison with 'Equals'
// which compares the key members using default or custom 'IEqualityComparer<T>'.
// Strings are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = bread.Equals(bread);
-----------
// Equality comparison with '==' and '!='
bool equal = bread == bread;
bool notEqual = bread != bread;
-----------
// Hash code
int hashCode = bread.GetHashCode();
-----------
// 'ToString' implementation
string value = bread.ToString(); // "Bread"
------------
// Implements IParsable<T> which is especially helpful with minimal web apis.
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
bool success = ProductName.TryParse("New product name", null, out var productName);
------------
// Implements "IFormattable" if the key member is an "IFormattable".
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
[ValueObject]
public sealed partial class Amount
{
private readonly int _value;
}
var amount = Amount.Create(42);
string formattedValue = amount.ToString("000", CultureInfo.InvariantCulture); // "042"
------------
// Implements "IComparable<ProductName>" if the key member is an "IComparable"
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var amount = Amount.Create(1);
var otherAmount = Amount.Create(2);
var comparison = amount.CompareTo(otherAmount); // -1
// Implements comparison operators (<,<=,>,>=) if the key member has comparison operators itself.
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var isBigger = amount > otherAmount;
------------
// Implements addition / subtraction / multiplication / division if the key member supports operators
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var sum = amount + otherAmount;
Additionally, the source generator implements a TypeConverter
which are used by some libraries/frameworks like JSON serialization or ASP.NET Core Model Binding.
var typeConverter = TypeDescriptor.GetConverter(typeof(ProductName));
string value = (string)typeConverter.ConvertTo(bread, typeof(string)); // "Bread"
ProductName productName = (ProductName)typeConverter.ConvertFrom("Bread");
Let's take a look at the complex value objects before we get to more realistic use cases.
A complex value object is considered a class
or a readonly struct
with a ValueObjectAttribute
and with more than 1 "assignable" properties/fields. The main use case is to manage multiple values as a whole.
A simple example would be a Boundary
with 2 properties, one is the lower boundary and the other is the upper boundary. Yet again, we skip the validation at the moment.
[ValueObject]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
}
The rest is implemented by a Roslyn source generator, providing the following API:
// Factory method for creation of new instances.
// Throws ValidationException if the validation fails
Boundary boundary = Boundary.Create(lower: 1, upper: 2);
-----------
// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = Boundary.TryCreate(lower: 1, upper: 2, out Boundary boundary);
-----------
// similar to TryCreate but returns a ValidationResult instead of a boolean.
ValidationResult? validationResult = Boundary.Validate(lower: 1, upper: 2, out Boundary boundary);
if (validationResult == ValidationResult.Success)
{
logger.Information("Boundary {Boundary} created", boundary);
}
else
{
logger.Warning("Failed to create boundary. Validation result: {ValidationResult}", validationResult.ErrorMessage);
}
-----------
// Equality comparison with 'Equals'
// which compares the members using default or custom comparers.
// Strings are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = boundary.Equals(boundary);
-----------
// Equality comparison with '==' and '!='
bool equal = boundary == boundary;
bool notEqual = boundary != boundary;
-----------
// Hash code of the members according default or custom comparers
int hashCode = boundary.GetHashCode();
-----------
// 'ToString' implementation
string value = boundary.ToString(); // "{ Lower = 1, Upper = 2 }"
Until now, the value objects were more or less simple classes without added value. Let's add the most important feature of a value object, the validation.
Both, the simple and complex value objects have a partial
method ValidateFactoryArguments
to implement custom validation in. The implementation of ValidateFactoryArguments
should not throw exceptions but use the ValidationResult
.
[ValueObject]
public sealed partial class ProductName
{
public string Value { get; }
static partial void ValidateFactoryArguments(ref ValidationResult? validationResult, ref string value)
{
if (String.IsNullOrWhiteSpace(value))
{
validationResult = new ValidationResult("Product name cannot be empty.",
new[] { nameof(Value) });
return;
}
if (value.Length == 1)
{
validationResult = new ValidationResult("Product name cannot be 1 character long.",
new[] { nameof(Value) });
return;
}
value = value.Trim();
}
}
The implementation of ValidateFactoryArguments
of a complex value object looks very similar.
[ValueObject]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
static partial void ValidateFactoryArguments(ref ValidationResult? validationResult, ref decimal lower, ref decimal upper)
{
if (lower <= upper)
return;
validationResult = new ValidationResult($"Lower boundary '{lower}' must be less than upper boundary '{upper}'",
new[] { nameof(Lower), nameof(Upper) });
}
}
Additionally to the partial
method ValidateFactoryArguments
for validation of factory method arguments there is another partial
method ValidateConstructorArguments
. The method ValidateConstructorArguments
is being called in the private
constructor implemented by the Roslyn source generator.
I highly recommend NOT to use
ValidateConstructorArguments
butValidateFactoryArguments
because a constructor has no other options as to throw an exception, which will result in worse integration with the libraries and frameworks, like JSON serialization, ASP.NET Core model binding/validation and Entity Framework Core.
[ValueObject]
public sealed partial class ProductName
{
public string Value { get; }
static partial void ValidateConstructorArguments(ref string value)
{
// do something
}
}
And the ValidateConstructorArguments
of a complex value object Boundary
.
[ValueObject]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
static partial void ValidateConstructorArguments(ref decimal lower, ref decimal upper)
{
// do something
}
}
By default, the source generator is using the default implementation of Equals
and GetHashCode
, except strings, for all assignable properties and fields for equality comparison and for the hash code. If the property or field is a string, then StringComparer.OrdinalIgnoreCase
is being used for comparisons.
The reason strings are not using EqualityComparer.Default is because I encountered very few use cases where the comparison must be performed case-sensitive. Case-sensitive string comparisons, I encountered in the past, were almost all bugs because the developer have forgotten to pass appropriate comparer.
With ValueObjectMemberEqualityComparerAttribute<TComparer, TMember>
it is possible to change both, the equality comparer and the members being used for comparison and computation of the hash code.
Simple value objects have just 1 member, we can put the equality comparer on.
[ValueObject]
public sealed partial class ProductName
{
[ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
public string Value { get; }
}
First generic parameter of ValueObjectMemberEqualityComparerAttribute
is an implementation of IEqualityComparerAccessor<T>
which provides the equality comparer to use.
public interface IEqualityComparerAccessor<in T>
{
static abstract IEqualityComparer<T> EqualityComparer { get; }
}
You can implement your own IEqualityComparerAccessor
or use one of the predefined accessors on static class ComparerAccessors
:
// Example of a custom implementation
public class StringOrdinal : IEqualityComparerAccessor<string>
{
public static IEqualityComparer<string> EqualityComparer => StringComparer.Ordinal;
}
// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
With complex types it is getting more complex...
By putting the ValueObjectMemberEqualityComparerAttribute
on 1 member only means that other members don't take part in the equality comparison!
[ValueObject]
public sealed partial class Boundary
{
// The equality comparison uses `Lower` only!
[ValueObjectMemberEqualityComparer<ComparerAccessors.Default<decimal>, decimal>]
public decimal Lower { get; }
public decimal Upper { get; }
}
To use all assignable properties in comparison, either don't use ValueObjectMemberEqualityComparerAttribute
at all or put it on all members.
[ValueObject]
public sealed partial class Boundary
{
[ValueObjectMemberEquality<ComparerAccessors.Default<decimal>, decimal>]
public decimal Lower { get; }
[ValueObjectMemberEquality<ComparerAccessors.Default<decimal>, decimal>]
public decimal Upper { get; }
}
A custom implementation of IComparer<T>
can be specified on key members only, i.e. having a simple value object. A complex value object doesn't implement the method CompareTo
.
Please note that this section is about implementation of
IComparable<T>
andIComparer<T>
. Don't confuse theIComparer<T>
withIEqualityComparer<T>
which is being used for equality comparison and the computation of the hash code.
Use ValueObjectMemberComparerAttribute
to specify a comparer.
[ValueObject]
public sealed partial class ProductName
{
[ValueObjectMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
public string Value { get; }
}
First generic parameter of the ValueObjectMemberComparerAttribute
is an implementation of IComparerAccessor<T>
which provides the comparer to use.
You can implement your own IComparerAccessor
or use one of the predefined accessors on static class ComparerAccessors
// Example of a custom implementation
public class StringOrdinal : IComparerAccessor<string>
{
public static IComparer<string> Comparer => StringComparer.OrdinalIgnoreCase;
}
// Predefined:
ComparerAccessors.StringOrdinal;
ComparerAccessors.StringOrdinalIgnoreCase;
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
It is possible to skip the generation of the factory methods Create
/TryCreate
/Validate
but this comes with a price. Some features like JSON (de)serialization or ASP.NET Core model binding depend on the factory methods. If there are no factory methods then neither JSON converter nor ASP.NET Core model binder are going to be implemented.
[ValueObject(SkipFactoryMethods = true)]
public sealed partial class ProductName
{
public string Value { get; }
}
By default, providing null
to methods Create
and TryCreate
of a keyed value object is not allowed. If property NullInFactoryMethodsYieldsNull
is set to true
, then providing a null
will return null
.
[ValueObject(NullInFactoryMethodsYieldsNull = true)]
public sealed partial class ProductName
{
public string Value { get; }
}
Similar as with NullInFactoryMethodsYieldsNull described above, but for empty strings.
If this property is set to true
then the factory methods Create
and TryCreate
will return null
if they are provided null
, an empty string or a string containing white spaces only.
[ValueObject(EmptyStringInFactoryMethodsYieldsNull= true)]
public sealed partial class ProductName
{
public string Value { get; }
}
Use ValueObjectAttribute
to set SkipIComparable
to true
to disable the implementation of IComparable
and IComparable<T>
.
[ValueObject(SkipIComparable = true)]
public sealed partial class ProductGroup
{
Use ValueObjectAttribute
to set AdditionOperators
to OperatorsGeneration.None
to disable the implementation of addition operators: +
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to perform addition of a Value Object with a value of the underlying type.
[ValueObject(AdditionOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{
Use ValueObjectAttribute
to set SubtractionOperators
to OperatorsGeneration.None
to disable the implementation of addition operators: -
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to perform subtraction of a Value Object with a value of the underlying type.
[ValueObject(SubtractionOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{
Use ValueObjectAttribute
to set MultiplyOperators
to OperatorsGeneration.None
to disable the implementation of addition operators: *
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to perform multiplication of a Value Object with a value of the underlying type.
[ValueObject(MultiplyOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{
Use ValueObjectAttribute
to set DivisionOperators
to OperatorsGeneration.None
to disable the implementation of addition operators: /
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to perform division of a Value Object with a value of the underlying type.
[ValueObject(DivisionOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{
Use ValueObjectAttribute
to set ComparisonOperators
to OperatorsGeneration.None
to disable the implementation of comparison operators: >
, >=
, <
, <=
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to compare a Value Object with a value of the underlying type.
[ValueObject(ComparisonOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{
Use ValueObjectAttribute
to set SkipIParsable
to true
to disable the implementation of IParsable<T>
.
[ValueObject(SkipIParsable = true)]
public sealed partial class ProductGroup
{
Use ValueObjectAttribute
to set SkipIFormattable
to true
to disable the implementation of IFormattable
.
[ValueObject(SkipIFormattable = true)]
public sealed partial class ProductGroup
{
Use ValueObjectAttribute
to set SkipToString
to true
to disable the implementation of the method ToString()
.
[ValueObject(SkipToString = true)]
public sealed partial class ProductGroup
{
For structs
only.
[ValueObject(DefaultInstancePropertyName= "None")]
public readonly partial struct ProductNameStruct
{
public string Value { get; }
}
// Usage
var none = ProductNameStruct.None; // instead of ProductNameStruct.Empty
Depending on the concrete JSON library you use, you need a different Nuget package:
There are 2 options to make the Value Objects JSON convertible.
The easiest way is to make Thinktecture.Runtime.Extensions.Json / Thinktecture.Runtime.Extensions.Newtonsoft.Json a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but transitive as well.
Both Nuget packages come with another Roslyn source generator, which implements a JSON converter and flags the value object with a JsonConverterAttribute
. This way the value object can be converted to and from JSON without extra code.
For simple value objects only. At the moment, there is no generic JSON converter for complex value objects, so Option 1 is the only option for now.
If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register a JSON converter with JSON serializer settings. By using a JSON converter directly, the Nuget package can be installed in any project where the JSON settings are configured.
- Use
ValueObjectJsonConverterFactory
withSystem.Text.Json
- Use
ValueObjectNewtonsoftJsonConverter
withNewtonsoft.Json
An example for ASP.NET Core application using System.Text.Json
:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddJsonOptions(options => options.JsonSerializerOptions
.Converters
.Add(new ValueObjectJsonConverterFactory()));
})
An example for minimal web apis:
var builder = WebApplication.CreateBuilder();
builder.Services
.ConfigureHttpJsonOptions(options => options.SerializerOptions
.Converters
.Add(new ValueObjectJsonConverterFactory()));
The code for Newtonsoft.Json
is almost identical:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddNewtonsoftJson(options => options.SerializerSettings
.Converters
.Add(new ValueObjectNewtonsoftJsonConverter()));
})
There are 2 options to make the value objects MessagePack serializable.
The easiest way is to make Thinktecture.Runtime.Extensions.MessagePack a dependency of the project(s) the value objects are in. The dependency doesn't have to be a direct one but transitive as well.
The Nuget package comes with another Roslyn source generator, which implements a MessagePack formatter and flags the value object with a MessagePackFormatterAttribute
. This way the value object can be serialized to and from MessagePack without extra code.
For simple value objects only. At the moment, there is no generic MessagePack formatter for complex value objects, so Option 1 is the only option for now.
If making previously mentioned Nuget package a dependency of project(s) with value objects is not possible or desirable, then the other option is to register the MessagePack formatter with MessagePack serializer options. By using the ValueObjectMessageFormatterResolver
directly, the Nuget package can be installed in any project where the MessagePack options are configured.
An example of a round-trip-serialization of the value object ProductName
:
// Use "ValueObjectMessageFormatterResolver.Instance"
var resolver = CompositeResolver.Create(ValueObjectMessageFormatterResolver.Instance, StandardResolver.Instance);
var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);
ProductName productName = ProductName.Create("Milk");
// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(productName, options, CancellationToken.None);
// Deserialize from MessagePack
var deserializedProductName = MessagePackSerializer.Deserialize<ProductName>(bytes, options, CancellationToken.None);
Having JSON convertible value objects is just half of the equation. If a value of a simple value object is received as a query parameter, then there is no JSON conversion in play but ASP.NET Core Model Binding. Besides model binding, i.e., conversion from query string to a value object, there is model validation as well.
ASP.NET Core Model Binding is for simple value objects only. A complex value object has more than 1 property/field, so, deserialization from a
string
to 2+ members is a case for JSON (de)serialization.
The parameter binding of Minimal Web Apis in .NET 7 is still quite primitive in comparison to the model binding of MVC controllers. To make a type bindable it has to implement either TryParse
or BindAsync
. A simple Value Object implements TryParse
(interface IParsable<T>
) by default, so it can be used with Minimal Web Apis without any changes.
At the moment, all means (i.e. TryParse
and BindAsync
) doesn't allow to pass custom validation errors to be returned to the client. The only information we can pass is an indication whether the parameter could be bound or not.
ASP.NET MVC gives us more control during model binding. For example, if we expect from client a ProductName
and receive the value A
, which is rejected by the validation, then the ASP.NET Core ModelState
must be invalid. In this case we can reject (or let ApiControllerAttribute reject) the request.
By rejecting the request, the client gets the status code BadRequest (400)
and the error:
{
"productName": [
"Product name cannot be 1 character long."
]
}
To help out the Model Binding we have to register the ValueObjectModelBinderProvider
with ASP.NET Core. By using the custom model binder, the Nuget package can be installed in any project where ASP.NET Core is configured.
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc(options => options.ModelBinderProviders
.Insert(0, new ValueObjectModelBinderProvider()));
})
Starting with Entity Framework Core 2.1 we've got the feature Value Conversion. By providing a value converter, the EF Core can convert a simple value object (like ProductName
) to a primitive type (like string
) before persisting the value and back to value object when reading the value from database.
The registration of a value converter can be done manually by using one of the method overloads of HasConversion
in OnModelCreating
.
// Entity
public class Product
{
// other properties...
public ProductName Name { get; private set; }
}
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.Property(p => p.Name)
.HasConversion(name => (string)name,
s => ProductName.Create(s));
});
}
}
Entity Framework Core value conversion is for simple value objects only. Treating a complex value object as an owned entity is more suitable than pressing multiple members into 1 column.
// Entity
public class Product
{
// other properties...
public Boundary Boundary { get; private set; }
}
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.OwnsOne(p => p.Boundary,
boundaryBuilder =>
{
boundaryBuilder.Property(b => b.Lower).HasColumnName("Lower");
boundaryBuilder.Property(b => b.Upper).HasColumnName("Upper");
});
});
}
}
Alternatively, you can install the appropriate Nuget package for EF Core 5, EF Core 6 or EF Core 7 and use the extension method AddEnumAndValueObjectConverters
to register the value converters for you.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddEnumAndValueObjectConverters();
}
}
You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.
modelBuilder.AddEnumAndValueObjectConverters(
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
});
The other options is to use the extension method UseValueObjectValueConverter
for the DbContextOptionsBuilder
.
services
.AddDbContext<DemoDbContext>(builder => builder
.UseValueObjectValueConverter(validateOnWrite: true,
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
})
Logging can be activated in the csproj-file. Define the property ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath
pointing to an existing(!) folder (like C:\temp\
). You can provide a file name (like samples_logs.txt
) which is being used as a template for creation of a unique log file name like samples_logs_20230322_220653_19c0d6c18ec14512a1acf97621912abb.txt
.
Please note, that there will be more than 1 log file (per project) because IDEs (Rider/VS) usually create 1 Source Generator for constant running in the background, and 1 for each build/rebuild of a project. Unless,
ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique
is set tofalse
.
With ThinktectureRuntimeExtensions_SourceGenerator_LogLevel
you can specify one of the following log levels: Trace
, Debug
, Information
(DEFAULT), Warning
, Error
.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
...
<ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>C:\temp\samples_logs.txt</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>
<ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>information</ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>
<ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>false</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>
</PropertyGroup>
If the logger throws an exception, for example due to insufficient file system access permissions, then the logger will try to write the exception into a temp file. You can find the file ThinktectureRuntimeExtensionsSourceGenerator.log
in the temp folder of the user the IDE/CLI is running with.
I started to write down some examples I used in the past to show the developers the benefits of value objects and smart enums.
More examples will come very soon!
There are multiple ways to implement an end date with open-end. All of them have their pros and cons.
Here are the most popular approaches I encountered in the past:
-
Use nullable
DateOnly?
(orDateTime?
)-
PRO: Better semantics, i.e.
null
means there is no end date. The default value ofDateOnly?
isnull
as well, which results in expected behavior. -
CON: (LINQ) queries must check for both
null
and a concrete date, i.e.query.Where(i => i.MyEndDate is null || i.MyEndDate > now)
. Using such query with a database usually results in worse performance because||
/OR
prevents the database from using an appropriate index.
-
PRO: Better semantics, i.e.
-
Use
DateOnly.MaxValue
(orDateTime
)-
PRO: The condition in the (LINQ) query is straight-forward
query.Where(i => i.MyEndDate > now)
. If this query is executed on a database then the database is able to use an appropriate index which result in better performance. -
CON: Using a special value like
DateOnly.MaxValue
to represent an open-ended date results in worse semantics. -
CON: The main culprit is the keyword
default
or the default value of aDateOnly
(orDateTime
), which isDateOnly.MinValue
. If the property/field/variable is not assigned explicitly and staysDateOnly.MinValue
, then this most likely will lead to an undesired behavior. In this situation I would like to have an open-ended end date instead of the date0001-01-01
, which is an invalid end date in the most use cases.
-
PRO: The condition in the (LINQ) query is straight-forward
The desired solution must:
- not require
OR
in queries to improve performance - have a default value which represents open-ended end date
An always-valid value object EndDate
which is a readonly struct
.
[ValueObject(DefaultInstancePropertyName = "Infinite", // "EndDate.Infinite" represent an open-ended end date
EqualityComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)] // for comparison with DateOnly without implicit cast
public readonly partial struct EndDate
{
// Source Generator should work with the property "Date" only and ignore this backing field
[ValueObjectMemberIgnore]
private readonly DateOnly? _date;
// can be public as well
private DateOnly Date
{
get => _date ?? DateOnly.MaxValue;
init => _date = value;
}
// Further validation
// static partial void ValidateFactoryArguments(ref ValidationResult? validationResult, ref DateOnly date)
// {
// validationResult = date.Year switch
// {
// < 2000 => new ValidationResult("The end date lies too far in the past."),
// >= 2050 => new ValidationResult("The end date lies too far in the future."),
// _ => validationResult
// };
// }
}
Basic usage (see also ValueObjectDemos.cs) is virtually the same as with DateOnly
or DateTime
.
// Create an EndDate
DateOnly today = DateOnly.FromDateTime(DateTime.Now);
EndDate endDate = (EndDate)today;
EndDate endDate = EndDate.Create(today); // alternative
// Compare the dates
var isTrue = EndDate.Infinite > endDate;
// Default value is equal to infinite date and equal to "DateOnly.MaxValue"
var defaultEndDate = default(EndDate);
var infiniteEndDate = EndDate.Infinite;
isTrue = infiniteEndDate == defaultEndDate;
// Get the actual date if needed
DateOnly dateOfDefaultDate = defaultEndDate;
DateOnly dateOfInfiniteDate = infiniteEndDate;
isTrue = dateOfDefaultDate == dateOfInfiniteDate;
// Compare DateOnly with EndDate
isTrue = EndDate.Infinite == dateOfDefaultDate
Use EndDate
with Entity Framework Core (see also Product.cs, EF-Demos and Support for Entity Framework Core)
Please note that
DateOnly
is not supported in EF Core 7 but will be in EF Core 8. I use the library ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly in my demos.
// Entity
public class Product
{
...
public EndDate EndDate { get; set; }
}
// query
var today = (EndDate)DateOnly.FromDateTime(DateTime.Today);
var products = await ctx.Products
.Where(p => p.EndDate >= today)
.ToListAsync();
Use EndDate
with ASP.NET Core controllers (see also DemoController.cs) and minimal web api (see also minimal web api demo).
Read the section "Support for Minimal Web Api Parameter Binding and ASP.NET Core Model Binding" to get more information.
// Controller
[Route("api"), ApiController]
public class DemoController : Controller
{
[HttpGet("enddate/{endDate}")]
public IActionResult RoundTripGet(EndDate endDate)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
return Json(endDate);
}
[HttpPost("enddate")]
public IActionResult RoundTripPost([FromBody] EndDate endDate)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
return Json(endDate);
}
}
// Minimal web api
var app = builder.Build();
var routeGroup = app.MapGroup("/api");
routeGroup.MapGet("enddate/{date}", (EndDate date) => date);
routeGroup.MapPost("enddate", ([FromBody] EndDate date) => date);
The response is the same in both cases.
GET api/enddate/2023-04-05
and
POST api/enddate
with body "2023-04-05"
returns "2023-04-05"
Value objects are excellent for checking some kind of invariants. In one of my use cases I had to perform a calculation of moderate complexity and the result and all of the partial results must always be positive.
We could use a plain decimal
and check the (partial) result after every(!) arithmetic operation, but it requires more code and is difficult to read an to maintain. Instead, we switched from decimal to a readonly struct Amount
which checks the invariant automatically.
[ValueObject(DefaultInstancePropertyName = "Zero", // renames Amount.Empty to Amount.Zero
ComparisonOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads, // for comparison of amount with a decimal without implicit conversion: amount > 42m
AdditionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads, // for arithmetic operations of amount with a decimal without implicit conversion: amount + 42m
SubtractionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,
MultiplyOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads,
DivisionOperators = OperatorsGeneration.DefaultWithKeyTypeOverloads)]
public readonly partial struct Amount
{
private readonly decimal _value;
static partial void ValidateFactoryArguments(ref ValidationResult? validationResult, ref decimal value)
{
if (value < 0)
validationResult = new ValidationResult("Amount must be positive.");
}
}
The usage is the same as with a plain decimal
.
// get an instance of amount with Create/TryCreate/Validate or an explicit cast
var amount = Amount.Create(1);
var otherAmount = (Amount)2;
var zero = Amount.Zero;
// equality comparisons
amount == zero; // false
amount > otherAmount; // false
amount > 42; // false
amount.CompareTo(otherAmount); // -1
// arithmetic operations
amount + otherAmount; // 3
amount + 42 // 43