Skip to content

Fight primitive obsession and create expressive domain models with source generators.

License

Notifications You must be signed in to change notification settings

Andreas-Dorfer/base-types

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

NuGet Package

AD.BaseTypes

Fight primitive obsession and create expressive domain models with source generators.

NuGet Package

PM> Install-Package AndreasDorfer.BaseTypes -Version 1.6.0

TLDR

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 record

Motivation

Consider 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:

Source Generation

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);
}

But there's more!

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;

Included Attributes

The included attributes are:

  • BoolAttribute: any bool
  • DateTimeAttribute: any DateTime
  • DateTimeOffsetAttribute: any DateTimeOffset
  • DecimalAttribute: any decimal
  • DoubleAttribute: any double
  • GuidAttribute: any Guid
  • IntAttribute: any int
  • MaxIntAttribute: ints less than or equal to a maximal value
  • MaxLengthStringAttribute: strings with a maximal character count
  • MinIntAttribute: ints greater than or equal to a minimal value
  • MinLengthStringAttribute: strings with a minimal character count
  • MinMaxIntAttribute: ints within a range
  • MinMaxLengthStringAttribute: strings with a character count within a range
  • NonEmptyGuidAttribute: any Guid that's not empty
  • NonEmptyStringAttribute: any string that's not null and not empty
  • PositiveDecimalAttribute: positive decimals
  • RegexStringAttribute: strings that follow a certain pattern
  • StringAttribute: any string that's not null

There are examples in the test code.

JSON Serialization

The generated types are transparent to the serializer. They are serialized like the types they wrap.

Custom Attributes

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;

Multiple Attributes

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);
}

NuGet Package

Arbitraries

Do you use FsCheck? Check out AD.BaseTypes.Arbitraries.

NuGet Package

PM> Install-Package AndreasDorfer.BaseTypes.Arbitraries -Version 1.6.0

Example

[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();

Included Arbitraries

The included arbitraries are:

  • BoolArbitrary
  • DateTimeArbitrary
  • DateTimeOffsetArbitrary
  • DecimalArbitrary
  • DoubleArbitrary
  • ExampleArbitrary
  • GuidArbitrary
  • IntArbitrary
  • MaxIntArbitrary
  • MaxLengthStringArbitrary
  • MinIntArbitrary
  • MinLengthStringArbitrary
  • MinMaxIntArbitrary
  • MinMaxLengthStringArbitrary
  • NonEmptyGuidArbitrary
  • NonEmptyStringArbitrary
  • PositiveDecimalArbitrary
  • StringArbitrary

There are examples in the test code.


NuGet Package

F#

Do you want to use the generated types in F#? Check out AD.BaseTypes.FSharp. The BaseType and BaseTypeResult modules offer some useful functions.

NuGet Package

PM > Install-Package AndreasDorfer.BaseTypes.FSharp -Version 1.6.0

Example

match (1995, 1, 1) |> DateTime |> BaseType.create<SomeWeekendInThe90s, _> with
| Ok (BaseType.Value dateTime) -> printf "%s" <| dateTime.ToShortDateString()
| Error msg -> printf "%s" msg

Options

You can configure the generator to emit the Microsoft.FSharp.Core.AllowNullLiteral(false) attribute.

  1. Add a reference to FSharp.Core.
  2. Add the file AD.BaseTypes.Generator.json to your project:
{
  "AllowNullLiteral": false
}
  1. Add the following ItemGroup to your project file:
<ItemGroup>
  <AdditionalFiles Include="AD.BaseTypes.Generator.json" />
</ItemGroup>

NuGet Package

ASP.NET Core

Du you need model binding support for ASP.NET Core? Check out AD.BaseTypes.ModelBinders.

NuGet Package

PM> Install-Package AndreasDorfer.BaseTypes.ModelBinders -Version 0.11.0

Configuration

services.AddControllers(options => options.UseBaseTypeModelBinders());

Note

AD.BaseTypes.ModelBinders is in an early stage.


NuGet Package

Swagger

Do you use Swagger? Check out AD.BaseTypes.OpenApiSchemas.

NuGetPackage

PM> Install-Package AndreasDorfer.BaseTypes.OpenApiSchemas -Version 0.11.0

Configuration

services.AddSwaggerGen(c =>
{
    //c.SwaggerDoc(...)
    c.UseBaseTypeSchemas();
});

Note

AD.BaseTypes.OpenApiSchemas is in an early stage.


Entity Framework Core

Do you want to use your primitives in EF Core? Check out AD.BaseTypes.EFCore.

NuGetPackage

PM> Install-Package AndreasDorfer.BaseTypes.EFCore -Version 0.11.0

Configuration

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", "")));