Fight primitive obsession and create expressive domain models with source generators.
PM> Install-Package AndreasDorfer.BaseTypes -Version 1.6.0
A succinct way to create wrappers around primitive types with records and source generators.
using AD.BaseTypes;
using System;
Rating ok = new(75);
try
{
Rating tooHigh = new(125);
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.Message);
//> Parameter must be less than or equal to 100. (Parameter 'value')
//> Actual value was 125.
}
[MinMaxInt(0, 100)] partial record Rating;
//the source generator creates the rest of the recordConsider the following snippet:
class Employee
{
public string Id { get; }
public string DepartmentId { get; }
//more properties
public Department GetDepartment() =>
departmentRepository.Load(DepartmentId);
}
interface IDepartmentRepository
{
Department Load(string id);
}Both the employee's ID and the associated department's ID are modeled as strings ... although they are logically separate and must never be mixed. What if you accidentally use the wrong ID in GetDepartment?
public Department GetDepartment() =>
departmentRepository.Load(Id);Your code still compiles. Hopefully, you've got some tests to catch that bug. But why not utilize the type system to prevent that bug in the first place?
You can use records like single case discriminated unions:
sealed record EmployeeId(string Value);
sealed record DepartmentId(string Value);
class Employee
{
public EmployeeId Id { get; }
public DepartmentId DepartmentId { get; }
//more properties
public Department GetDepartment() =>
departmentRepository.Load(DepartmentId);
}
interface IDepartmentRepository
{
Department Load(DepartmentId id);
}Now, you get a compiler error when you accidentally use the employee's ID instead of the department's ID. Great! But there's more bugging me: both the employee's and the department's ID must not be empty. The records could reflect that constraint like this:
sealed record EmployeeId
{
public EmployeeId(string value)
{
if(string.IsNullOrEmpty(value)) throw new ArgumentException("must not be empty");
Value = value;
}
public string Value { get; }
}
sealed record DepartmentId
{
public DepartmentId(string value)
{
if(string.IsNullOrEmpty(value)) throw new ArgumentException("must not be empty");
Value = value;
}
public string Value { get; }
}You get an ArgumentException whenever you try to create an empty ID. But that's a lot of boilerplate code. There sure is a solution to that:
With AD.BaseTypes you can write the records like this:
[NonEmptyString] partial record EmployeeId;
[NonEmptyString] partial record DepartmentId;That's it! All the boilerplate code is generated for you. Here's what the generated code for EmployeeId looks like:
[TypeConverter(typeof(BaseTypeTypeConverter<EmployeeId, string>))]
[JsonConverter(typeof(BaseTypeJsonConverter<EmployeeId, string>))]
sealed partial record EmployeeId : IComparable<EmployeeId>, IComparable, IBaseType<string>
{
readonly string value;
public EmployeeId(string value)
{
new NonEmptyStringAttribute().Validate(value);
this.value = value;
}
string IBaseType<string>.Value => value;
public override string ToString() => value.ToString();
public int CompareTo(object? obj) => CompareTo(obj as EmployeeId);
public int CompareTo(EmployeeId? other) => other is null ? 1 : Comparer<string>.Default.Compare(value, other.value);
public static explicit operator string(EmployeeId item) => item.value;
public static EmployeeId Create(string value) => new(value);
}Let's say you need to model a name that's from 1 to 20 characters long:
[MinMaxLengthString(1, 20)] partial record Name;Or you need to model a serial number that must follow a certain pattern:
[RegexString(@"^\d\d-\w\w\w\w$")] partial record SerialNumber;The included attributes are:
BoolAttribute: anyboolDateTimeAttribute: anyDateTimeDateTimeOffsetAttribute: anyDateTimeOffsetDecimalAttribute: anydecimalDoubleAttribute: anydoubleGuidAttribute: anyGuidIntAttribute: anyintMaxIntAttribute:ints less than or equal to a maximal valueMaxLengthStringAttribute:strings with a maximal character countMinIntAttribute:ints greater than or equal to a minimal valueMinLengthStringAttribute:strings with a minimal character countMinMaxIntAttribute:ints within a rangeMinMaxLengthStringAttribute:strings with a character count within a rangeNonEmptyGuidAttribute: anyGuidthat's not emptyNonEmptyStringAttribute: anystringthat's not null and not emptyPositiveDecimalAttribute: positivedecimalsRegexStringAttribute:strings that follow a certain patternStringAttribute: anystringthat's not null
There are examples in the test code.
The generated types are transparent to the serializer. They are serialized like the types they wrap.
You can create custom attributes. Let's say you need a DateTime only for weekends:
[AttributeUsage(AttributeTargets.Class)]
class WeekendAttribute : Attribute, IBaseTypeValidation<DateTime>
{
public void Validate(DateTime value)
{
if (value.DayOfWeek != DayOfWeek.Saturday && value.DayOfWeek != DayOfWeek.Sunday)
throw new ArgumentOutOfRangeException(nameof(value), value, "must be a Saturday or Sunday");
}
}
[Weekend] partial record SomeWeekend;You can apply multiple attributes:
[AttributeUsage(AttributeTargets.Class)]
class YearsAttribute : Attribute, IBaseTypeValidation<DateTime>
{
readonly int from, to;
public YearsAttribute(int from, int to)
{
this.from = from;
this.to = to;
}
public void Validate(DateTime value)
{
if (value.Year < from || value.Year > to)
throw new ArgumentOutOfRangeException(nameof(value), value, $"must be from {from} to {to}");
}
}
[Years(1990, 1999), Weekend] partial record SomeWeekendInThe90s;The validations happen in the same order as you've applied the attributes. Here's what the generated code for SomeWeekendInThe90s looks like:
[TypeConverter(typeof(BaseTypeTypeConverter<SomeWeekendInThe90s, DateTime>))]
[JsonConverter(typeof(BaseTypeJsonConverter<SomeWeekendInThe90s, DateTime>))]
sealed partial record SomeWeekendInThe90s : IComparable<SomeWeekendInThe90s>, IComparable, IBaseType<DateTime>
{
readonly DateTime value;
public SomeWeekendInThe90s(DateTime value)
{
new YearsAttribute(1990, 1999).Validate(value);
new WeekendAttribute().Validate(value);
this.value = value;
}
DateTime IBaseType<DateTime>.Value => value;
public override string ToString() => value.ToString();
public int CompareTo(object? obj) => CompareTo(obj as SomeWeekendInThe90s);
public int CompareTo(SomeWeekendInThe90s? other) => other is null ? 1 : Comparer<DateTime>.Default.Compare(value, other.value);
public static explicit operator DateTime(SomeWeekendInThe90s item) => item.value;
public static SomeWeekendInThe90s Create(DateTime value) => new(value);
}Do you use FsCheck? Check out AD.BaseTypes.Arbitraries.
PM> Install-Package AndreasDorfer.BaseTypes.Arbitraries -Version 1.6.0
[MinMaxInt(Min, Max), BaseType(Cast.Implicit)]
partial record ZeroToTen
{
public const int Min = 0, Max = 10;
}
const int MinProduct = ZeroToTen.Min * ZeroToTen.Min;
const int MaxProduct = ZeroToTen.Max * ZeroToTen.Max;
MinMaxIntArbitrary<ZeroToTen> arb = new(ZeroToTen.Min, ZeroToTen.Max);
Prop.ForAll(arb, arb, (a, b) =>
{
var product = a * b;
return product >= MinProduct && product <= MaxProduct;
}).QuickCheckThrowOnFailure();The included arbitraries are:
BoolArbitraryDateTimeArbitraryDateTimeOffsetArbitraryDecimalArbitraryDoubleArbitraryExampleArbitraryGuidArbitraryIntArbitraryMaxIntArbitraryMaxLengthStringArbitraryMinIntArbitraryMinLengthStringArbitraryMinMaxIntArbitraryMinMaxLengthStringArbitraryNonEmptyGuidArbitraryNonEmptyStringArbitraryPositiveDecimalArbitraryStringArbitrary
There are examples in the test code.
Do you want to use the generated types in F#? Check out AD.BaseTypes.FSharp. The BaseType and BaseTypeResult modules offer some useful functions.
PM > Install-Package AndreasDorfer.BaseTypes.FSharp -Version 1.6.0
match (1995, 1, 1) |> DateTime |> BaseType.create<SomeWeekendInThe90s, _> with
| Ok (BaseType.Value dateTime) -> printf "%s" <| dateTime.ToShortDateString()
| Error msg -> printf "%s" msgYou can configure the generator to emit the Microsoft.FSharp.Core.AllowNullLiteral(false) attribute.
- Add a reference to FSharp.Core.
- Add the file
AD.BaseTypes.Generator.jsonto your project:
{
"AllowNullLiteral": false
}- Add the following
ItemGroupto your project file:
<ItemGroup>
<AdditionalFiles Include="AD.BaseTypes.Generator.json" />
</ItemGroup>Du you need model binding support for ASP.NET Core? Check out AD.BaseTypes.ModelBinders.
PM> Install-Package AndreasDorfer.BaseTypes.ModelBinders -Version 0.11.0
services.AddControllers(options => options.UseBaseTypeModelBinders());AD.BaseTypes.ModelBinders is in an early stage.
Do you use Swagger? Check out AD.BaseTypes.OpenApiSchemas.
PM> Install-Package AndreasDorfer.BaseTypes.OpenApiSchemas -Version 0.11.0
services.AddSwaggerGen(c =>
{
//c.SwaggerDoc(...)
c.UseBaseTypeSchemas();
});AD.BaseTypes.OpenApiSchemas is in an early stage.
Do you want to use your primitives in EF Core? Check out AD.BaseTypes.EFCore.
PM> Install-Package AndreasDorfer.BaseTypes.EFCore -Version 0.11.0
Apply a convention to your DbContext to tell EF Core how to save and load your primitives to the database.
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.AddBaseTypeConversionConvention();
}Your can also configure your types manually
builder.Property(x => x.LastName)
.HasConversion<BaseTypeValueConverter<LastName, string>>();or overrides the default convention with a custom converter.
builder.Property(x => x.FirstName)
.HasConversion((x) => x + "-custom-conversion", (x) => FirstName.Create(x.Replace("-custom-conversion", "")));